Hybrid Mobile Reward Check Explained: لما التطبيق يوزع الجوائز على نفسه
تطبيق موبايل يقسم فحص الجوائز بين managed code ومكتبة native صغيرة، مع خلط و bytecode runner و anti-debug. كل ذلك لا ينقل القرار خارج الهاتف. هنا نفكك السلسلة ونعيد بناء التحويلات ونشغل دوال الـnative بالترتيب.
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/
ابحث عن ثلاثة أشياء:
- مكتبات native لكل معمارية، ومنها مكتبة vendor صغيرة بجانب مكتبات الـruntime الكبيرة.
- ملف blob مرافق في assets. إذا كانت الجائزة تعمل في وضع الطيران، فكل مدخلات القبول موجودة على القرص.
- تجميعات 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
ملاحظتان من تجارب حقيقية:
- شغل الـharness على صندوق Linux نظيف بنفس المعمارية. فحوصات الـanti-debug (حالة الـtracer و process maps و probes على loopback لمنافذ الـhooking الشائعة) تنجح بهدوء في بيئة نظيفة، وغالبا لن تحتاج أي patch.
- تحميل مكتبة مبنية لأندرويد على لينكس المكتبي قد يفشل بسبب أسماء مثل
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
- انقل القبول إلى server. الـclient يطلب، والـserver يوافق ويصدر كود single-use.
- اربط الجوائز بالهوية والوقت. حساب مع nonce مع انتهاء، وموقع من الـserver.
- اجعل الـclient يبرهن ولا يقرر. عمل الجهاز يبني proof. والتحقق خارج الجهاز.
- لا تشحن مخزن الجوائز. لا برامج تطبع الأسرار، ولا مفاتيح تفكها، ولا جداول كاملة.
- اعتبر anti-tamper تأخيرا. أبق الفحوص إذا أعجبتك، لكن لا تبن نموذج التهديد عليها.
- افشل بالإغلاق وسجل. أي فشل تحقق محلي يمنع ويطلق حدثا مرئيا للـserver.
- دوّر واسحب. أكواد قصيرة العمر مع denylist تحول التسريب إلى حادث، وليس نموذج عمل.
Testing / Audit Points
| What to look for | Why it matters |
|---|---|
| الجائزة تعمل في وضع الطيران | الصلاحية محلية |
| الزر يقود إلى seed وفك وتشغيل وفتح | أكواد الحالة نقاط patch |
| بصمة الحزمة من bytes الحزمة | قابلة لإعادة الإنتاج يعني قابلة للسكربت |
| خلط managed مع فك stream | يمكن نقلها لسكربت في أمسية واحدة |
| runner صغير مع bytecode | البرنامج والمفسر معا |
| فحوصات tracer و maps و loopback | ضجيج يتجاوزه التشغيل النظيف غالبا |
أسئلة مراجعة سريعة:
- هل يحتوي الـAPK على كل ما يلزم للقبول؟
- هل توجد nonce أو توقيع server في مسار الـclaim؟
- هل يمكن استدعاء كل دالة native من harness؟
- هل النتائج أرقام بسيطة؟
- هل نقل فحص توقيع واحد إلى server يكسر كل التجاوز؟
إذا كانت الإجابات نعم ولا ونعم ونعم ونعم، فأمامك مراسم وليس حدا أمنيا.
Common Myths
“كود native يعني أمان.”
لا. يعني وقتا أطول. مساعد صغير بأربع صادرات هو عطلة نهاية أسبوع، وليس جدارا.
“الترجمة المسبقة تخفي المنطق.”
تخفيه عن البحث النصي. فك الحزمة وستقرأ managed code عاديا فيه rotations و XOR.
“منع الـdebugging يوقف الـreversing.”
هو يزعج الـdebuggers المرفقة. إعادة التشغيل النظيفة بالمدخلات الصحيحة غالبا لا تلمسه أصلا.
“الـbytecode المخصص مثل التشفير.”
لا. runner مع برنامجه، وكلاهما مشحون معا، هو لغز ومفتاحه ملصق على ظهره.
Final Thoughts
التعقيد الذي يبقى على جهاز واحد هو مجرد وقت انتقال. احصر القفزات، وانقل الحلقتين بدقة، وشغل الصادرات بالترتيب، واجعل روتين النهاية يكتب في مخزنك.
الكشك فيه أربعة موظفين وزبون واحد. وكنت أنت الخمسة.
أبق الاحتفال على الـclient. وأبق القرار على الـserver.