تخيل مطورًا قرر أن يجعل فحص كلمة المرور "غير قابل للكسر".
فشفر دوال الـvalidation، وفك تشفيرها وقت التشغيل، ووضعها داخل memory جديدة قابلة للتنفيذ، وبنى Custom Virtual Machine، وبدّل ترتيب أحرف كلمة المرور، ثم أضاف طبقات من arithmetic فوق كل ذلك.
النتيجة تبدو مخيفة جدًا.
تقريبًا بالدرجة التي تجعلك تتوقع أن يطلب البرنامج منك شهادة في هندسة الحاسوب قبل أن يسمح لك بكتابة أول character.
لكن هناك مشكلة صغيرة جدًا.
الـclient نفسه يجب أن يعرف كيف يقرر هل كلمة المرور صحيحة أم لا.
وهذا يعني أن التعليمات والقيم والمنطق الذي يحتاجه لكي يتخذ هذا القرار يجب أن يظهر في مرحلة ما أثناء التشغيل.
هنا تبدأ المتعة.
في هذا المقال سنفكك نموذجًا عامًا لـpassword validator شديد الـobfuscation، ونمشي من أول ptrace()، مرورًا بـruntime decryption والـCustom VM، وصولًا إلى الـmath الذي يحدد النتيجة فعلًا.
What Is Client-Side Password Validation, Anyway?
الـClient-Side Password Validation يعني ببساطة أن الجهاز الذي أمام المستخدم هو الذي يقرر هل الـpassword صحيحة.
أبسط مثال يمكن أن يكون:
if password == "correct-horse-battery-staple":
print("Correct!")
هذا طبعًا لا يحتاج reverse engineering.
يحتاج فقط أن تفتح الملف وتقول: "شكرًا على التعاون."
لكن يمكن أن يصبح الشكل هكذا:
Encrypted code
|
v
Runtime decryption
|
v
Executable memory
|
v
Custom VM
|
v
Password permutation
|
v
Arithmetic
|
v
Comparison
الفرق أن النسخة الثانية تحتاج وقتًا أكثر.
لكنها لا تغيّر الـtrust boundary.
إذا كان الـclient يحتوي على كل ما يحتاجه ليعرف الـsecret الصحيح، فالشخص الذي يملك الـclient ويستطيع مراقبة تشغيله يستطيع في النهاية مراقبة هذه المعلومات أيضًا.
Obfuscation ليست Security Boundary
فكر فيها كأنك وضعت مفتاح الخزنة داخل صندوق زجاجي، ثم أضفت عليه ثلاثة أقفال شكلية، ودخان، وصفارة إنذار.
الصندوق أصبح مزعجًا أكثر.
لكن المفتاح ما زال داخل الصندوق.
الـObfuscation ترفع تكلفة التحليل.
لا تنقل السر خارج الـclient.
Why Should You Care?
هذا النوع من التصميم يظهر عندما يحاول المطور حماية logic حساس مع إبقاء الـverification محليًا.
من الأمثلة:
- Desktop license checks
- Offline activation
- Game key validation
- Proprietary client logic
- Mobile applications
- أدوات تعتمد على secret محلي
- Reverse engineering challenges
والفرق المهم هنا هو الفرق بين زيادة تكلفة الهجوم وإنشاء حد أمني حقيقي.
| التصميم | الخطر الأساسي |
|---|---|
| Plaintext comparison | استخراج السر شبه فوري |
| Hash داخل الـclient | يمكن مهاجمة الـcheck أو brute-force محليًا |
| Encrypted validation code | يمكن التقاطه بعد فك التشفير |
| Custom VM | يضيف عملًا أكثر، لكنه لا يغيّر مكان السر |
| Server-side verification | يبقي السر خارج الـclient |
الـVM قد يجعل حياتك أصعب.
لكنه لا يجعل حياة الـsecret أكثر أمانًا.
What Does the Obfuscated Validator Actually Do?
غالبًا يمكن تقسيم هذا النوع من الـvalidator إلى ست مراحل:
1. Anti-debugging
2. Runtime decryption
3. VM initialization
4. Input preparation
5. Mathematical validation
6. Final success/failure branch
المشكلة أن هذه المراحل تكون متداخلة.
ولهذا قد تفتح الـdecompiler وتجد مئات الأسطر من الكود، ثم تنظر إليها لمدة عشر دقائق وتبدأ في الشك أن الكمبيوتر شخصيًا لديه مشكلة معك.
لا تحاول فهم كل شيء.
اعزل المرحلة التي تعطيك معلومة جديدة، ثم انتقل لما بعدها.
Stage 1: ابدأ من الـRuntime Behavior
قبل أن تغوص في الـdecompiler، راقب ما يفعله البرنامج فعليًا.
مثلًا:
ltrace ./validator
قد تحصل على شيء مثل:
malloc(...)
mmap(...)
mmap(...)
mmap(...)
ptrace(...)
exit(...)
الـmmap() المتكرر مهم.
والـptrace() أهم.
وجود الاثنين معًا يخبرك أن البرنامج قد يكون:
- ينشئ memory regions وقت التشغيل
- يكتب فيها code
- يحاول منع الـdebugging
وهو بالضبط النوع من السلوك الذي يستحق التحقيق قبل قراءة مئات الأسطر.
لماذا يستخدم البرنامج ptrace()؟
أحد الأساليب الشائعة للـAnti-Debugging هو:
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
الفكرة بسيطة:
إذا كان process تحت trace بالفعل، قد يفشل الاستدعاء.
والبرنامج يعتبر الفشل علامة على وجود debugger.
ثم يغادر.
بالنسبة للمحلل، هذه ليست نهاية الطريق.
هي فقط نقطة تحكم واضحة.
في بيئة reversing مخصصة، يمكنك مراقبة نتيجة الاستدعاء أو تعديلها أثناء الـdebugging حتى تكمل العملية التنفيذ الطبيعي.
الفكرة هنا ليست "كسر anti-debugging بطريقة سحرية".
الفكرة هي أن أي branch واضح يمنعك من المتابعة يجب عزله عن بقية التحليل.
Stage 2: ابحث عن الـRuntime-Decrypted Code
افتح الـbinary في Ghidra أو IDA أو Binary Ninja.
قد تجد function مسؤولة عن تجهيز runtime memory.
النمط الذي يهمنا يشبه:
void *region = mmap(
NULL,
size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0
);
ثم يتم نسخ أو فك بيانات إلى المنطقة الجديدة.
وجود:
READ
WRITE
EXECUTE
في memory region جديدة ليس شيئًا تريد أن تمر عليه وكأنك لم تره.
قد يكون:
- Runtime code generation
- Unpacking
- JIT-like behavior
- Decryption
- Self-modifying code
السؤال الأفضل ليس:
"كيف أفهم الـencrypted bytes؟"
بل:
"كيف تبدو هذه الـbytes بعد أن ينتهي البرنامج من تجهيزها؟"
هذا التغيير الصغير في السؤال يوفر عليك وقتًا محترمًا.
Dumping الـMapped Regions
يمكن استخدام GDB لالتقاط المنطقة بعد رجوع mmap() بالعنوان.
مثلًا:
break mmap
commands
silent
set $len = $rsi
finish
set $addr = $rax
printf "mapped %p, size %lu\n", $addr, $len
dump memory /tmp/region.bin \
$addr \
$addr+$len
continue
end
في التطبيق الحقيقي، ستحتاج غالبًا إلى حفظ كل region في ملف مستقل بدل الكتابة فوق نفس الملف.
بعد ذلك، disassemble للـregions التي التقطتها.
قد تجد أن كل واحدة تبدأ بتسلسل صالح مثل:
endbr64
ثم تعليمات machine code عادية.
وهنا تبدأ الصورة بالوضوح.
البرنامج لم يجعل الـcode غير قابل للقراءة.
هو فقط أجّل لحظة ظهوره.
Stage 3: اللحظة التي تعرف فيها أنك أمام VM
في مرحلة لاحقة، ستجد function تشبه execution loop.
مثلًا:
for (;;) {
uint8_t opcode = *ip;
if (opcode == VM_EXIT)
break;
handlers[opcode](
&ip,
&sp,
&stack,
&state
);
}
لو رأيت:
- Instruction pointer
- Opcode
- Dispatch table
- Handler functions
- Loop
- Exit opcode
فأنت غالبًا أمام Virtual Machine.
أصبح لديك الآن CPU صغير يعيش داخل CPU حقيقي.
لأن المعالج الأصلي يبدو أن أداءه جيدًا، فلماذا لا نضيف واحدًا آخر؟
Finding the VM Bytecode
بعد فهم الـdispatcher، ابحث عن memory region أو global array يحتوي على الـVM instructions.
قد تبدو البيانات هكذا:
03 00 00 00
12 17 00 00
19 8A 13 00
05 00 00 00
...
في البداية تبدو كأنها bytes عشوائية.
لكن لو لاحظت أن الـrecords تتكرر كل أربعة bytes، جرّب اعتبارها أربع bytes لكل instruction.
مثلًا:
def split_instructions(words, width=4):
return [
words[i:i + width]
for i in range(0, len(words), width)
]
بعدها قارن أحد الحقول مع قيم الـopcodes التي يستخدمها الـdispatcher.
إذا بدأت القيم تتطابق، فقد وجدت الـinstruction format.
ابنِ جدول Opcodes صغير
لا تحاول كتابة emulator كامل من البداية.
ابدأ بجدول:
| Opcode | الوظيفة |
|---|---|
0x00 | حساب طول الـinput |
0x01 | Shift left |
0x02 | Modulo |
0x03 | تحميل user input |
0x04 | Division |
0x05 | Addition |
0x06 | Multiplication |
0x07 | Anti-debugging wrapper |
0x08 | OR |
0x0B | Jump |
0x0D | Password loop |
0x10 | Copy |
0x11 | Inner loop |
0x12 | قراءة password byte |
0x13 | Comparison |
0x16 | Conditional branch |
0x17 | Relative move |
0x18 | Subtraction |
0x19 | Immediate move |
0x1A | Shift right |
0x1C | Return |
لا تحتاج أن تعرف كل opcode حتى تبدأ الحل.
نحتاج أولًا أن نعرف أين تدخل الـpassword وأين تنتهي.
Stage 4: تتبع الـPassword Bytes
أهم instruction غالبًا هي التي تقرأ character من الـinput الأصلي.
نسخة مبسطة:
uint8_t value = password[index];
push(value);
لكن الـVM قد لا تقرأ characters بالترتيب الطبيعي.
مثلًا قد تقرأ:
password[24]
password[14]
password[27]
password[15]
ثم:
password[11]
password[7]
password[12]
password[4]
هذا ليس عشوائيًا.
إنه permutation.
الـVM يأخذ أربع characters من أماكن مختلفة، يعتبرها chunk واحد، ثم يرسلها إلى سلسلة من العمليات الحسابية.
هنا يبدأ الـobfuscation في فقدان جزء كبير من هيبته.
Stage 5: افهم الـFinal Arithmetic
لنفرض أن أربعة characters يتم جمعها في قيمة 32-bit:
def pack_chunk(chars):
return (
chars[0]
| (chars[1] << 8)
| (chars[2] << 16)
| (chars[3] << 24)
)
بعد ذلك يقوم الـVM بعمل rotate:
encoded = rol32(chunk, rotation)
ثم يقارن النتيجة مع constant:
if encoded != expected:
fail()
قد يبدو هذا كـbit soup ممتاز.
لكنه reversible بالكامل.
إذا كانت:
encoded = ROL32(chunk, rotation)
فإن:
chunk = ROR32(encoded, rotation)
انتهى.
نعم، أحيانًا كل الـVM موجود فقط ليجعلك تصل إلى هذه الجملة بعد ساعة من الـreverse engineering.
عكس الـRotation
نحتاج:
MASK32 = 0xffffffff
def ror32(value, count):
count &= 31
if count == 0:
return value & MASK32
value &= MASK32
return (
(value >> count)
|
(value << (32 - count))
) & MASK32
إذا كان عندنا:
expected = 0xDEADBEEF
rotation = 11
نحسب:
chunk = ror32(expected, rotation)
ثم نعيده إلى أربعة bytes:
chars = bytes([
chunk & 0xff,
(chunk >> 8) & 0xff,
(chunk >> 16) & 0xff,
(chunk >> 24) & 0xff,
])
هذه هي الـcharacters الأصلية.
ثم نضع كل character في مكانها الحقيقي.
نكرر الأمر مع بقية الـchunks.
Solver بسيط
بعد أن نستخرج الـequations من الـVM، لا نحتاج عادةً إلى إعادة تشغيل الـVM كله.
مثلًا:
CHECKS = [
([24, 14, 27, 15], 7, 0x12345678),
([11, 7, 12, 4], 14, 0x87654321),
# ...
]
password = bytearray(b"?" * 32)
for positions, rotation, target in CHECKS:
chunk = ror32(target, rotation)
chars = bytes([
chunk & 0xff,
(chunk >> 8) & 0xff,
(chunk >> 16) & 0xff,
(chunk >> 24) & 0xff,
])
for position, char in zip(positions, chars):
password[position] = char
print(password.decode())
لاحظ التحول.
في البداية لدينا:
Encrypted functions
Custom VM
Anti-debugging
Runtime memory
Random-looking bytecode
وفي النهاية:
ROR32
+
byte unpacking
+
permutation
الـmonster خرج منه صوت صغير جدًا.
لو كانت الـMath أعقد
أحيانًا لا تكون المعادلة مجرد rotate.
قد تجد شيئًا مثل:
x = ((x * A) + B) ^ C
x = rol32(x, R)
x = x mod N
هنا يوجد أكثر من طريق.
1. Reverse العمليات يدويًا
تحرك من النهاية إلى البداية.
final
|
v
inverse XOR
|
v
inverse rotate
|
v
subtract B
|
v
multiply by inverse(A)
إذا كانت كل العمليات invertible، فالحل مباشر.
2. استخدم Z3
لو بدأت المعادلة تبدو كأن أحدهم فتح calculator وقرر الانتقام منك، استخدم SMT.
from z3 import *
x = BitVec("x", 32)
solver = Solver()
solver.add(
RotateLeft(x, 7) == 0x12345678
)
if solver.check() == sat:
print(solver.model()[x])
3. اكتب Emulator
إذا كان هناك loops وbranches وstate متغير، كتابة emulator صغيرة قد تكون أسرع من إعادة كتابة الـdecompiler يدويًا.
والـemulator هنا ليس الهدف.
هو مجرد عدسة تجعل الـVM قابلًا للقراءة.
كيف تجعل الـDynamic Analysis مفيدة؟
لو طبعت كل register وكل instruction، ستحصل غالبًا على جدار نصي جميل يصلح لتخويف شخص في مقابلة عمل.
لكن ليس ليساعدك.
الأفضل هو طباعة الـstate الذي يشرح القرار الحالي:
ip=0132 opcode=12 password[24] -> 0x41
ip=0135 opcode=19 push 0x00000007
ip=0138 opcode=05 add
ip=0142 opcode=1A shr 0x03
ip=0146 opcode=13 compare
يمكنك بناء helper بسيط:
def trace(ip, opcode, **state):
print(
f"ip={ip:04x} "
f"opcode={opcode:02x} "
f"{state}"
)
الهدف من الـtrace ليس أن ترى كل شيء.
الهدف أن ترى سبب كل انتقال مهم.
لماذا الـCustom VM لا يحل المشكلة؟
الـCustom VM مفيد في شيء واحد جدًا:
يجعل الشخص الذي يحل المشكلة يعمل أكثر.
يمكنه:
- إخفاء الـlogic عن static analysis
- زيادة وقت التحليل
- إرباك الأدوات الآلية
- جعل الـpatching أصعب
- إخفاء الـintent لفترة
لكنه لا يستطيع:
- إزالة الـsecret من الـclient
- منع مراقبة التنفيذ
- جعل القرار المحلي trustworthy
- منع الـinstrumentation
الـclient يحتاج الـinformation حتى يقرر.
وإذا كنت تتحكم في الـclient، يمكنك مراقبة الـinformation أثناء اتخاذ القرار.
هذه هي الفكرة التي لا تحبها الأنظمة المعتمدة على local secrets.
كيف يكون التصميم أكثر أمانًا؟
اجعل التحقق الحساس خارج الـclient.
مثلًا:
Client
|
| credential proof
v
Server
|
| validation
v
Protected secret / database
أما في التطبيقات التي تحتاج offline behavior، فيمكن استخدام:
- Signed capabilities
- Asymmetric cryptography
- Short-lived authorization tokens
- Device-bound credentials
- Server-issued licenses
الاختيار يعتمد على الـapplication.
المبدأ ثابت:
إذا كان الـclient يستطيع وحده إثبات الـsecret، فهو غالبًا يحتوي على معلومات كافية تسمح بدراسته.
Testing and Audit Points
عندما تراجع password checker غريبًا، اسأل:
- هل توجد constants مرتبطة بالـvalidation داخل الـbinary؟
- هل القرار بالكامل محلي؟
- هل البرنامج ينشئ executable memory أثناء التشغيل؟
- هل يتم فك code مباشرة قبل استخدامه؟
- هل توجد dispatch table للـVM؟
- هل يتم تغيير ترتيب input characters؟
- هل المقارنة النهائية تعتمد على values داخل الـclient؟
- هل يمكن trace أو patch للـvalidation branch؟
- هل الـobfuscation هي وسيلة الحماية الأساسية؟
- هل يمكن نقل verification إلى server؟
إذا كانت إجاباتك تتحول إلى "نعم" بشكل متكرر، فغالبًا أمامك verifier مكلف جدًا في التحليل، لكنه ما زال يعتمد على نفس الـclient في النهاية.
Defense Checklist
| الفئة | الإجراء | الهدف |
|---|---|---|
| Trust boundary | انقل الـverification الحساس إلى server | إبقاء الأسرار خارج الـclient |
| Credentials | لا تضع secrets بصيغة قابلة للاستخراج | تقليل أثر reverse engineering |
| Authorization | استخدم signed أو server-issued tokens | فصل الصلاحيات عن local logic |
| Obfuscation | استخدمها defense-in-depth | زيادة تكلفة التحليل فقط |
| Anti-debugging | لا تعتبرها وسيلة لإخفاء السر | هي تعيق التحليل ولا تصنع سرية |
| Binary review | راجع constants ومنطق المقارنة | اكتشاف الأسرار المضمنة |
| Runtime review | راقب decryption والـexecutable mappings | فهم unpacking والـVM behavior |
Common Myths
"الكود Encrypted، إذن لا يمكن قراءته."
الـCPU لا يستطيع تنفيذ encrypted instructions للأبد.
في لحظة ما، يجب أن تصبح هذه البيانات instructions قابلة للتنفيذ.
راقب تلك اللحظة.
"طالما أن الموجود Hash فلا يوجد Secret."
ليس بهذه البساطة.
إذا كان الـclient يملك كل ما يحتاجه ليقول "صح" أو "خطأ"، فقد تتمكن من مهاجمة الـlocal check أو تعديل القرار نفسه.
وجود Hash لا يخلق trust boundary تلقائيًا.
"الـAnti-Debugging يمنع Reverse Engineering."
هو يجعل debugging مزعجًا.
وهذه ليست نفس الجملة.
"الـCustom VM مثل Encryption."
لا.
الـVM هو execution mechanism.
هو يخفي الـlogic ويزيد تكلفة التحليل، لكنه لا يخرج الـsecret من الجهاز.
Final Thoughts
أجمل شيء في هذا النوع من reverse engineering ليس اكتشاف أن هناك VM.
بل اكتشاف كم ينكمش حجم المشكلة عندما تجد الـabstraction الصحيحة.
في البداية يبدو عندك:
Anti-debugging
Encrypted functions
Dynamic memory
Custom bytecode
Jump tables
Password permutation
Arithmetic
وبعد العزل الصحيح يصبح لديك:
ROL32
|
v
ROR32
|
v
Four bytes
وهذه الفكرة تنطبق على reverse engineering بشكل عام.
لا تحاول فهم الـbinary كله دفعة واحدة.
ابحث عن الـstate transition الذي يقرر النتيجة فعلًا.
وبعدها ارجع للخلف.
يمكن للـclient أن يخفي الطريق.
لكن لا يستطيع أن يخفي الوجهة عن الـmachine التي يجب عليها الوصول إليها.