Client-Side Password Validation Explained: عندما يبني الـPassword Checker معالجًا صغيرًا لنفسه

🇺🇸EN🇸🇦AR

تخيل مطورًا قرر أن يجعل فحص كلمة المرور "غير قابل للكسر".

فشفر دوال الـ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
0x01Shift left
0x02Modulo
0x03تحميل user input
0x04Division
0x05Addition
0x06Multiplication
0x07Anti-debugging wrapper
0x08OR
0x0BJump
0x0DPassword loop
0x10Copy
0x11Inner loop
0x12قراءة password byte
0x13Comparison
0x16Conditional branch
0x17Relative move
0x18Subtraction
0x19Immediate move
0x1AShift right
0x1CReturn

لا تحتاج أن تعرف كل 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 غريبًا، اسأل:

  1. هل توجد constants مرتبطة بالـvalidation داخل الـbinary؟
  2. هل القرار بالكامل محلي؟
  3. هل البرنامج ينشئ executable memory أثناء التشغيل؟
  4. هل يتم فك code مباشرة قبل استخدامه؟
  5. هل توجد dispatch table للـVM؟
  6. هل يتم تغيير ترتيب input characters؟
  7. هل المقارنة النهائية تعتمد على values داخل الـclient؟
  8. هل يمكن trace أو patch للـvalidation branch؟
  9. هل الـobfuscation هي وسيلة الحماية الأساسية؟
  10. هل يمكن نقل 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 التي يجب عليها الوصول إليها.