
Android CVE-2022-20474에 대한 상세 기술 분석 및 개념 증명(PoC)으로, 음수 길이의 LazyValue를 악용하여 스스로 변경되는 Bundle 동작을 유발하는 Bundle 불일치 취약점입니다.
참고: 이 글을 읽기 전에 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가 역직렬화 완료 후 재정렬된다는 점입니다. 다음 코드와 같습니다: