Skip to content
• 7 دقائق للقراءة

Hybrid Mobile Reward Check Explained: لما التطبيق يوزع الجوائز على نفسه

تطبيق موبايل يقسم فحص الجوائز بين managed code ومكتبة native صغيرة، مع خلط و bytecode runner و anti-debug. كل ذلك لا ينقل القرار خارج الهاتف. هنا نفكك السلسلة ونعيد بناء التحويلات ونشغل دوال الـnative بالترتيب.

#Reverse Engineering #Mobile RE #Android #Native Code #Client-Side Trust
Hybrid Mobile Reward Check Explained: لما التطبيق يوزع الجوائز على نفسه hero illustration
تسجيل صوتي للمقال
قراءة آلية
0:00 / --:--

Hybrid Mobile Reward Check Explained: لما التطبيق يوزع الجوائز على نفسه

تخيل كشك جوائز فيه موظف يطلب منك ختم، والموظف يطلب من مساعد، والمساعد يفتح خزنة، والخزنة ترجع تسأل الموظف نفسه. أربعة توقيعات ووجوه جدية. ثم تكتشف أن الأربعة شخص واحد يبدل القبعة كل مرة.

هذه فكرة فحص الجوائز الهجين في تطبيقات الموبايل. جزء من المنطق في managed code مترجم مسبقا، وجزء في مكتبة native صغيرة، وبينهما ملف blob مرافق. الشكل يوحي أن هناك عمقا دفاعيا. الحقيقة أن جهازا واحدا يوافق على جائزته بنفسه.

في هذا المقال نفكك نموذجا عاما لهذه السلسلة: كيف تحصر سطح الـAPK، وكيف تسترجع المنطق المرافق، وكيف تنقل تحويلين صغيرين إلى سكربت، وكيف تشغل دوال الـnative بالترتيب الصحيح، ولماذا تبقى الثقة كلها في الـclient.


What Is a Hybrid Reward Check, Anyway?

فحص الجوائز الهجين هو مسار approval يعمل offline ومقسم بين runtime مختلفة:

UI button
  |
  v
managed validator
  |
  v
native seeder
  |
  v
managed mixing pass
  |
  v
native unsealer
  |
  v
managed stream pass
  |
  v
native runner + final open

الجزء الـmanaged يمكن decompile بعد فك حزمة الـahead-of-time. الجزء الـnative صغير عن قصد: تهيئة state، وفك برنامج مدمج، وتشغيله، ثم إظهار secret نصي قصير.

المهم ليس التقسيم. المهم مكان القرار. إذا كانت كل المدخلات (ملف مرافق، وبصمة الحزمة، ومصفوفة state) وكل النتائج (أرقام status بسيطة) تعيش على الهاتف، فالهاتف هو صاحب القرار.

التقسيم ليس حماية

التقسيم يرفع تكلفة التحليل. كل قفزة تجبرك تبدل الأداة: decompiler ثم disassembler ثم سكربت ثم harness.

لكنه لا يصنع trust boundary. الـclient يملك الملف والخوارزمية وسياسة المفاتيح. ومن يملك الجهاز يستطيع مراقبة كل قفزة بنفس القيم التي يستخدمها التطبيق.

كأنك وضعت قفلا داخل صندوق زجاجي له قسمان. قسمان، ونفس الزجاج.


Why Should You Care?

هذا التصميم يظهر عندما يريد الفريق مكافآت تعمل بدون شبكة:

  • جوائز الاستمرارية اليومية والكوبونات
  • أعلام premium وبوابات الميزات
  • ملفات ترخيص وعدادات تجريبية
  • عملات الألعاب والمقتنيات
  • أكواد ولاء يجب أن تعمل مع اتصال سيئ
التصميمالخطر الأساسي
Flag نصي في prefsتعديل مباشر
فحص managed واحدdecompile سريع وتجاوز
سلسلة managed مع nativeعمل أكثر، ونفس القرار المحلي
bytecode runner مرافقالبرنامج والمفسر معا، وكلاهما مقروء
كود single-use من serverالسر يبقى خارج الجهاز

الفرق المهم بين زيادة التكلفة ونقل الصلاحية.

مكتبة native ترفع التكلفة. توقيع server ينقل الصلاحية.


What Does the Chain Actually Do?

السلسلة العامة فيها ست مراحل. غالبا تكون متداخلة حتى تبدو أعمق:

1. Read bundled blob
2. Fingerprint the install package
3. Seed native state
4. Churn state with a managed pass
5. Unseal and unwrap the program
6. Run and open

لا تحتاج إتقان الست دفعة واحدة. ابدأ من handler الخاص بالزر وخله يرتب لك العمل.


Stage 1: Map the Surface Without Running the App

الـAPK ملف zip. ابدأ بالسرد.

unzip app.apk -d extracted
ls extracted/lib/arm64-v8a/
ls extracted/assets/

ابحث عن ثلاثة أشياء:

  1. مكتبات native لكل معمارية، ومنها مكتبة vendor صغيرة بجانب مكتبات الـruntime الكبيرة.
  2. ملف blob مرافق في assets. إذا كانت الجائزة تعمل في وضع الطيران، فكل مدخلات القبول موجودة على القرص.
  3. تجميعات managed مضغوطة داخل حزمة. بعد فك الضغط تحصل على DLLs عادية، واحدة لكل قطعة.

بعد فك الحزمة، ابحث عن handler الخاص بالزر و method الـclaim غير المتزامنة. كود الواجهة مزعج لكنه صريح. هو يسمي التسلسل كاملا: قراءة الملف، وبصمة الحزمة، و seed، وخلط، وفك، و stream، وتشغيل، وفتح أخير.

Why the install fingerprint matters

كثير من السلاسل تحسب hash لمقطع من حزمة التثبيت نفسها وتخلطه في الـseed. الفكرة ربط الملف بهذا البناء بالذات.

بالنسبة للمحلل، هذه مجرد input حتمية أخرى. اقرأ نفس المقطع من bytes الـAPK، ونفذ نفس الـhash (غالبا fold بسيط من عائلة FNV-1a)، وستحصل على نفس الكلمة التي يحسبها التطبيق. لا تحتاج جهازا.


Stage 2: Port the Two Managed Passes

غالبا توجد تحويلتان managed صغيرتان حول استدعاءات الـnative. تبدو مخيفة في الـIL ثم تتحول إلى حلقات قصيرة.

The mixing pass

النمط العام:

def churn(state):
    for round in range(ROUNDS):
        order = range(len(state)) if round % 2 == 0 else reversed(range(len(state)))
        for i in order:
            prev = state[(i - 1) & MASK]
            cur = state[i]
            nxt = state[(i + 1) & MASK]
            v = cur + rotl(prev, A) + rotr(nxt, B)
            v = (v * C) & MASK32
            state[i] = rotl(v, D)
    return state

الخطأ المشهور هنا إسقاط الحد الأوسط. الـstack في الـIL يحتفظ بالكلمة الحالية أثناء تحميل الجيران، فالمجموع فيه ثلاثة أجزاء وليس اثنين. انقل الكود سطرا بسطر واختبر الشكل: input ثابت الحجم و output ثابت الحجم وحتمية كاملة.

The stream pass

المرحلة الثانية تبني keystream كتلة بعد كتلة من الـstate، ثم XOR مع bytes البرنامج:

def unwrap(program, state):
    out = bytearray(len(program))
    block = 0
    pos = 0
    st = list(state)
    while pos < len(program):
        ks = keystream_block(st, block)
        n = min(len(ks), len(program) - pos)
        for i in range(n):
            out[pos + i] = program[pos + i] ^ ks[i]
        pos += n
        block += 1
    return bytes(out)

كل تحديث كتلة هو rotations وجمع وضرب على مصفوفة الكلمات، ثم سكب الكلمات إلى bytes بترتيب big endian. مرة أخرى لا server ولا nonce. نفس الدخل يعطي نفس الخرج كل مرة.


Stage 3: Drive the Native Exports in Order

المكتبة الـnative صغيرة عن قصد. صادراتها بالمعنى العام:

seed(blob, blob_len, install_word, out_state)
unseal(churned_state, out_program, inout_len)
run(program, program_len, churned_state)
final_open()

أكواد الرجوع أرقام بسيطة. الصفر يعني أكمل. غير ذلك يعني توقف. هذه البساطة في صالحك.

أبسط harness يفعل ما يفعله handler الخاص بالزر:

cells = new_state()
assert native.seed(blob, install_word, cells) == 0
cells = churn(cells)
prog, prog_len = new_buffer()
assert native.unseal(cells, prog, prog_len) == 0
clean = unwrap(prog_bytes, cells)
assert native.run(clean, cells) == 0
assert native.final_open() == 0

ملاحظتان من تجارب حقيقية:

  1. شغل الـharness على صندوق Linux نظيف بنفس المعمارية. فحوصات الـanti-debug (حالة الـtracer و process maps و probes على loopback لمنافذ الـhooking الشائعة) تنجح بهدوء في بيئة نظيفة، وغالبا لن تحتاج أي patch.
  2. تحميل مكتبة مبنية لأندرويد على لينكس المكتبي قد يفشل بسبب أسماء مثل libc.so مقابل libc.so.6 وعلامات الإصدار. يكفي shim صغير يعيد تصدير حفنة دوال libc مع runpath صحيح. لا تحتاج emulator.

إذا كان الـseed ينجح والـunseal يفشل باستمرار، راجع نقلك للـmanaged أولا قبل اتهام البصمة. حد واحد ناقص في الخلط يكفي لجعل كل الفحوص التالية تفشل بصمت.


Stage 4: The Final Open and the Commit Helper

الدالة الأخيرة غالبا تفعل ثلاثة أشياء: تجهز مخزن مفاتيح قصير، وتستدعي verifier داخليا مع جداول read-only ومخزن خرج بحجم secret نصي قصير، ثم تسلم المخزن إلى helper داخلي.

للتحليل، تجاوز المراسم. بعد نجاح المراحل السابقة، استدع الـverifier الداخلي مباشرة مع مخزن خرج من عندك ونفس الجداول التي يستخدمها الغلاف. عند النجاح يرجع صفرا ويملأ مخزنك بسلسلة الجائزة. لا debugger، فلا يشتعل أي tripwire خاص بالـtracer.

هذه هي الخلاصة. التطبيق أمضى أربع قفزات ليقنع نفسه بالاحتفال. وأنت أعدت نفس القفزات وأحضرت طبقك معك.


Vulnerable Code Examples

أنماط عامة، وليست نسخا من أي تطبيق.

Claim flow

Vulnerable

async def claim_reward():
    blob = read_bundled_blob()
    fp = fingerprint_install_package()
    state = [0] * N
    if native.seed(blob, fp, state) != 0:
        return
    state = churn(state)
    prog = bytearray(M)
    if native.unseal(state, prog) != 0:
        return
    clean = unwrap(prog, state)
    if native.run(clean, state) != 0:
        return
    if native.final_open() == 0:
        show_reward()

الهاتف يقرأ ويخلط ويفك ويشغل ويحكم. كل القيم محلية.

Patched

async def claim_reward(account_token):
    nonce = await backend.get_nonce(account_token)
    proof = build_local_proof(nonce)
    result = await backend.redeem(account_token, nonce, proof)
    if result.approved:
        show_reward(result.code)

القبول وإصدار الكود يحدثان خارج الجهاز. الـclient يعرض فقط.

Native helper

Vulnerable pattern

int check_claim(const unsigned char *blob, size_t n,
                uint32_t fp, uint32_t *state) {
    derive(blob, n, fp, state); // all inputs local
    return 0; // caller treats 0 as deserved
}

قرار محلي من مدخلات محلية. قابل للـpatch وإعادة التشغيل.

Better pattern

int verify_claim(const unsigned char *nonce, size_t nn,
                 const unsigned char *sig, size_t sn,
                 const unsigned char *pubkey) {
    return sig_verify(sig, nonce, nn, pubkey); // fail closed
}

التزوير يحتاج مفتاح server الخاص، وليس صبرا مع disassembler.


Defense / How to Fix

  1. انقل القبول إلى server. الـclient يطلب، والـserver يوافق ويصدر كود single-use.
  2. اربط الجوائز بالهوية والوقت. حساب مع nonce مع انتهاء، وموقع من الـserver.
  3. اجعل الـclient يبرهن ولا يقرر. عمل الجهاز يبني proof. والتحقق خارج الجهاز.
  4. لا تشحن مخزن الجوائز. لا برامج تطبع الأسرار، ولا مفاتيح تفكها، ولا جداول كاملة.
  5. اعتبر anti-tamper تأخيرا. أبق الفحوص إذا أعجبتك، لكن لا تبن نموذج التهديد عليها.
  6. افشل بالإغلاق وسجل. أي فشل تحقق محلي يمنع ويطلق حدثا مرئيا للـserver.
  7. دوّر واسحب. أكواد قصيرة العمر مع denylist تحول التسريب إلى حادث، وليس نموذج عمل.

Testing / Audit Points

What to look forWhy it matters
الجائزة تعمل في وضع الطيرانالصلاحية محلية
الزر يقود إلى seed وفك وتشغيل وفتحأكواد الحالة نقاط patch
بصمة الحزمة من bytes الحزمةقابلة لإعادة الإنتاج يعني قابلة للسكربت
خلط managed مع فك streamيمكن نقلها لسكربت في أمسية واحدة
runner صغير مع bytecodeالبرنامج والمفسر معا
فحوصات tracer و maps و loopbackضجيج يتجاوزه التشغيل النظيف غالبا

أسئلة مراجعة سريعة:

  1. هل يحتوي الـAPK على كل ما يلزم للقبول؟
  2. هل توجد nonce أو توقيع server في مسار الـclaim؟
  3. هل يمكن استدعاء كل دالة native من harness؟
  4. هل النتائج أرقام بسيطة؟
  5. هل نقل فحص توقيع واحد إلى server يكسر كل التجاوز؟

إذا كانت الإجابات نعم ولا ونعم ونعم ونعم، فأمامك مراسم وليس حدا أمنيا.


Common Myths

“كود native يعني أمان.”

لا. يعني وقتا أطول. مساعد صغير بأربع صادرات هو عطلة نهاية أسبوع، وليس جدارا.

“الترجمة المسبقة تخفي المنطق.”

تخفيه عن البحث النصي. فك الحزمة وستقرأ managed code عاديا فيه rotations و XOR.

“منع الـdebugging يوقف الـreversing.”

هو يزعج الـdebuggers المرفقة. إعادة التشغيل النظيفة بالمدخلات الصحيحة غالبا لا تلمسه أصلا.

“الـbytecode المخصص مثل التشفير.”

لا. runner مع برنامجه، وكلاهما مشحون معا، هو لغز ومفتاحه ملصق على ظهره.


Final Thoughts

التعقيد الذي يبقى على جهاز واحد هو مجرد وقت انتقال. احصر القفزات، وانقل الحلقتين بدقة، وشغل الصادرات بالترتيب، واجعل روتين النهاية يكتب في مخزنك.

الكشك فيه أربعة موظفين وزبون واحد. وكنت أنت الخمسة.

أبق الاحتفال على الـclient. وأبق القرار على الـserver.


References

⌘
Suggested Searches