العشوائية مفيدة جدًا، إلى أن يقرر التطبيق استخدام نفس مصدر العشوائية للإجابة عن سؤالين مختلفين.
جزء من التطبيق يستخدم الـ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 IDs | endpoint جانبي قد يستهلك نفس 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؟
لو كنت تراجع تطبيقًا مشابهًا، ابحث عن هذه الأشياء:
-
PRNG واحد لعدة وظائف مختلفة.
globalrandomأو sharedRandomobject يستحق النظر. -
نفس
randrange()في branches مختلفة.
تطابق الـbounds قد يكون مفتاح stream alignment. -
random output يظهر للمستخدم بطريقة غير مباشرة.
Debug output، selected items، error messages، أو اختلافات في response. -
invalid input يتم تجاهله.
continue، append مشروط، أو conversion failures لا تؤدي إلى rejection. -
zip()معall()أوany().
اسأل دائمًا: ماذا يحدث إذا كان أحد الطرفين فارغًا أو أقصر من المتوقع؟ -
outer length صارم لكن inner length غير صارم.
قد يطلب التطبيق 100 field، ثم يبني منها 64 فقط. -
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
-
Python Documentation,
randomand pseudorandom number generation.
https://docs.python.org/3/library/random.html -
OWASP, Input Validation Cheat Sheet.
https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html -
Python Documentation, Built-in Functions.
https://docs.python.org/3/library/functions.html