[이상동몽] 창문쟁이가 안드로이드를 시작하면 생기는 일 Part 0 (KR)

0. 서론
안녕하세요. Libera입니다. 지난 추석은 재미있게 보내셨는지요.
그동안 Windows 커널을 중점으로 Windows에 대해서만 연구를 해왔는데요, 이번에는 windows에서 벗어나서 안드로이드에서 연구를 진행해 보려고 합니다. 갑자기 왜 갑자기 안드로이냐 하면, 제가 굉장한 장비충이기 때문입니다 ㅎㅎ… 남자들의 로망일 수도 있는 집에 여러가지 장비들을 두는걸 좋아해서 IOT와 안드로이드를 하면 좋겠다 생각은 하고 있었습니다. 그런데 마침 이번에 팀 내에서 프로젝트를 진행할 때 안드로이드 프로젝트가 눈에 들어와 잠깐 윈도우는 집어 넣고, 안드로이드를 해 보기로 했습니다 ㅎㅎ.

제가 속한 팀명은 [이상 동몽]입니다. 같이 안드로이드에서 취약점을 찾자라는 같은 “몽”을, 대신 삼성과 픽셀이라는 다른 “상”을 가져 이상 동몽입니다. 저는 그중에서도 구글 픽셀을 전담해 깊게 파고들 예정입니다. 지금은 지갑사정이 좋지 않아 픽셀 폰을 구매하지 못했지만 이번 프로젝트에서 취약점을 찾아 그 바운티로 본격적으로 진행하려고 합니다 ㅎㅎ. 그래서 저는 애뮬레이션으로 진행할 수 있는 자바 프레임워크 단에서 연구를 진행하려고 합니다.
이번 글에서는 안드로이드 생태계의 통신 구조인 IPC 메커니즘을 먼저 짚고 넘어가려 합니다. 그 중에서도 특히, 프로세스 간 격리된 메모리 장벽을 넘어 데이터를 직렬화하여 전달할 때 발생하는 논리 취약점인 ‘Parcel Mismatch’를 메인으로 다루려고 합니다. 그럼 시작해 볼까요?
1. 안드로이드 IPC와 데이터 전달 구조
안드로이드 생태계에서 앱들은 기본적으로 서로 독립된 프로세스에서 실행되며 격리되어 있습니다. 그렇기 때문에 A라는 앱 또는 Activity에서 B로 데이터를 넘기기 위해서는 Intent.putExtra("username", Libera") 와 같이 IPC 기반의 통신 방식을 사용해야 합니다.
이 과정에서 데이터가 어떻게 포장되고 전달되는지 이해하려면 다음 세가지 핵심 개념을 알아야 합니다.
- Intent (인텐트) : “어떤 화면으로 이동해라”, “어떤 서비스를 실행해라” 와 같은 목적지와 행동 지침을 담는 객체입니다. 데이터를 전달할 때는 이 Intent의
Extra속성을 활용하게 됩니다. - Extra 속성 : Intent에는 단순히 목적지 뿐만 아니라, 그 목적지에서 작업을 수행하는 데 필요한 추가적인 데이터를 함께 보낼 수 있습니다. 이때 데이터를 담아두기 위해 Intent 내부에 마련된 공간(속성)이 바로
Extra입니다. - Bundle (번들) : Intent의
Extra속성에 실질적인 데이터를 묶어서 담기 위해 사용하는 객체 입니다. 내부적으로는 메모리 효율을 높인 Map 자료구조를 사용해 Key-Value 쌍으로 데이터를 관리합니다. - Parcel (파셀) : 번들에 담긴 데이터를 프로세스 간에 전송할 수 있도록 바이트 형태로 직렬화 하여 담아두는 컨테이너(메모리 버퍼)입니다.
즉, 데이터를 Bundle로 묶어서 ⇒ Intent의 Extra 공간에 싣고 ⇒ 이를 Parcel이라는 상자에 담아 바이트로 압축(직렬화)한 뒤 ⇒ Binder 커널 드라이버를 통해 다른 프로세스로 배송한다. 이게 안드로이드에서 IPC 통신 흐름 이라고 이해하시면 됩니다.
데이터를 굳이 직렬화 하는 이유는 프로세스 간 격리된 메모리 장벽을 Binder 커널 드라이버를 통해 무사히 통과하려면 Parcel이라는 상자에 데이터를 규격에 맞게 직렬화해서 담는 과정이 반드시 필요하기 때문입니다.
2. Parcel
Parcel은 안드로이드 IPC에서 프로세스 간 데이터를 전송하기 위해 사용하는 메모리 버퍼이자 직렬화 컨테이너입니다. 이 버퍼에는 일반적인 문자열, 상수, 바이트 배열 등 원시(Primitive) 타입 데이터뿐만 아니라, 안드로이드 전용 IPC 직렬화 인터페이스인 Parcelable을 구현한 커스텀 객체도 담을 수 있습니다.
여기서 Parcelable 이란 안드로이드 환경에서 객체를 빠르고 효율적으로 전달하기 위해 고안된 특수한 데이터 규격(인터페이스)입니다. 자바의 기본 직렬화 방식인 Serializable은 내부적으로 리플렉션을 사용해 처리 속도가 느리고 메모리 오버헤드가 크다는 단점이 있습니다. 이를 해결하기 위해 안드로이드 OS는 Parcelable을 도입하여, 객체를 바이트로 포장하고 푸는 과정을 시스템에 맡기지 않고 개발자가 직접 코드로 명시하도록 강제함으로써 IPC 통신 성능을 극대화했습니다.
또한 Parcel은 Array, List, Map, SparseArray와 같은 표준 Java Collection 객체를 직접 읽고 쓰는 메서드(writeList, readMap 등)를 제공합니다. 여기서 이 컬렉션 자료구조들이 중요한 이유는, 내부적으로 또 다른 Parcelable 객체나 하위 컬렉션을 포함하는 중첩(Nested) 구조를 띠기 때문입니다.
Parcelable 객체를 직렬화하고 역직렬화 하는 과정은 다음과 같이 이루어 집니다.
- 직렬화 (Write) : 송신자는
writeToParcel메서드를 오버라이딩해 멤버 변수들을 정의된 순서와 크기대로 Parcel 버퍼에 기록합니다. - 역직렬화 (Read) : 수신자는
createFromParcel메서드를 통해 데이터 스트림에서 바이트를 읽어 들여 메모리 상에 객체를 재구성합니다.
여기서 보안상 가장 핵심적인 전제 조건은 직렬화(Write)할 때 기록한 데이터의 타입, 크기, 순서가 역직렬화(Read)할 때의 로직과 정확히 일치해야 한다는 점입니다.
그 이유는 Parcel이 데이터를 저장하는 방식 때문인데, Parcel은 기본적으로 내부 데이터의 경계를 따로 저장하지 않는 단순하고 연속된 바이트 스트림입니다. “여기까지가 정수형이고, 여기서부터가 문자열이다”라는 식별표가 없기 때문에 데이터 구조체의 경계는 오로지 개발자가 작성한 순서와 로직에 의해서만 정의가 됩니다.
따라서 송신자가 쓴 순서와 크기에서 단 1바이트 만이라도 어긋나게 읽는 순간, 이어지는 모든 후속 데이터들의 의미가 완전히 달라지게 됩니다.
2-1. 실제 바이트 확인
윈도우만 하다가 안드로이드를 하게 되니 잘 와닿지 않는 부분이 많더라구요, 직접 바이트를 확인해 보면 경계가 없는 연속된 바이트 스트림이 뭔지 좀 더 알기 쉬울거에요. 아래는 가장 단순한 Bundle입니다. 가장 최신 안드로이드를 에뮬로 올려서 확인해 봤습니다.
Bundle b = new Bundle();
b.putString("team", "이상동몽");
b.putInt("part", 0);
00000000 40 00 00 00 42 4e 44 4c 02 00 00 00 04 00 00 00 @...BNDL........
00000010 70 00 61 00 72 00 74 00 00 00 00 00 01 00 00 00 p.a.r.t.........
00000020 00 00 00 00 04 00 00 00 74 00 65 00 61 00 6d 00 ........t.e.a.m.
00000030 00 00 00 00 00 00 00 00 04 00 00 00 74 c7 c1 c0 ............t...
00000040 d9 b3 bd ba 00 00 00 00 ........
BNDL은 매직으로 이 부분을 발견하면 여기서 부터 Bundle이구나 라고 생각하면 됩니다. 바이트를 보시면 인자에 넣어준 String이 그대로 보이는걸 알 수 있습니다. “이상동몽”은 0x0038부터 들어가 있습니다 ㅎㅎ.
전체 72바이트를 구간으로 쪼개 보면 이렇게 생겼습니다.
머리 8바이트
0x0000 40 00 00 00 길이 L = 64 ← 매직 뒤부터 끝까지의 바이트 수
0x0004 42 4e 44 4c "BNDL" 매직 ← 여기서부터 Bundle
본문 64바이트 (0x0008 ~ 0x0047, 이게 L이 가리키는 범위)
0x0008 02 00 00 00 엔트리 개수 N = 2
0x000c 엔트리 0 key "part" + 타입태그 01(VAL_INTEGER) + 값 0
0x0024 엔트리 1 key "team" + 타입태그 00(VAL_STRING) + 값 "이상동몽"
엔트리 1을 더 쪼개면
0x0024 04 00 00 00 key 길이 = 4 (글자 수)
0x0028 74 00 65 00 61 00 6d 00 key "team" (UTF-16LE)
0x0030 00 00 00 00 NUL + 정렬 패딩
0x0034 00 00 00 00 타입태그 = 0 → VAL_STRING
0x0038 04 00 00 00 값 길이 = 4 (글자 수)
0x003c 74 c7 c1 c0 d9 b3 bd ba 값 "이상동몽" (UTF-16LE)
0x0044 00 00 00 00 NUL + 정렬 패딩
머리 8바이트가 일종의 헤더입니다. 0x0004 의 BNDL 을 보면 여기서부터 Bundle이고, 0x0000 의 L=64는 매직 뒤 부터 64바이트가 이 Bundle의 몸통이라는 뜻입니다. 길이를 앞에 적어두면 내용을 안 봐도 통째로 건너뛸 수 있죠. 이 아이디어를 밑에서 Lazy Bundle 절에서 다시 만나게 됩니다.
여기서 하나 구분해 둘게 있는데, 0x0000의 L은 Bundle 하나 전체의 길이입니다. 밑에서 볼 Lazy Bundle의 접두사는 엔트리 하나하나마다 따로 붙는 다른 숫자입니다. 지금 이 덤프에 엔트리별 접두사가 안 보이는 건, 값이 String이나 int처럼 크기가 자명한 타입이면 접두사를 붙이지 않기 때문입니다.
이제 타입 태그를 보겠습니다. 값마다 태그가 하나씩 붙습니다. part 앞에는 01(VAL_INTEGER), team 앞에는 00(VAL_STRING)이 있죠. 읽는 쪽은 이 숫자를 보고 “다음은 int를 읽어야겠다, String을 읽어야겠다”를 결정합니다.
그런데 이 태그도 그냥 4바이트 숫자일 뿐이라 위치가 밀리면 엉뚱한 걸 태그로 읽습니다. 같은 바이트를 두 가지 방식으로 읽어 봤습니다.
[A] 쓴 대로 읽기
0x0000 길이 L = 64
0x0004 매직 = 0x4c444e42
0x0008 엔트리 개수 N = 2
#0 key(0x000c)="part" 타입태그(0x001c)=1 → VAL_INTEGER 값 = 0
#1 key(0x0024)="team" 타입태그(0x0034)=0 → VAL_STRING 값 = "이상동몽"
남은 바이트 = 0
[B] 첫 값을 4바이트 덜 읽은 파서
0x0000 길이 L = 64
0x0004 매직 = 0x4c444e42
0x0008 엔트리 개수 N = 2
#0 key(0x000c)="part" 타입태그(0x001c)=1 → VAL_INTEGER 값 = 0
↑ 이 값을 4바이트 덜 읽었다고 가정하고 되감음
#1 key(0x0020)=null 타입태그(0x0028)=6619252 → 정의되지 않은 타입 값 = (읽을 수 없어 중단)
남은 바이트 = 28
[B]의 6619252가 어디서 왔을까요. 0x0028을 덤프에서 찾아보면 74 00 65 00 입니다. 원래 "team"의 t와 e, 문자열의 글자 두 개였던 바이트가 자료형 번호로 읽힌 것이죠. 키는 null이 되고, 28바이트가 읽히지 않고 남습니다. 단 4바이트 때문에요.
눈에 걸리는 게 하나 더 있습니다. 0x0024의 04 00 00 00은 원래 "team"의 길이 4인데, 하필 VAL_PARCELABLE의 번호도 4입니다. 밀림이 저 자리를 타입 태그로 읽게 만든다면 파서는 “여기 객체가 있구나” 하고 클래스를 찾아 인스턴스를 만들려고 합니다. 이게 밑에서 볼 Self-changing Bundle이 노리는 지점입니다.
참고로 이 타입 태그는 안드로이드 12에도 있었습니다. Parcel이 값을 읽을 때 뭘 읽어야 할지 알려면 반드시 필요하니까요. 안드로이드 13이 추가한 건 태그가 아니라 항목마다 붙는 길이 접두사입니다. 즉 “이게 무슨 타입인지”는 원래부터 적혀 있었고, “여기까지가 한 항목”이라는 울타리가 없었던 게 문제였습니다.
그럼 이어서 Intent를 한번 확인해 볼까요? 이번엔 위 Bundle을 Extras에 넣은 Intent를 열어 봤습니다.
Intent i = new Intent("com.libera.lab.ACTION_PROBE");
i.setData(Uri.parse("content://com.libera.lab/notes/7"));
i.addCategory(Intent.CATEGORY_DEFAULT);
i.putExtra("team", "이상동몽");
i.putExtra("part", 0);
00000000 1b 00 00 00 63 6f 6d 2e 6c 69 62 65 72 61 2e 6c ....com.libera.l
00000010 61 62 2e 41 43 54 49 4f 4e 5f 50 52 4f 42 45 00 ab.ACTION_PROBE.
00000020 01 00 00 00 20 00 00 00 63 6f 6e 74 65 6e 74 3a .... ...content:
00000030 2f 2f 63 6f 6d 2e 6c 69 62 65 72 61 2e 6c 61 62 //com.libera.lab
00000040 2f 6e 6f 74 65 73 2f 37 00 00 00 00 ff ff ff ff /notes/7........
00000050 ff ff ff ff 00 00 00 00 00 00 00 00 ff ff ff ff ................
00000060 ff ff ff ff 00 00 00 00 01 00 00 00 1f 00 00 00 ................
00000070 61 6e 64 72 6f 69 64 2e 69 6e 74 65 6e 74 2e 63 android.intent.c
00000080 61 74 65 67 6f 72 79 2e 44 45 46 41 55 4c 54 00 ategory.DEFAULT.
00000090 00 00 00 00 00 00 00 00 fe ff ff ff 40 00 00 00 ............@...
000000a0 42 4e 44 4c 02 00 00 00 04 00 00 00 70 00 61 00 BNDL........p.a.
000000b0 72 00 74 00 00 00 00 00 01 00 00 00 00 00 00 00 r.t.............
000000c0 04 00 00 00 74 00 65 00 61 00 6d 00 00 00 00 00 ....t.e.a.m.....
000000d0 00 00 00 00 04 00 00 00 74 c7 c1 c0 d9 b3 bd ba ........t.......
000000e0 00 00 00 00 00 00 00 00 ........
Bundle을 봤던 것과 똑같이 Write하는 순서가 그대로 보이는 연속된 바이트 스트림을 확인할 수 있습니다.
그런데 이 덤프만 보면 “그래서 Bundle은 정확히 어디서 어디까지고, Parcel은 어디야?” 라는 생각이 들더라구요. 그래서 한번 확인을 해봤습니다.

이제 한눈에 볼 수 있습니다. Intent와 Parcel은 이 전체를 이야기 하고, Bundle이 그 안에 존재합니다.
그럼 Intent와 Parcel은 무슨 차이냐 싶은데요. Intent는 내용, Parcel은 그 내용을 적어 넣은 버퍼입니다. Parcel은 자신의 필드가 따로 없습니다. 전부 Intent가 가진 값이거나, Parcel의 쓰기 규칙 때문에 생긴 부수적 바이트 (문자열 길이 4바이트, NUL, 4바이트 정렬 패딩) 입니다.
밑에서 설명할 Parcel Mismatch는 안드로이드 12를 기준으로 한 내용인데, 위에서 본 값마다 붙는 타입 태그는 그때도 그대로 있었습니다. 없었던 건 항목마다 붙는 길이 접두사뿐이니 그 점만 염두에 두고 봐주시면 됩니다 ㅎㅎ.
3. Parcel Mismatch
수신자 쪽에서 Parcel 버퍼에 대한 읽기 작업이 발생하면, 파서는 그 안에 있는 모든 내용을 끝까지 순차적으로 역직렬화 해버립니다.
](image_1.png)
위 사진을 보면 바깥쪽 ArrayList 컨테이너 안에 문자열 “abc”, 정수 123, Parcelable (P1) , 그리고 또 다른 리스트인 List L1 이 들어있는걸 확인할 수 있습니다. 그리고 이 List L1 내부를 열어 보면 다시 문자열, 정수, Parcelable , Serializable 등 다양한 요소들이 겹겹이 중접(Nested) 되어 있습니다.
즉, 읽기 절차가 시작되면 단순히 겉면만 읽고 끝나는 것이 아니라, 맨 앞의 문자열(“abc”)부터 시작해 가장 깊은 곳에 있는 Map 안의 요소들까지 꼬리에 꼬리를 물고 모든 요소가 연쇄적으로 역직렬화 대상이 됩니다.
그런데 만약 특정한 로직 오류나 개발자의 실수로 인해 “쓰는 양”과 “읽는 양”의 불일치가 발생한다면 어떻게 될까요? 아래의 코드를 살펴 봅시다.
Parcelable Write (송신 로직)
public void writeToParcel(Parcel parcel, int flags) {
parcel.writeInt(f1);
parcel.writeByteArray(f2);
}
Parcelable Read (수신 로직)
f1 = parcel.readInt();
if (f1 > 0) {
parcel.readByteArray(f2);
}
](image_2.png)
위의 취약한 코드 패턴을 보면, 송신할 때는 정수 f1 과 바이트 배열 f2 를 무조건 직렬화하여 버퍼에 작성합니다. 반면 수신할 때는 정수 f1 의 값이 0보다 작거나 같을 경우, if 조건문에 의해 readByteArray(f2) 가 실행되지 않아 배열 읽기 작업이 통째로 누락됩니다.
](image_3.png)
위 사진은 단 하나의 로직 불일치로 인해 파싱 오프셋이 어긋나버리는 순간입니다.
안드로이드 파서는 첫 데이터에서 당장 예외(Exception)가 발생하지 않는 한, 멈추지 않고 후속 데이터에 대한 파싱을 계속 시도합니다. 하지만 이전 단계에서 읽지 않고 남겨둔 바이트 배열의 잔여 데이터가 버퍼에 그대로 남아있기 때문에, 이어지는 모든 후속 데이터들은 완전히 잘못된 위치(오프셋)부터 엉뚱하게 파싱되며 치명적인 순차적 데이터 손상으로 이어집니다.
4. Self-changing Bundle
단순한 데이터 손상처럼 보이는 이 Parcel Mismatch 문제는, 해커들에 의해 Self-changing bundle이라는 강력한 무기로 악용될 수 있습니다.
Bundle b = new Bundle();
// Fill b
Bundle c = new Bundle(b.writeToParcel());
// b.writeToParcel => 직렬화(Serialization)
// new Bundle() => 역직렬화(Deserialization)
Self-changing bundle이란 원문 Bundle(b)을 한 번 직렬화했다가 다시 역직렬화하여 새로운 Bundle(c)에 할당했을 때, 그 내용이 논리적으로 변해버리는(b != c) 기형적인 Bundle을 의미합니다.
이 마술이 어떻게 공격에 쓰이는지, 프로세스 A(공격자) ➔ B(보안 검증) ➔ C(최종 실행)로 이어지는 IPC 흐름을 통해 살펴보겠습니다.
공격자 A의 목표는 방어 시스템 B가 볼 수 없도록 악성 데이터(예: intent => 42)를 교묘하게 숨겨서 통과시킨 뒤, 최종 목적지인 C에서 이를 실행하게 만드는 것입니다.
](image_4.png)
공격에 사용될 취약한 패키지는 위 사진처럼 “읽을 때는 4바이트(readInt)만 읽고, 쓸 때는 8바이트(writeLong)를 쓰는” 불일치 오류를 가지고 있습니다.
](image_5.png)
이 취약점을 이용한 공격 시나리오는 다음과 같이 전개됩니다.
(사진의 A(H)는 번들 헤더를, A0는 취약한 패키지를, A1은 숨겨둔 악성 Intent를 나타냅니다)
A ➔ B 전송 및 B의 파싱 (TOC: Time of Check):
A가 B에게 데이터를 보냅니다. B가 데이터를 검사하기 위해 역직렬화할 때, 첫 번째 취약한 요소(A0)에서
readInt()가 호출되어 4바이트만 읽습니다.
그 뒤에 교묘하게 배치된 악성 Intent(A1)는 객체로 파싱되지 않고 단순한 바이트 배열 데이터로 취급되어 B의 보안 검사를 무사히 통과(패싱)합니다.B ➔ C 재전송 과정에서의 변이:
검사를 마친 B가 데이터를 C로 넘기기 위해 다시 직렬화를 수행합니다. 이때 취약한 패키지에서
writeLong()이 호출되면서, 원래 4바이트 크기였던 ‘0’ 뒤에 의미 없는 4바이트 0이 추가로 덧붙여져 총 8바이트가 쓰이게 됩니다.C의 파싱 및 권한 탈취 (TOU: Time of Use):
C가 B에게 받은 데이터를 다시 역직렬화합니다. C 역시 취약한 요소에 대해
readInt()를 수행하여 8바이트 중 앞의 4바이트만 읽고 멈춥니다.
바로 이 순간, B에서 생성된 의미 없는 나머지 4바이트가 파싱 포인터에 남게 됩니다. C는 이 남은 4바이트를 다음 항목(A1)의 Key 길이로 착각하게 됩니다.
결과적으로 파싱 위치가 정확히 밀려버리면서, 이전까지는 단순한 데이터 취급을 받던 바이트 배열이 역직렬화 대상이 됩니다. 파싱 과정을 거치며 완벽한 형태의 새로운 악성 Intent 객체로 부활하게 되고, 결국 권한 탈취 등의 치명적인 문제로 이어지게 됩니다.
5. 안드로이드 13 ‘Lazy Bundle’ 도입
이러한 Parcel Mismatch가 시스템 전체를 뒤흔드는 재앙이 될 수 있었던 근본적인 이유는, 안드로이드 12까지의 우선적인 역직렬화(Eager Deserialization) 방식 때문이었습니다.
안드로이드 12 환경에서는 Bundle 내부의 특정 항목 하나만 쿼리하더라도, 전체 Bundle이 한꺼번에 역직렬화되어 버렸습니다. 즉, 당장 사용하지 않는 나머지 항목들까지 모두 파싱되어 실제 객체로 변환된 뒤 Map에 적재되었던 것이죠.
](image_6.png)
위 사진처럼 Parcel 버퍼(원통)에 P1과 P2를 차례대로 삽입했다고 가정해 보겠습니다. 만약 P1 클래스에 불일치가 발생해 쓰는 양보다 적게 읽어버린다면, 두 번째 항목인 k2와 P2를 읽을 때 잘못된 오프셋부터 시작하게 되어 번들 전체의 해석이 망가집니다. 이 문제는 구조체의 경계가 오로지 데이터(페이로드)와 개발자의 코드에 의해서만 정의되었기 때문에 피할 수 없는 한계였습니다.
](image_7.png)
이를 해결하기 위해 구글은 아주 직관적이고 강력한 방어책을 내놓습니다. 한 항목의 불일치가 다른 항목에 영향을 미치지 않도록, 직렬화에 사용된 바이트 수를 접두사(Prefix item length)로 명시하여 붙이도록 구조를 바꾼 것입니다.
이렇게 각각의 항목 앞에 길이를 적어두면, 설령 내부 객체(P1)에서 Mismatch가 발생하더라도 파서는 당황하지 않고 접두사에 적힌 길이만큼만 정확히 건너뛰어 다음 항목(P2)의 올바른 오프셋으로 접근할 수 있게 됩니다.
](image_8.png)
이러한 길이 정보를 바탕으로 안드로이드 13부터 도입된 핵심 기능이 바로 ‘Lazy Bundle(지연 번들)’입니다.
이제는 Bundle을 열쇠로 딸 때 모든 항목을 한꺼번에 쏟아내어 역직렬화하지 않습니다. 예를 들어 사용자가 첫 번째 항목만 쿼리할 경우, 첫 번째 항목만 실제 객체(Live Object)로 역직렬화되고 나머지 항목들은 Map에 ‘지연 값(Lazy Value)’이라는 형태로 저장됩니다.
이 지연 값 컨테이너는 압축된 백킹 Parcel(원본 바이트 스트림)에 대한 참조와 각 항목을 나중에 역직렬화하는 데 필요한 메타데이터만을 머금고 있다가, 실제로 해당 데이터가 필요할 때만 역직렬화를 수행합니다.
결과적으로 불필요한 연쇄 읽기를 차단(Lazy)하고, 항목 간의 경계(Prefix)를 명확히 함으로써 Self-changing Bundle과 같은 악의적인 오프셋 조작 공격을 원천적으로 차단해 낸 것입니다.
5-1 길이 접두사를 바이트로 확인해 보자.
위 P1, P2 그림이 실제 바이트에서는 어떻게 생겼는지 확인해 보겠습니다. Bundle에 Parcelable 2개를 넣어서 진행해 보았습니다.
Bundle b = new Bundle();
b.putParcelable("k1", new Intent("A"));
b.putParcelable("k2", new Intent("B"));
00000000 14 01 00 00 42 4e 44 4c 02 00 00 00 02 00 00 00 ....BNDL........
00000010 6b 00 31 00 00 00 00 00 04 00 00 00 74 00 00 00 k.1.........t...
00000020 16 00 00 00 61 00 6e 00 64 00 72 00 6f 00 69 00 ....a.n.d.r.o.i.
00000030 64 00 2e 00 63 00 6f 00 6e 00 74 00 65 00 6e 00 d...c.o.n.t.e.n.
00000040 74 00 2e 00 49 00 6e 00 74 00 65 00 6e 00 74 00 t...I.n.t.e.n.t.
00000050 00 00 00 00 01 00 00 00 41 00 00 00 00 00 00 00 ........A.......
00000060 ff ff ff ff ff ff ff ff 00 00 00 00 00 00 00 00 ................
00000070 ff ff ff ff ff ff ff ff 00 00 00 00 00 00 00 00 ................
00000080 00 00 00 00 00 00 00 00 fe ff ff ff ff ff ff ff ................
00000090 00 00 00 00 02 00 00 00 6b 00 32 00 00 00 00 00 ........k.2.....
000000a0 04 00 00 00 74 00 00 00 16 00 00 00 61 00 6e 00 ....t.......a.n.
빨간색으로 칠해둔 74 00 00 00 을 계산해 보면 116입니다. 이것이 안드로이드 13에서 추가된 길이 접두사 입니다. 이게 적용되면 아래와 같이 해석이 됩니다.
1번 항목의 payload 시작 0x0020
+ 길이 접두사 116 (0x74)
───────────────────
2번 항목 key 시작 0x0094 ← 실제 덤프의 k2 위치와 정확히 일치
즉, 1번 항목의 내용과는 무관하게 0x001c 에 적힌 길이 접두사로 2번 항목이 어디서 부터 시작되는지를 알 수 있는 것이죠. 1번 항목에서 mismatch가 발생하더라도 파서는 1번 항목의 시작점 + 116으로 점프해서 2번 항목을 정상적으로 읽게 되는것이죠.
6. CVE-2025-48535

