
تحليل تقني مفصّل وإثبات مفهوم لثغرة CVE-2022-20474 في أندرويد، وهي ثغرة عدم تطابق في Bundle تستغل LazyValue بطول سالب لتحقيق سلوك Bundle يتغيّر ذاتيًا.
ملاحظة مهمة، قبل قراءة هذا المقال، ينبغي أن يكون لديك فهم أولي للثغرات المتعلقة بعدم تطابق الحزمة (Bundle Mismatch). المواد المرجعية التالية إذا لم تكن قد قرأتها بعد، يُنصح بقراءتها أولاً:
لقد كنت أدرس مؤخراً مقال LeakValue لـ michalbednarski بدقة. أثناء المناقشة مع Canyie، قال إن هذه المقالة تذكر أيضاً حالة حزمة متغيرة ذاتياً (Self-changing Bundle) في سيناريو LazyValue، فذهبت للبحث عن النص الأصلي، وكان هناك بالفعل مثل هذه الفقرة التي فاتتني أثناء قراءة مقال ميشال. النص الأصلي يقول ما يلي:
(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)
ما يشير إليه ميشال هو على الأرجح CVE-2022-20474 (النشرة, التصحيح)، ألقيت نظرة على التصحيح، لكن الوظيفة في رابط التصحيح لم تكن كاملة، فقمت بإكمالها ثم درستها بعناية:
@@ -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 في الكود هو length في LazyValue، في الحقيقة هذا هو فقط طول الكائن المتغير المضمن في LazyValue، بينما يجب أن يكون طول LazyValue بأكمله محكوماً بحقل mLength، أي valueLength في الكود، والذي يُمرر إلى mLength في دالة البناء لـ LazyValue.
دعنا نقارن مع تنسيق تخطيط 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.
objectLength غير الطبيعيحيث يذكر أن objectLength يساوي -8 يسبب بعض المشاكل، مما أعطانا إلهاماً إضافياً. هل يمكن لـ LazyValue العمل بشكل طبيعي في هذه الحالة؟
@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 العادي، مثل قراءة ClassName لـ Parcelable، ثم تنفيذ createFromParcel، بعد اكتمال القراءة، لا يوجد فرق عن Key-Value العادي، ولا يؤثر على التسلسل اللاحق. بالرجوع مرة أخرى، الفكرة الأساسية لـ Self-changed Bundle هي التعديل بعد اكتمال القراءة، ولكن هذا مجرد قراءة عادية تتجاوز الحدود، ويبدو أن هذا الاتجاه غير قابل للتطبيق.
ماذا لو لم يتم تطبيق LazyValue خلال هذه العملية، أي أنه يستمر في المشاركة في IPC كهوية LazyValue، وعندها سيتم استدعاء دالة 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 بعد اكتمال إلغاء التسلسل، كما في الكود التالي: