هندسة عكسية لتطبيقات Flutter: لما التشفير يصير غلاف فخامة والباب مفتوح
تخيّل تشتري صندوق مقفول، والمفتاح ملزوق تحت الغطاء. هذا وصف دقيق لعدد غير قليل من تطبيقات البنوك على الموبايل. الترافيك مشفّر، الـ APK يبدو معقّد، والبروتوكول كله جالس داخل مكتبة native تنتظر أحد يقرأها بهدوء.
المقال يمشي معك سيناريو واقعي: تطبيق بنكي مبني بـ Flutter على أندرويد، يتكلم مع الـ backend عبر envelope مشفّر مخصص. نفك الـ APK، نسترجع منطق Dart من الـ AOT snapshot، نبني شكل الطلب كما هو بالضبط، نسجّل دخول، ونشوف كيف ثغرة تحقق ملكية على السيرفر تسمح لك تتصرف بحساب غير حسابك. بدون تعويذات وبدون "شغّل Frida وخلاص". التحليل الثابت أولاً.
وش اللي قدامنا أصلاً؟
تطبيق Flutter في وضع release مو كود Java مقروء. الواجهة ومنطق العمل مكتوبين بـ Dart ومتحولين مسبقاً (AOT) إلى مكتبة native:
lib/arm64-v8a/libapp.so # منطق التطبيق (Dart AOT snapshot)
lib/arm64-v8a/libflutter.so # محرك Flutter
لو فتحت libapp.so بديكمبايلر ARM64 عادي، غالب اللي تشوفه helpers تبع Dart VM. تحتاج أداة تفهم snapshot حسب نسخة Dart المستخدمة.
الأداة المناسبة هنا blutter. تكتشف نسخة Dart من محرك Flutter، تبني Dart VM مطابق، وتطلع لك:
- assembly لـ ARM64 مع أسماء classes و methods مسترجعة
- object pool (endpoints، أسماء headers، أسماء الحقول)
- قالب Frida لو احتجت dynamic لاحقاً
التسلسل المعتاد:
# استخراج المكتبات
unzip app.apk -d extracted
# أو: apktool d -s app.apk -o extracted
# تشغيل blutter على arm64-v8a
python3 blutter.py extracted/lib/arm64-v8a/ ./out/
# المخرجات المفيدة
ls out/asm/ # assembly مع أسماء الحزم
# out/pp.txt # object pool (نصوص وثوابت)
# out/objs.txt # dump للكائنات
بعد ما يخلص، توقف التخمين وتبدأ تقرأ أسماء حقيقية مثل EncryptionHelper::encryptAESParamsWithRSA و ApiService::_sendRequest.
ليش يهمك الموضوع؟
- الترافيك المشفّر مو صندوق أسود. إذا العميل يقدر يشفّر، العميل فيه الخوارزمية وسياسة المفاتيح وأسماء الحقول.
-
التعمية مو تفويض.
envelope أنيق من RSA+AES ما يمنع السيرفر من الثقة بحقل
from_accountجاي من العميل. - التحليل الثابت أوفر من الديناميكي. المحاكيات وFrida وحروب certificate pinning تسرق وقت. dump نظيف لـ Dart snapshot غالباً يجاوب سؤال شكل الـ wire من أول مرة.
- تطبيقات البنوك تحب crypto مخصص. راح تشوف نفس النمط: تشفير هجين، headers غريبة، JWT داخل الـ body، وعبارة "إحنا مشفرين إذن إحنا آمنين."
مثال عملي: استرجاع API بنكي من الصفر
السيناريو
عندك APK بنكي لأندرويد. الترافيك HTTPS مع Host مخصص. الـ body يشبه base64 عشوائي. الـ headers فيها أسماء مثل KEY و IV و SALT و SIGNATURE. السيرفر يرفض JSON صريح بـ 400 فاضي. الهدف: تتكلم البروتوكول صح، تسجّل، تدخل، وتجرّب endpoint التحويل.
الخطوة 1: ارسم السطح بدون تشغيل التطبيق
من object pool ومن assembly حق api_service تسترجع مسارات مثل:
-
user/register -
login -
user/me -
transaction/transfer -
transaction/history
وكمان قيمة Host ثابتة في التطبيق (السيرفر يتحقق منها).
تلقى أيضاً مفتاح عام PEM داخل الـ snapshot. هذا المفتاح العام للسيرفر، يُستخدم لتغليف المادة المتماثلة.
الخطوة 2: أعد بناء مسار التشفير
الدوال المهمة، بأسمائها المسترجعة، تمشي تقريباً كذا:
-
generateAESParams()- 32 بايت عشوائي → مفتاح AES
- 16 بايت → IV
- 16 بايت → salt
-
jsonEncode(body)بصيغة مضغوطة (بدون مسافات زيادة). -
generateSHA256Signature(json)→ digest سداسي عشري لـ UTF-8 حق الـ JSON. هذا يصير header اسمهSIGNATURE. -
encryptAES(json, key, iv)بـ AES-CBC مع padding على نمط PKCS7، والنتيجة base64. هذا هو body الطلب. -
encryptAESParamsWithRSA(key, iv, salt):- أولاً base64 لكل واحد من الثلاثة
- بعدين RSA-OAEP على نص الـ base64 (UTF-8) بالمفتاح العام
-
hash حق OAEP:
SHA-256
(يتوافق مع أسلوب
fast_rsa؛ SHA-1 يفشل بصمت) -
يرجع map:
encryptedKey,encryptedIv,encryptedSalt
- بناء الـ headers:
| Header | القيمة |
|---|---|
| Content-Type | text/plain |
| KEY | نتيجة RSA-OAEP للمفتاح |
| IV | نتيجة RSA-OAEP للـ IV |
| SALT | نتيجة RSA-OAEP للـ salt |
| SIGNATURE | SHA-256 سداسي عشري لنص JSON الأصلي |
| Host | المضيف البنكي المثبت في التطبيق |
الـ body: ciphertext الـ AES بعد base64 فقط. بدون غلاف JSON إضافي.
الخطأ اللي يضيع ساعات: تشفّر RSA على بايتات المفتاح الخام بدل base64(key). العميل يسوي base64 أولاً، بعدين OAEP على النص. انسخ هذا حرفياً وإلا كل طلب يرجع 400 فاضي.
الخطوة 3: طابق مخططات الطلب من toJson
من classes الطلب في الـ dump:
التسجيل تقريباً:
{
"device_id": "...",
"email": "...",
"first_name": "...",
"last_name": "...",
"middle_name": "",
"password": "...",
"username": "..."
}
تسجيل الدخول:
{
"email": "...",
"password": "..."
}
التحويل:
{
"amount": 1.01,
"auth": { "token": "<jwt>" },
"from_account": 12345678,
"remark": "ملاحظة اختيارية",
"to_account": 87654321
}
السجل / تفاصيل الحساب غالباً:
{ "token": "<jwt>" }
لاحظ التناقض: التحويل يحط JWT تحت auth.token، والسجل يستخدم token في الجذر. السيرفر دقيق. انسخ سلوك العميل.
الخطوة 4: فك تشفير الردود
السيرفر يعيد استخدام نفس مفتاح AES و IV اللي أرسلتهم (ملفوفين بـ RSA في الـ headers) لتشفير الرد بنفس الأسلوب. احتفظ بـ key و iv لنفس الطلب، فك base64 للرد، AES-CBC decrypt، انزع padding، بعدين JSON.
شكل رد الدخول (توضيحي):
{
"token": "eyJ...",
"pin": "AA...."
}
الـ PIN يتخزن محلياً بعد XOR/base64 لواجهة فتح القفل. مو مطلوب لكل استدعاء API بعد ما عندك JWT، لكن فكّه يعلّمك كيف التطبيق يحمي أسرار الجهاز (بشكل ضعيف).
الخطوة 5: اعرف رقم حسابك
/user/me ممكن يرجع unauthorized حسب طريقة التحقق. السجل غالباً يشتغل مع { "token": "..." } ويرجع قائمة تحويلات. المستخدم الجديد عادة عنده تحويل "مكافأة ترحيب" من حساب النظام إلى حسابه. حقل to_account في هذا التحويل هو رقم حسابك.
الخطوة 6: ثغرة التفويض
endpoint التحويل يقبل from_account من العميل بدون التحقق إن المستخدم المصادق يملك هذا الحساب. إذا حطيت from_account على حساب نظام معروف و to_account على حسابك، بمبلغ يقبله السيرفر (هنا: أكبر من 1)، التحويل ينجح.
الرد فيه حقل remark يتحكم فيه السيرفر أو يملأه لحساب النظام. في حادثة حقيقية ممكن يكون مذكرة داخلية أو مرجع دفع أو metadata حساس. في مختبر غالباً يكون الهدف اللي تدور عليه.
الخلاصة المرة: بعد كل RSA و AES والتواقيع و Flutter، الثغرة كانت غياب تحقق ملكية على حقل رقم صحيح واحد.
أمثلة كود
على السيرفر (توضيحي عام)
ضعيف
@app.post("/api/v1/transaction/transfer")
def transfer(req: TransferRequest, user=Depends(auth_user)):
# يثق بـ from_account القادم من العميل
debit(req.from_account, req.amount)
credit(req.to_account, req.amount)
return record_transfer(req)
العميل يقدر يحط أي from_account موجود.
مصلّح
@app.post("/api/v1/transaction/transfer")
def transfer(req: TransferRequest, user=Depends(auth_user)):
if req.from_account not in user.owned_accounts:
raise Forbidden("from_account not owned by caller")
if req.amount <= 1:
raise BadRequest("amount too small")
debit(req.from_account, req.amount)
credit(req.to_account, req.amount)
return record_transfer(req)
الملكية تُفرض على السيرفر. حقول العميل غير موثوقة افتراضياً.
تشفير العميل (نمط فقط)
افتراض خاطئ في إعادة التنفيذ بـ Python
# غلط: OAEP على بايتات المفتاح الخام
enc_key = rsa_oaep(aes_key)
مطابق للعميل الحقيقي
# صح: OAEP على base64(key) كنص UTF-8، مع SHA-256
enc_key = rsa_oaep(base64.b64encode(aes_key))
فرق سطر واحد. البروتوكول كله أخضر أو أحمر.
الدفاع / كيف تصلح
- لا تثق بحقول الهوية القادمة من العميل للتفويض. أرقام الحسابات والأدوار والأرصدة من جلسة السيرفر، مو من JSON.
- تشفير القناة مو تحكم وصول. TLS أو envelopes مخصصة توقف التنصت، مو الـ confused deputy.
- تحقق من المبالغ وانتقالات الحالة برسائل خطأ واضحة بدون ما تنهار العملية (سطر حالة HTTP فاضي هدية للمهاجم وكابوس للتشغيل).
- تجنّب شحن مفاتيح عامة طويلة العمر إن قدرت تستخدم mTLS أو مفاتيح جلسة قصيرة، ومع كذا افترض إن العميل عدائي بالكامل.
- لا تخترع صيغ تشفير إلا بمراجعة. فضّل stacks معروفة (TLS + auth قياسي). الصيغ الهجينة المخصصة مكان أخطاء SHA-1 ضد SHA-256 و 400 الصامت.
- التثبيت والتعمية يرفعون تكلفة الهندسة العكسية؛ ما يشيلون حاجة التحقق على السيرفر.
خاتمة
Flutter AOT يبان مخيف لين تعامل مع libapp.so كقطعة أثرية من الدرجة الأولى وتستخدم أدوات تتكلم Dart. مسار استرجاع البروتوكول ميكانيكي: اطلع الرموز، اكتب الـ headers، اكتب حقول toJson، طابق defaults مكتبة التشفير اللي التطبيق يستخدمها فعلاً، بعدين ابنِ عميل رفيع.
المفارقة تتكرر. فرق تصرف أسابيع على envelopes ومسرح XOR للـ PIN، وبعدين تترك from_account رقم حر. الصندوق كان مقفول. المفتاح تحت الغطاء. والصراف ما سأل عن الاسم على ورقة السحب.
إذا تبني APIs للموبايل، افترض إن كل حقل في الـ body تحت سيطرة المهاجم، حتى لو وصل داخل ثلاث طبقات من RSA و AES.