위에서 안드로이드 12에서 왜 Parcel Mismatch가 발생하는지와 안드로이드 13에서 이를 어떻게 패치했는지에 대해서 이야기를 해봤는데요. 이렇게 이론적인 내용만 다루면 너무 아쉬우니 최근에는 Parcel Mismatch가 어떻게 발생하는지를 확인해 봅시다!
Lazy Bundle이 도입된 이후에도 Mismatch를 유발하는 기법이 등장했는데, 바로 CVE-2025-48535 취약점입니다. 이 취약점은 시스템 권한을 가진 Settings 앱에서 발생했으며, 공격자가 원하는 임의의 Activity를 시스템 권한으로 실행할 수 있게 만드는 취약점 입니다. 핵심 원인은 우리가 알아볼 Mismatch의 한 종류인 Creator Mismatch입니다.
그럼 과연 Creator Mismatch는 무엇일까요?
안드로이드 에서 Parcelable 객체를 전송할 때, 데이터를 쓰는 쪽과 읽는 쪽의 기준이 완전히 다르기 때문에 발생하는 불일치 현상을 말합니다.
- 직렬화 (쓰기): 객체의 ‘실제 런타임 타입’이 가진
writeToParcel()메서드를 사용합니다. - 역직렬화 (읽기): 코드가 기대하는 ‘선언된 타입’의 정적 메서드인
CREATOR를 사용합니다.
공격자는 이 비대칭성을 노려, 평범한 부모 클래스인 Intent 대신 그 하위 클래스인 LabeldIntent 객체에 악성 데이터를 담아 Settings 앱으로 보냈습니다.
아래 AOSP 실제 코드를 보면 LabeledIntent는 직렬화를 할 때 부모(Intent)의 필드를 먼저 쓴 뒤, 자신만의 고유한 4개의 꼬리 필드(라벨, 아이콘 등)를 추가로 덧붙여서 보냅니다.
// frameworks/base/core/java/android/content/pm/LabeledIntent.java:28
public class LabeledIntent extends Intent {
private String mSourcePackage;
private int mLabelRes;
private CharSequence mNonLocalizedLabel;
private int mIcon;
// :162 — 부모를 먼저 쓰고, 4개 필드를 덧붙인다
public void writeToParcel(Parcel dest, int parcelableFlags) {
super.writeToParcel(dest, parcelableFlags);
dest.writeString(mSourcePackage); // String16
dest.writeInt(mLabelRes);
TextUtils.writeToParcel(mNonLocalizedLabel, dest, parcelableFlags); // CharSequence
dest.writeInt(mIcon);
}
}
여기서 한가지 짚고 넘어가자면 공격자 앱에서 Settings로 넘어오는 이 첫 번째 직렬화는 아무 문제가 없습니다. Bundle에는 클래스명이 android.content.pm.LabeledIntent로 적혀 들어가고, 받는 쪽은 그 문자열을 보고 LabeledIntent.CREATOR를 골라 씁니다. 쓸 때도 LabeledIntent, 읽을 때도 LabeledIntent니까 짝이 맞고, 꼬리 4개까지 정확히 읽힙니다. 즉 이 단계가 끝나면 Settings는 꼬리 값이 다 채워진 온전한 LabeledIntent 객체를 손에 들고 있고, 읽히지 않고 남은 바이트 같은 건 없습니다.
그렇다면 뭐가 문제냐 싶을텐데요? Creator Mismatch는 그 다음에 터집니다. Settings가 이 객체를 다시 직렬화해서 다른 프로세스로 보낼 때입니다.
- 보내는 쪽 :
writeToParcel()을 호출하는데, 이건 인스턴스 메서드라 객체의 실제 런타임 타입인LabeledIntent의 것이 불립니다. 그래서 부모 + 꼬리 4개가 나갑니다. - 받는 쪽 : 그 자리에 적힌 선언 타입의
CREATOR를 씁니다. 그게Intent.CREATOR면 부모 스키마까지만 읽고 멈춥니다.
이러면 꼬리 4개는 읽히지 않고 Parcel에 남게 되고, 그 뒤에 쓰여 있던 다른 데이터들의 자리로 밀려 들어갑니다.
6-1. 공격자가 보낸 Bundle 덤프
그럼 공격자가 실제로 뭘 보내는지, 그리고 앞에서 본 길이 접두사가 이 payload에서는 어떻게 동작하는지 확인해 보겠습니다.
Bundle out = getResultExtras(true);
out.putParcelable(Intent.EXTRA_RESTRICTIONS_INTENT,
new LabeledIntent(base, "text/plain", PackageManager.MATCH_DEFAULT_ONLY, 0x4141));
setResultExtras(out);
00000000 30 01 00 00 42 4e 44 4c 01 00 00 00 28 00 00 00 0...BNDL....(...
00000010 61 00 6e 00 64 00 72 00 6f 00 69 00 64 00 2e 00 a.n.d.r.o.i.d...
00000020 69 00 6e 00 74 00 65 00 6e 00 74 00 2e 00 65 00 i.n.t.e.n.t...e.
00000030 78 00 74 00 72 00 61 00 2e 00 72 00 65 00 73 00 x.t.r.a...r.e.s.
00000040 74 00 72 00 69 00 63 00 74 00 69 00 6f 00 6e 00 t.r.i.c.t.i.o.n.
00000050 73 00 5f 00 69 00 6e 00 74 00 65 00 6e 00 74 00 s._.i.n.t.e.n.t.
00000060 00 00 00 00 04 00 00 00 cc 00 00 00 20 00 00 00 ............ ...
00000070 61 00 6e 00 64 00 72 00 6f 00 69 00 64 00 2e 00 a.n.d.r.o.i.d...
00000080 63 00 6f 00 6e 00 74 00 65 00 6e 00 74 00 2e 00 c.o.n.t.e.n.t...
00000090 70 00 6d 00 2e 00 4c 00 61 00 62 00 65 00 6c 00 p.m...L.a.b.e.l.
000000a0 65 00 64 00 49 00 6e 00 74 00 65 00 6e 00 74 00 e.d.I.n.t.e.n.t.
000000b0 00 00 00 00 19 00 00 00 63 6f 6d 2e 6c 69 62 65 ........com.libe
000000c0 72 61 2e 63 76 65 34 38 35 33 35 2e 50 52 4f 42 ra.cve48535.PROB
000000d0 45 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff E...............
000000e0 00 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ................
000000f0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000100 fe ff ff ff ff ff ff ff 00 00 00 00 0a 00 00 00 ................
00000110 74 00 65 00 78 00 74 00 2f 00 70 00 6c 00 61 00 t.e.x.t./.p.l.a.
00000120 69 00 6e 00 00 00 00 00 00 00 01 00 01 00 00 00 i.n.............
00000130 ff ff ff ff 41 41 00 00 ....AA..
0x006c 를 보면 android.content.pm.LabeledIntent 가 그대로 박혀 있는게 보입니다. 앞에서 이야기했던 받는 쪽에서 어떤 CREATOR가 돌지 결정하는 그 클래스명 LabeledIntent가 여기에 있는거죠. 그 뒤 0x00b4 부터가 부모 Intent의 필드들이고, 0x010c 부터 0x0138 까지가 문제의 꼬리 4개입니다.
0x0068 을 보면 길이접두사가 보이는데 이 값 204는 제대로 역할을 하고 있습니다. 이 항목에 만약 Bundle 항목이 더 있었다면 LabeledIntent안에서 무슨 일이 벌어지든 다음 항목은 정상적으로 읽힙니다.
이 Bundle에서 공격자가 확보한건 클래스명을 LabeledIntent로 적어 둬서, Settings가 그 타입으로 객체를 복원하게 만드는 것입니다. 이 객체가 Settings 앱에서 꺼내진 다음 문제가 발생하는데 길이 접두사가 없는 다른 통로로 다시 직렬화 되는데 이때도 객체는 여전히 LabeledIntent입니다. 즉 꼬리 4개가 그대로 다시 Write되어 나가고, 여기서 말하는 통로는 밑에서 볼 Settings가 PMS에게 검사를 요청하는 Binder 호출입니다.
7. Creator Mismatch로 발생한 LaunchAnyWhere.
Settings 앱은 이 LabeledIntent 객체를 실행하기 전에 “이게 안전한 목적지인가?”를 검사합니다. 그런데 이 검사를 직접하는게 아닌 시스템 서비스(PMS)에게 확인을 합니다. 즉 객체를 PMS로 보내야 하고 PMS에 보내려면 다시 직렬화를 해야 합니다. 바로 이 지점이 위에서 말한 다시 직렬화 해서 보내는 지점입니다.
// Settings 프로세스 안에서 동작하는 코드
// AppRestrictionsFragment.java:662
private Intent assertSafeToStartCustomActivity(Intent intent) {
[1] // 공격자가 선택한 객체가 Binder 경계를 넘어 PMS로 전달된다!
// 직렬화 -> 전송 -> PMS가 역직렬화 -> 결과 수신까지 왕복 한번이 이 한줄에서 끝난다.
ResolveInfo resolveInfo = mPackageManager.resolveActivity(
intent, PackageManager.MATCH_DEFAULT_ONLY); // :668
// ... (중간 검사 로직) ...
[2] // new Intent가 런타임 타입을 평문 Intent로 되돌리는 정규화.
// 정규화 코드가 있긴 한데 — 검사가 다 끝난 다음에 수행된다.
final Intent vettedIntent = new Intent(intent); // :683
vettedIntent.setComponent(activityInfo.getComponentName()); // :684
vettedIntent.setPackage(activityInfo.packageName); // :685
return vettedIntent;
}
위 코드의 [1] 에 호출하는 resolveActivity는 내부적으로 AIDL을 통해 시스템 서비스(PMS)와 통신합니다. Settings가 객체를 직렬화해 PMS로 보내고, PMS가 resolve한 결과(ResolveInfo)를 받아옵니다. 이때 호출되는 Binder 인터페이스는 다음과 같이 생겼습니다.
// frameworks/base/core/java/android/content/pm/IPackageManager.aidl:133
ResolveInfo resolveIntent(in Intent intent, String resolvedType, long flags, int userId);
in Intent intent인자 하나 뒤에 string, long, int 세 개의 인자가 차례대로 기다리고 있습니다. Stub(수신측)이 순서대로 인자를 읽어 들일 때, 바로 이 지점에서 아까 버퍼에 남아있던 꼬리 필드들이 뒤따르는 인자들의 자리로 쫙 밀려 들어가며 파싱되어 버립니다!
이 호출은 Binder 트랜잭션 한 번으로 나갑니다. 위 선언에 적힌 4개의 인자가 Parcel 하나에 순서대로 담겨 커널 Binder 드라이버를 건너고, 받는 쪽 Stub이 같은 순서로 꺼내 읽는 식입니다. AIDL은 이 순서를 적어둔 선언이고 실제로 프로세스 경계를 건너는 건 이 Parcel입니다.
Bundle 안에서는 각 항목 앞에 길이가 붙어 있어서 괜찮았지만, 이 Parcel의 인자들은 in Intent 다음에 String, long, int 가 아무 접두사도 없이 순차적으로 Write 되어 있습니다. 그래서 한번 Mismatch가 발생하면 문제가 발생하는 것이죠.
실제로 트랜잭션 Parcel이 어떻게 되어 있는지 한번 확인해 볼까요?
00000000 01 00 00 00 19 00 00 00 63 6f 6d 2e 6c 69 62 65 ........com.libe
00000010 72 61 2e 63 76 65 34 38 35 33 35 2e 50 52 4f 42 ra.cve48535.PROB
00000020 45 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff E...............
00000030 00 00 00 00 00 00 00 00 ff ff ff ff ff ff ff ff ................
00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000050 fe ff ff ff ff ff ff ff 00 00 00 00 0a 00 00 00 ................
↑ 0x005c 부터가 꼬리 ───────────────────┘
00000060 74 00 65 00 78 00 74 00 2f 00 70 00 6c 00 61 00 t.e.x.t./.p.l.a.
00000070 69 00 6e 00 00 00 00 00 00 00 01 00 01 00 00 00 i.n.............
00000080 ff ff ff ff 41 41 00 00 ff ff ff ff 00 00 01 00 ....AA..........
↑ 0x0088 부터가 "의도한" arg1~arg3
00000090 00 00 00 00 00 00 00 00 ........
핵심은 두 부분입니다. Write 하는 쪽은 arg0을 0x0088 까지 Write 했는데, 읽는 쪽의 Intent.CREATOR 는 0x005c 부터 읽기 시작하는 것이죠, 즉 이 사이 44바이트가 Mismatch가 발생한 부분인 것입니다.
이 오프셋 밀림(Shift) 현상으로 인해 값들이 어떻게 왜곡되는지 표로 정리해볼까요?
| AIDL 인자 | Settings가 의도한 값 | PMS가 실제로 읽는 값 (오프셋 밀림) | 오프셋으로 보면 |
|---|---|---|---|
intent |
공격자 Intent | 공격자 Intent (여기까진 정상) | 0x0000~0x005c |
resolvedType |
null | “text/plain” ➔ mSourcePackage |
0x005c |
flags |
0x10000 |
0x0000000100010000 ➔ 하위 32비트가 mLabelRes, 상위 32비트가 TextUtils 마커 |
0x0078~0x0080 |
userId |
0 | -1 ➔ 라벨 문자열의 길이 필드 | 0x0080 |
결과적으로 시스템이 의도 했던 검사 조건은 모두 제 역할을 하지 못하고, 공격자가 조작한 플래그와 조건으로 치환됩니다. 이 조작된 값들을 바탕으로 PMS가 반환한 resolveInfo 는 공격자 패키지를 가리키고 Settings 앱은 이를 안전하다고 착각하게 되어 시스템 권한(System UID)으로 실행을 해버리게 됩니다.
그럼 과연 패치는 어떻게 했을 까요?
패치는 정규화 코드를 검사 호출부 앞으로 옮겨서 진행하는 것으로 단 한줄의 패치로 취약점이 막히게 되었습니다. 기존 코드 [2]에 있던 new Intent(intent) 는 필드 값은 유지하되 런타임 타입만 평문 Intent 로 되돌리는 역할을 하는데 이 코드를 앞으로 당긴 것이죠.
private Intent assertSafeToStartCustomActivity(Intent intent) {
[1] // 정규화를 Binder 경계 앞으로 올림 (하위 클래스 꼬리표 사전 제거)
+ final Intent vettedIntent = new Intent(intent);
ResolveInfo resolveInfo = mPackageManager.resolveActivity(
[2] // 검사 대상도 원본이 아닌 정규화된 사본으로 교체
- intent, PackageManager.MATCH_DEFAULT_ONLY); // 제거됨.
+ vettedIntent, PackageManager.MATCH_DEFAULT_ONLY);
// ...
// 늦게 하던 정규화는 제거
- final Intent vettedIntent = new Intent(intent); // 제거됨.
vettedIntent.setComponent(activityInfo.getComponentName());
vettedIntent.setPackage(activityInfo.packageName);
return vettedIntent;
}
이제 Binder 경계를 넘기기 ‘전’에 정규화가 수행되면서, 하위 클래스가 덧붙인 꼬리 필드들이 사전에 말끔하게 제거됩니다. 직렬화 시 오직 평문 Intent 스키마만 남게 되어 Creator Mismatch 발생되지 않도록 차단합니다.
마치며

