1.3 אקראיות צפויה פתרון
פתרון - אקראיות צפויה¶
נעבור על התרגילים לפי הסדר. הרעיון המרכזי חוזר בכולם: ברגע שאנחנו יודעים את הזרע (או שאין זרע בכלל), הפלט של המחולל צפוי לגמרי, וכל "ניחוש" הופך לחישוב.
פתרון תרגיל 1 - למה זה שביר¶
המקור של האתגר:
#include <stdio.h>
int main() {
unsigned int random;
random = rand();
unsigned int key = 0;
scanf("%d", &key);
if ((key ^ random) == 0xdeadbeef) {
printf("Good!\n");
system("/bin/cat flag");
return 0;
}
printf("Wrong, maybe you should try 2^32 cases.\n");
return 0;
}
תשובות:
- משווים את
key ^ randomל-0xdeadbeef. הערךrandomמגיע מ-rand(). - אין שום קריאה ל-
srandבקוד. זה הבאג. - בגלל שאין
srand, ההתנהגות זהה ל-srand(1): זרע דיפולטי קבוע. לכןrandomמקבל את אותו ערך בכל הרצה, ובכל הרצה עתידית - זה לא באמת אקראי. ההודעה הלועגת "try 2^32 cases" מניחה ש-random אקראי, אבל הוא לא, אז אין צורך בשום ברוט-פורס.
פתרון תרגיל 2 - הקבוע¶
דרך ראשונה, תוכנית C:
דרך שנייה, בתוך הבינארי random עם GDB. קודם מאתרים את ההוראה שאחרי call rand:
pwndbg> disassemble main
...
0x08048649 <+...>: call 0x8048420 <rand@plt>
0x0804864e <+...>: mov DWORD PTR [ebp-0x8], eax <-- here eax holds rand()
...
עוצרים על ההוראה שאחרי הקריאה וקוראים את הערך המוחזר:
pwndbg> break *main+<offset of the mov after call rand>
pwndbg> run
pwndbg> print/x $eax
$1 = 0x6b8b4567
התוצאה, בשתי הדרכים:
הערה: אם האתגר מהודר ל-32 ביט, ערך ההחזרה ב-eax. אם מדובר בבינארי 64 ביט, קוראים $rax (או $eax לחצי התחתון). בשני המקרים הקבוע זהה, כי הוא נקבע על ידי glibc ולא על ידי הארכיטקטורה.
פתרון תרגיל 3 - הדגל¶
הפעולה XOR הופכית לעצמה, אז מהמשוואה key ^ random == 0xdeadbeef נובע:
חישוב בית-בית:
0xde ^ 0x6b = 0xb5
0xad ^ 0x8b = 0x26
0xbe ^ 0x45 = 0xfb
0xef ^ 0x67 = 0x88
-----------------------
key = 0xb526fb88 = 3039230856
דרך 1 - שורת פקודה אחת:
דרך 2 - pwntools מקומית:
from pwn import *
p = process('./random')
key = 0xdeadbeef ^ 0x6b8b4567 # 3039230856
p.sendline(str(key).encode())
print(p.recvall().decode())
דרך 3 - pwntools מול השרת דרך SSH (כך פותרים את random בשרת האמיתי):
from pwn import *
s = ssh(host='pwnable.kr', port=2222, user='random', password='guest')
p = s.process('./random')
key = 0xdeadbeef ^ 0x6b8b4567
p.sendline(str(key).encode())
print(p.recvall().decode())
בכל הדרכים מקבלים Good! ואז את תוכן הדגל.
למה זה עבד: אין srand, לכן random הוא הקבוע 0x6b8b4567. פתרנו משוואת XOR יחידה במקום לעבור על 2^32 מקרים. לגבי scanf("%d") והערך שגדול מ-INT_MAX: מה שמעניין אותנו זו תבנית הביטים ב-key בת ארבעת הבתים. scanf מאכלס את הבתים בערך 0xb526fb88, וה-XOR פועל על הביטים - לכן שליחת העשרוני 3039230856 נותנת בדיוק את התוצאה 0xdeadbeef.
איך להכליל: בכל פעם שאתם רואים rand() בלי srand(), הפלט ידוע מראש. אותה גישה עובדת גם אם התוכנית קוראת ל-rand() כמה פעמים - פשוט מחשבים את הסדרה המלאה של הזרע הדיפולטי (1804289383, 846930886, 1681692777, ...) ומתאימים לכל שימוש.
פתרון תרגיל 4 - שבירת הזרע לפי זמן¶
-
מרחב הזרעים. הזרע הוא
time(NULL), מספר השניות מאז 1970. התוקף יודע את הזמן הנוכחי, אז מבחינתו מרחב האפשרויות אינו 2^32 אלא חלון קטנטן של כמה שניות סביב רגע ההרצה - בפועל אפשרות אחת עד חמש. זה ההבדל בין בלתי אפשרי למיידי. -
הסקריפט. אנחנו קוראים ל-
srand/randשל libc עצמה דרךctypes, כדי לקבל בדיוק את אותם מספרים שהתוכנית ב-C תקבל. מחשבים את הזרע לפי הזמן הנוכחי, מנחשים, ואם צריך מרחיבים לחלון קטן:
from pwn import *
import ctypes, time
libc = ctypes.CDLL("libc.so.6")
def predict(seed, mod=1000000):
libc.srand(seed)
return libc.rand() % mod
def solve_once():
p = process('./timelock')
p.recvuntil(b'Guess my number:')
# the window: the current second, and one-two seconds each direction to cover delays
now = int(time.time())
for seed in (now, now - 1, now + 1, now - 2, now + 2):
guess = predict(seed)
log.info(f"trying seed={seed} guess={guess}")
p.sendline(str(guess).encode())
out = p.recvall(timeout=1).decode(errors='ignore')
if 'Correct' in out:
log.success(out.strip())
return True
# if we missed, the process already ended - open a new one for the next attempt
p = process('./timelock')
p.recvuntil(b'Guess my number:')
return False
solve_once()
בגלל שהתוכנית מבקשת ניחוש אחד ואז מסיימת, כל ניסיון פותח תהליך חדש. בפועל, אם מריצים את הסקריפט מיד, הזרע של השנייה הנוכחית מספיק בניסיון הראשון, ומקבלים:
- גרסה מינימלית, כשההרצה מיידית ואין השהיה, הזרע הוא פשוט עכשיו:
import ctypes, time, subprocess
libc = ctypes.CDLL("libc.so.6")
libc.srand(int(time.time()))
guess = libc.rand() % 1000000
print(subprocess.run(['./timelock'], input=f"{guess}\n".encode(),
capture_output=True).stdout.decode())
למה זה עבד: time(NULL) הוא לא סוד. שחזרנו את הזרע מהשעון שלנו, ומכיוון ש-rand דטרמיניסטי, שחזרנו איתו את secret בדיוק. החלון הקטן נועד רק לספוג השהיה בין הרגע שהתוכנית הזריעה לרגע שאנחנו חישבנו.
איך להכליל ולהרחיב:
srand(time(NULL) ^ getpid()). מוסיף אנטרופיה של מזהה תהליך, אבל ב-PID יש בפועל טווח מוגבל (לרוב עד כמה עשרות אלפים). מרחב החיפוש גדל לחלון הזמן כפול טווח ה-PID - עדיין בר-ברוט-פורס, פשוט מוסיפים לולאה פנימית על ה-PID.- קריאות
rand()מרובות לפני בחירת הסוד. לא משנה - מריצים אצלנו את אותה כמות קריאות אחריsrandומגיעים לאותו מצב פנימי, ואז שולפים את הערך הרלוונטי. - החלפה ל-
getrandom()או קריאה מ-/dev/urandom. כאן הברוט-פורס מת: אין זרע צפוי לשחזר, האנטרופיה מגיעה מהkernel ואינה נגזרת מהשעון. זו הסיבה שלכל שימוש רגיש באבטחה חייבים CSPRNG, לאrand().