PRNG Stream Reuse Explained: عندما تعيد العشوائية استخدام نفسها

🇺🇸EN🇸🇦AR

العشوائية مفيدة جدًا، إلى أن يقرر التطبيق استخدام نفس مصدر العشوائية للإجابة عن سؤالين مختلفين.

جزء من التطبيق يستخدم الـPRNG لاتخاذ قرار صغير. جزء آخر يستخدمه لبناء secret. الاثنان يبدآن من نفس الحالة، ويستخدمان نفس randrange(0, 100). فجأة الـsecret لم يعد secret أصلًا. صار مجرد القيم التالية في stream سبق أن شاهدتها.

وهذه هي الفكرة الأساسية.

سنفكك هنا سيناريو Python عام يجمع بين ثلاث مشاكل صغيرة:

  • نفس الـPRNG stream يُستخدم في مسارين منطقيين مختلفين.
  • بعض القيم غير الصالحة يتم تجاهلها بدل رفضها.
  • فحص نهائي يعتمد على all() فوق نتيجة zip() قد تكون فارغة.

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


ما هو PRNG Stream Reuse أصلًا؟

الـPRNG ليس سحرًا. هو state machine حتمية.

عندما تكون الـinternal state نفسها، فإن الاستدعاء التالي يعيد نفس القيمة.

لهذا السبب هناك فرق مهم بين كلمة "random" في الاستخدام البرمجي وبين العشوائية الآمنة للتشفير. مكتبة random في Python مخصصة للاستخدام العام وتبني قيمها من generator state حتمية، وليست الخيار المناسب لبناء secrets حساسة أمنيًا. [1]

المشكلة الخطيرة هنا ليست فقط استخدام PRNG يمكن التنبؤ به. المشكلة في إعادة استخدام نفس الـstream.

مثلًا:

rng = random.Random(seed)

decision = rng.randrange(0, 100)

secret_length = rng.randrange(0, 100)

لو استطعت معرفة decision، فقد بدأت بالفعل في معرفة ما يأتي بعده.

والأمر يصبح أسوأ عندما يستخدم المساران نفس الاستدعاء بالضبط:

rng.randrange(0, 100)

هنا أنت لا تحتاج إلى reverse engineer للـgenerator نفسه. لا تحتاج إلى استرجاع كامل الـstate. أنت فقط تحتاج إلى محاذاة الاستدعاءات.

تخيل شخصين يقرآن نفس قائمة مرقمة. إذا أخبرك الأول بما قرأه في العنصر رقم 1 و2، فلست بحاجة إلى اقتحام القائمة حتى تعرف العنصر رقم 3.


لماذا يهمك هذا؟

هذا النوع من المشاكل يمكن أن يظهر في أماكن كثيرة:

المكانالمشكلة المحتملة
Reset Tokensقيمة مكشوفة قد تكشف جزءًا من stream المستخدم للـtoken
Session IDsendpoint جانبي قد يستهلك نفس generator
Temporary Codesالكود "العشوائي" يصبح قابلًا لإعادة البناء
Noncesقيم مرتبطة بسبب مشاركة الـstate
Multi-stage workflowsمرحلة واحدة تكشف ما تستخدمه مرحلة أخرى

والأخطر عندما لا يكشف التطبيق الرقم مباشرة، بل يكشف أثره:

  • عنصرًا تم اختياره عشوائيًا.
  • رسالة خطأ.
  • branch لا يعمل إلا عند قيمة معينة.
  • debug output.
  • سلوكًا يمكن استخدامه كـoracle.

السيناريو

افترض أن لدينا TCP service تقبل بالضبط 100 قيمة مفصولة بـ,.

التطبيق يمرر input عبر أربع مراحل.

المرحلة الأولى: عدد القيم

if len(parts) != 100:
    return False

لا شيء مثير هنا.

المرحلة الثانية: تحويل القيم

القيم الرقمية تتحول إلى indices. القيم غير الرقمية يتم وضعها في قائمة أخرى، ثم يستخدم التطبيق الـPRNG لاختيار عنصر منها:

if non_numeric:
    r = rng.randrange(0, len(non_numeric))

    if r % 2:
        for _ in range(len(non_numeric)):
            selected = non_numeric[
                rng.randrange(0, len(non_numeric))
            ]

            print("selected:", selected)

لو كانت القائمة تحتوي على 100 عنصرًا، يصبح لدينا:

r = rng.randrange(0, 100)       # الاستدعاء رقم 1

# إذا كانت القيمة فردية:
for _ in range(100):
    rng.randrange(0, 100)       # الاستدعاءات 2 إلى 101

وهنا تبدأ المشكلة.

التطبيق كشف لنا جزءًا من stream العشوائية.

المرحلة الثالثة: بناء secret

بعد ذلك يأتي branch مختلف:

length = rng.randrange(0, 100)

secret = "".join(
    chr(rng.randrange(0, 100))
    for _ in range(length)
)

يبدو مستقلًا.

لكنه ليس مستقلًا.

إذا بدأ المساران من نفس الـPRNG state، وإذا استخدما نفس randrange(0, 100), تصبح الصورة:

call #1   -> length
call #2   -> secret[0]
call #3   -> secret[1]
...

وبما أن المسار الأول كشف لنا هذه القيم، يمكننا بناء الـsecret مباشرة.

لا يوجد هنا seed brute-force.

لا يوجد سحر.

فقط stream واحد يتم استخدامه مرتين.


أول فخ: الرقم الظاهر ليس بالضرورة قيمة الـPRNG

هناك تفصيلة صغيرة جدًا لكنها مهمة.

قد يكون التطبيق يفعل:

selected = choices[rng.randrange(0, 100)]
print(ord(selected))

ثم ترى:

114

هذا لا يعني أن random value كانت 114.

قد تكون القيمة المختارة هي الحرف:

'r'

وord('r') == 114.

إذا كنت تعرف choices التي استخدمها التطبيق، يمكنك عكس mapping:

ord_to_index = {
    ord(ch): i
    for i, ch in enumerate(choices)
}

ثم:

actual_random_value = ord_to_index[114]

هذه النقطة وحدها قادرة على إفساد exploit صحيح بالكامل. إذا تعاملت مع ord() على أنه random output، ستحصل على stream يبدو منطقيًا، لكنه مجرد stream خاطئ.


الفخ الثاني: all([]) ترجع True

الآن نصل إلى validation bug.

لنفترض أن لدينا:

checks = [
    expected == actual
    for expected, actual
    in zip(expected_text, user_text[64:])
]

if not all(checks):
    return False

المبرمج يريد مقارنة suffix كامل.

لكن ماذا يحدث إذا كان:

user_text[64:]

فارغًا؟

لدينا:

list(zip("anything", ""))

والنتيجة:

[]

وبالتالي:

all([]) == True

Python هنا لا يفعل شيئًا خاطئًا. المشكلة أن الـapplication افترض أن وجود loop يعني أن المقارنة حصلت فعلًا.

لا يكفي أن تقول "الـcharacters تطابقت". يجب أن تتأكد أولًا أن عدد الـcharacters نفسه صحيح.

الأفضل:

if len(user_text) != 128:
    return False

if user_text[:64] != expected_prefix:
    return False

if user_text[64:] != expected_suffix:
    return False

أو، إذا كان استخدام zip() ضروريًا:

if len(user_text[64:]) != len(expected_suffix):
    return False

if not all(
    a == b
    for a, b in zip(expected_suffix, user_text[64:])
):
    return False

لكن غالبًا المقارنة المباشرة أبسط وأوضح.


الفخ الثالث: القيم غير الصالحة التي تختفي

هناك bug صغير آخر يجعل الـempty-suffix bypass عمليًا.

تخيل parser مثل هذا:

for value in parts:
    if value.isdigit():
        index = int(value)

        if 0 <= index < len(data):
            indices.append(index)

ماذا يحدث إذا أرسلنا:

9999

القيمة رقمية.

الـparsing نجح.

لكنها خارج الـrange، فيتم تجاهلها.

ولا يحدث rejection.

هنا يمكن للمهاجم أن يرسل:

64 valid indices
36 out-of-range values

فيظل هناك 100 input fields، لكن ينتج عنها 64 قيمة فعلية فقط.

وهذا فرق مهم بين:

  • "input قابل للـparsing"
  • و"input صالح منطقيًا"

OWASP توصي بالتحقق من النوع والـrange والمعنى، ورفض القيم غير المتوقعة بدل تجاهلها والاستمرار. [2]


جمع الأخطاء الثلاثة

الآن attack chain واضحة.

1. تسريب الـPRNG stream

نرسل 100 قيمة non-numeric معروفة.

التطبيق يستهلك:

randrange(0, 100)

مرة، ثم 100 مرة إضافية.

إذا كانت القيمة الأولى فردية، يظهر لنا جزء من stream.

وبما أننا نعرف الـarray الأصلية، نستطيع تحويل الحرف الظاهر إلى index حقيقي.

2. بناء candidate secrets

نحن نعرف أن أول قيمة كانت فردية، لكننا لا نعرف قيمتها.

إذًا الاحتمالات تصبح:

1, 3, 5, ..., 99

أي 50 احتمالًا فقط.

ثم:

for length in range(1, 100, 2):
    secret = "".join(
        chr(x)
        for x in leaked_values[:length]
    )

    digest = hashlib.sha256(
        secret.encode()
    ).hexdigest()

وبذلك أصبح لدينا 50 candidate hashes.

3. تحويل hash إلى indices

إذا كانت الخدمة تعيد بناء النص من buffer ثابت، وكان كل hash يتكون من:

0123456789abcdef

فكل ما نحتاجه هو موقع كل character داخل ذلك الـbuffer.

مثلًا:

char_to_index = {
    "0": 311,
    "1": 314,
    "2": 317,
    # ...
}

ثم يتحول SHA256 بالكامل إلى 64 indices.

4. إضافة قيم خارج الـrange

نضيف:

[9999] * 36

فيتم قبول 100 field، بينما لا ينتج فعليًا إلا 64 character.

5. الـsuffix يصبح فارغًا

وبذلك يصبح:

user_text[64:]

فارغًا، وتتحول المقارنة إلى:

all([])

فتنجح.

بقي فقط أن يكون أول 64 character هو الـSHA256 الصحيح.


شكل الـexploit في صورة مختصرة

import hashlib

def build_candidates(leaked_values):
    candidates = []

    for length in range(1, 100, 2):
        secret = "".join(
            chr(x)
            for x in leaked_values[:length]
        )

        digest = hashlib.sha256(
            secret.encode()
        ).hexdigest()

        candidates.append(digest)

    return candidates


def build_payload(digest, char_to_index):
    indices = [
        char_to_index[ch]
        for ch in digest
    ]

    # نبني فقط 64 character فعلية
    indices += [9999] * 36

    return ",".join(
        str(x)
        for x in indices
    )

الكود نفسه بسيط.

الجزء الممتع هو لماذا ينجح.

لدينا:

نفس PRNG state
        +
نفس randrange(0, 100)
        +
output observable
        =
secret قابل لإعادة البناء

ولدينا:

عدد input ثابت
        +
قيم خارج الـrange يتم تجاهلها
        +
all(zip(..., empty))
        =
validation bypass

هذه هي السلسلة كاملة.


كيف تكتب الكود بشكل آمن؟

أول خطوة: لا تشارك نفس PRNG بين أشياء لا علاقة لها ببعض.

النسخة الضعيفة

import random

rng = random.Random(seed)

def build_public_result(items):
    choice = rng.randrange(0, 100)
    return items[choice]

def build_secret():
    length = rng.randrange(0, 100)

    return bytes(
        rng.randrange(0, 100)
        for _ in range(length)
    )

المشكل هنا أن المسارين مربوطان بنفس stream.

النسخة الأفضل

import secrets

def build_public_result(items):
    choice = secrets.randbelow(len(items))
    return items[choice]

def build_secret():
    length = secrets.randbelow(100)

    return bytes(
        secrets.randbelow(100)
        for _ in range(length)
    )

عندما يكون الرقم security-sensitive، استخدم مصدرًا مناسبًا مثل secrets بدل random. Python توضح أن random للاستخدام العام، بينما secrets مصمم للقيم الحساسة أمنيًا. [1]

والنقطة الأهم: لا تعتمد على استبدال module فقط. إذا كانت وظيفتان يجب أن تكونا مستقلتين، لا تربطهما بنفس state أصلًا.


إصلاح Input Validation

النسخة الضعيفة

for value in parts:
    if value.isdigit():
        index = int(value)

        if 0 <= index < len(data):
            indices.append(index)

المشكلة أن invalid value تختفي بصمت.

النسخة الصحيحة

if len(parts) != 100:
    raise ValueError("Exactly 100 values are required")

indices = []

for value in parts:
    if not value.isdigit():
        raise ValueError(
            "Only decimal integers are allowed"
        )

    index = int(value)

    if not 0 <= index < len(data):
        raise ValueError(
            "Index out of range"
        )

    indices.append(index)

إذا كانت القيمة غير صالحة، ارفضها.

لا تتركها تختفي وكأنها لم تكن موجودة.

الـinput validation الآمن يجب أن يتحقق من النوع والـlength والـrange والمعنى، وليس فقط من أن Python استطاعت قراءة القيمة. [2]


إصلاح all() وzip()

تجنب هذا:

if all(
    a == b
    for a, b in zip(expected, actual)
):
    accept()

من دون فحص الـlength.

الأبسط:

if actual != expected:
    return False

أو:

if len(actual) != len(expected):
    return False

if not all(
    a == b
    for a, b in zip(expected, actual)
):
    return False

القاعدة هنا بسيطة: لا تجعل iterator هو الذي يقرر ما إذا كانت المقارنة موجودة أصلًا.


أين تبحث أثناء الـCode Review؟

لو كنت تراجع تطبيقًا مشابهًا، ابحث عن هذه الأشياء:

  1. PRNG واحد لعدة وظائف مختلفة.
    global random أو shared Random object يستحق النظر.
  2. نفس randrange() في branches مختلفة.
    تطابق الـbounds قد يكون مفتاح stream alignment.
  3. random output يظهر للمستخدم بطريقة غير مباشرة.
    Debug output، selected items، error messages، أو اختلافات في response.
  4. invalid input يتم تجاهله.
    continue ، append مشروط، أو conversion failures لا تؤدي إلى rejection.
  5. zip() مع all() أو any().
    اسأل دائمًا: ماذا يحدث إذا كان أحد الطرفين فارغًا أو أقصر من المتوقع؟
  6. outer length صارم لكن inner length غير صارم.
    قد يطلب التطبيق 100 field، ثم يبني منها 64 فقط.
  7. Fresh connections تعيد نفس PRNG state.
    هذه تجعل كل connection تجربة جديدة بنفس الـstream.

هذه ليست قواعد خاصة بسيناريو واحد. هي نقاط جيدة جدًا للـmanual review.


خرافات شائعة

"لازم أسترجع حالة MT19937 كاملة."

ليس دائمًا.

إذا كان التطبيق يكشف لك output كافيًا ثم يعيد استخدام نفس stream لبناء secret، فقد يكون stream alignment أسهل بكثير من state recovery.

"all([]) مجرد trivia في Python."

ليس عندما تكون خلف authorization أو validation boundary.

إذا كان التطبيق يفترض أن iterable غير فارغ دائمًا، فقد يكون empty iterable هو بالضبط المدخل الذي يكسر المنطق.

"القيم خارج الـrange غير مؤذية لأن التطبيق يتجاهلها."

تجاهل input لا يعني أنه غير مؤثر.

إذا كان تجاهل القيمة يغير حجم البيانات التي تصل إلى security check لاحق، فأنت أمام attacker-controlled behavior.

"50% chance لتفعيل الـoracle تجعل الهجوم غير عملي."

ليس بالضرورة.

مع PRNG deterministic، الحالة نفسها تعطي نفس النتيجة. وحتى لو احتجت إلى عدة تجارب، يكفي أحيانًا عدد صغير من requests للحصول على oracle عملي.


Defense Checklist

الفئةالإجراء
Randomnessاستخدم secrets للقيم الحساسة أمنيًا
PRNG Stateافصل generators بين الوظائف غير المرتبطة
Input Countارفض أي عدد غير متوقع من الحقول
Input Typeارفض النوع غير المسموح
Rangeارفض القيم خارج الـrange بدل تجاهلها
Output Lengthتحقق من الحجم قبل المقارنة
Collection Checksتعامل صراحة مع empty وshort collections
Error Handlingلا تكشف random selections أو internal state
Session Behaviorلا تعيد تهيئة security-sensitive PRNG بطريقة متوقعة
Code Reviewاختبر empty وshort وlong وmalformed inputs

الخلاصة

هذا النوع من المشاكل يوضح شيئًا مهمًا في الـapplication security: أحيانًا لا تحتاج إلى bug واحد ضخم.

PRNG مشترك يكشف stream.

Parser يتجاهل القيم غير الصالحة.

Validation تعتمد على zip() وall() بدون التأكد من الـlength.

كل نقطة وحدها قد تبدو صغيرة.

لكن عند جمعها معًا، يتحول التطبيق من نظام validation إلى سلسلة خطوات يمكن التنبؤ بها.

استخدم مصدر randomness مناسب، افصل الـstate بين الوظائف، ارفض الـinvalid input مبكرًا، وتحقق من الـlength قبل أي collection comparison.

لأن أسوأ validation check ليس الذي يحتوي على bug معقد.

أحيانًا يكون الذي ينتهي ببساطة إلى:

all([])

والباب الذي كان يفترض أن يكون مقفولًا... لم يكن مقفولًا أصلًا.

References

  1. Python Documentation, random and pseudorandom number generation.
    https://docs.python.org/3/library/random.html
  2. OWASP, Input Validation Cheat Sheet.
    https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
  3. Python Documentation, Built-in Functions.
    https://docs.python.org/3/library/functions.html