지금까지 안드로이드 IPC 통신의 근간인 Intent, Bundle, Parcel의 개념부터 시작해, 아주 작은 직렬화 불일치(Mismatch) 하나가 어떻게 권한 탈취로 이어지는지, 그리고 구글이 이를 어떻게 아키텍처 개선(Lazy Bundle)으로 막아냈는지, 그리고 최근에 발생한 Parcel Mismatch 1-day까지 살펴보았습니다.
이처럼 안드로이드 생태계는 윈도우와는 또 다른 매력이 있는것 같습니다. 물론 윈도우를 기반으로 OS 흐름을 이해하고 있는 저에게는 이해가 안되는 부분이 많았지만 (특히 제가 새로운 핸드폰들을 살 수 있게 자기 합리화 할 수 있는 아주 좋은 분야 같습니다 ㅎㅎ)
이번 Part0을 통해 안드로이드가 데이터를 주고 받는 방식에 대해 알 수 있었습니다. 다음 글에서는 저희 [이상동몽] 팀의 다른 팀원이 또 다른 부분에 대해 이야기하는 글로 돌아오겠습니다.
긴 글 읽어 주셔서 감사합니다. 지금까지 이상동몽 팀의 Libera 였습니다! (다음엔 꼭 실물 픽셀 폰을 들고 인사드리겠습니다 ㅋㅋ)
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.