1.2 באגים לוגיים והשוואות הרצאה
לא כל חולשה מתחילה בbuffer overflow. חלק גדול מהבאגים הקריטיים ביותר בעולם האמיתי הם באגים לוגיים - המתכנת פשוט כתב תנאי שגוי, בדק את הדבר הלא נכון, או הניח הנחה שגויה על טווח של ערך. אין כאן דריסת זיכרון, אין shellcode ואין ROP. יש רק קוד שעושה בדיוק את מה שכתוב בו, וזה שונה ממה שהמתכנת התכוון. בהרצאה הזו נלמד לזהות את המשפחה הזו: מלכודות של קדימות אופרטורים, בלבול בין השמה להשוואה, לולאות השוואה שסופרות את הדבר הלא נכון, וחוסר בדיקת גבולות על מספרים. בדרך נבין לעומק את שלושת האתגרים mistake, lotto ו-blackjack מ-pwnable.kr.
מה זה באג לוגי - logic bug¶
באג לוגי הוא פער בין הכוונה של המתכנת לבין הסמנטיקה של הקוד שהוא כתב. הבינארי תקין לחלוטין מבחינת זיכרון: אין overflow, אין מצביע פראי, אין חריגה. אם תריצו עליו checksec תראו אולי canary, NX, PIE ו-RELRO מלא - וזה לא משנה כלום, כי אנחנו לא הולכים לדרוס שום דבר. אנחנו הולכים לתת לתוכנית קלט חוקי לחלוטין שמוביל אותה למסקנה שהמתכנת לא רצה.
זו נקודה חשובה: כל הmitigations שלמדתם עליהם - canary, ASLR, NX, PIE - נועדו לעצור שיבוש זיכרון. מול באג לוגי הם חסרי משמעות. אין דרך "להקשיח" תוכנית מפני תנאי שגוי; הדרך היחידה היא לכתוב את התנאי נכון.
איך מוצאים באג כזה? קוראים את קוד המקור לאט, שורה-שורה, ושואלים על כל תנאי: "מה בדיוק מחושב פה, ומה קורה בקצוות?". באתגרי pwnable.kr הרמה הזו נדיבה איתנו - קוד המקור נמצא לצד הבינארי (קובץ ה-.c), אז המשחק הוא משחק קריאה.
קדימות אופרטורים ב-C - operator precedence¶
הטעות הקלאסית ביותר בשפת C נובעת מכך שלאופרטורים יש קדימות (מי נדבק למי) ואסוציאטיביות (מאיזה כיוון). רוב המתכנתים זוכרים ש-* קודם ל-+, אבל שוכחים משהו קריטי: אופרטור ההשמה = נמצא כמעט בתחתית הטבלה, נמוך יותר מכל אופרטורי ההשוואה.
הנה קטע מהטבלה, מהקדימות הגבוהה לנמוכה:
high (binds strong)
() [] -> .
! ~ ++ -- (unary)
* / %
+ -
<< >>
< <= > >= <-- ordering operators
== != <-- equality operators
&
^
|
&&
||
= += -= ... <-- assignment, almost at the bottom!
low (binds weak)
שימו לב מה זה אומר בפועל: בביטוי a = b < c, האופרטור < נדבק חזק יותר מ-=, ולכן זה נקרא כ-a = (b < c), ולא כ-(a = b) < c. כלומר, קודם משווים את b ל-c ומקבלים 0 או 1, ואז מציבים את התוצאה הזו לתוך a.
המלכודת - fd = open(...) < 0¶
הנה הדפוס המדויק שמופיע באתגר mistake. המתכנת רצה לפתוח קובץ ולבדוק שהפעולה הצליחה:
int fd;
if (fd = open("/home/mistake/password", O_RDONLY, 0400) < 0) {
printf("can't open password %d\n", fd);
return 0;
}
הכוונה הייתה: "תשים ב-fd את התוצאה של open, ואז תבדוק אם fd < 0". אבל בגלל הקדימות, מה שהקומפיילר באמת רואה הוא:
בואו נפרק את זה:
open("/home/mistake/password", ...) -> returns a valid file descriptor, e.g. 3
(3 < 0) -> false, i.e. 0
fd = 0 -> fd gets 0, not 3 !
if (0) -> the condition is false, we don't enter the if
הקובץ אכן נפתח (ה-fd התקין 3 קיים, פשוט הלך לאיבוד), אבל המשתנה fd שמור עכשיו את הערך 0. ומה זה תיאור קובץ 0? זהו stdin - הקלט הסטנדרטי. השורה שאמורה הייתה לקרוא את הסיסמה מהקובץ:
קוראת עכשיו את "הסיסמה הסודית" ישירות מהמקלדת שלנו. במילים אחרות, אנחנו זה שמספקים את הערך שאיתו נשווה בהמשך. באג של תו אחד בטבלת קדימות פותח את כל האתגר.
התיקון, אגב, הוא סוגריים מפורשים: if ((fd = open(...)) < 0). שורה אחת של סוגריים מבדילה בין קוד תקין לחולשה.
השמה מול השוואה - assignment vs comparison¶
קרוב משפחה של אותו באג הוא הבלבול בין = (השמה) ל-== (שוויון). שני התווים דומים ושניהם ביטויים חוקיים ב-C, ולכן הקומפיילר לא תמיד יצעק.
הביטוי admin = 1 מציב 1 ל-admin ומחזיר 1, ולכן ה-if תמיד נכנס. המתכנת התכוון ל-admin == 1. באתגר mistake נראה גם את הדפוס הזה בשורת ה-read:
שוב הקדימות: > חזק מ-=, אז זה len = (read(...) > 0). אם ה-read הצליח (החזיר מספר גדול מ-0), אז read(...) > 0 שווה 1, ולכן len = 1, ו-!(1) הוא 0, ולא נכנסים לענף השגיאה. הקוד "עובד" במקרה - אבל len שווה 1 במקום מספר הבתים האמיתי. עוד באג קדימות שהמתכנת אפילו לא שם לב אליו.
טעויות בהשוואות ובטווחים - comparison and range mistakes¶
קטגוריה רחבה נוספת היא השוואה שנעשית נכון תחבירית, אבל על הערך הלא נכון או בטווח הלא נכון. כמה דפוסים שכדאי לחפש:
- סימן מול חוסר-סימן - signed vs unsigned: השוואה בין
int(עם סימן) ל-unsigned. ערך שלילי שמומר ל-unsigned הופך למספר ענק, ובדיקת גבול עליון עלולה לעבור. - טווח קלט מול טווח פנימי: התוכנית משווה קלט המשתמש לערכים בטווח מסוים, אבל הקלט יכול לחרוג מהטווח הזה. אם למשל מספרים חוקיים הם 1 עד 45, אבל אנחנו שולחים בתים בטווח 0 עד 255, חלק מהערכים שלנו לעולם לא יוכלו להתאים - וזו הנקודה שבה טעות בבחירת הקלט תמנע פתרון (נראה את זה ב-lotto).
- בית גולמי מול תו ASCII: כשקוראים עם
read()מקבלים בתים גולמיים; כשמקלידים "1" במקלדת שולחים את התו'1'שערכו0x31= 49. אם התוכנית משווה לערך המספרי 1, התו'1'לעולם לא יתאים.
הכלל: על כל השוואה, שאלו את עצמכם מה טווח הערכים האפשריים של כל צד, ומה קורה בקצוות ובערכים חריגים.
באג בלולאת השוואה - the position-independent match¶
עכשיו לדפוס מתוחכם יותר, זה של אתגר lotto. נניח שהתוכנית מגרילה 6 מספרים אקראיים, ואנחנו מגישים 6 מספרים. הכוונה: לנצח צריך שכל מספר שלנו יתאים למספר במקום המקביל. אבל ההשוואה נכתבה כך:
int match = 0;
for (i = 0; i < 6; i++) {
for (j = 0; j < 6; j++) {
if (lotto[i] == submit[j]) {
match++;
}
}
}
if (match == 6) { /* Jackpot */ }
זו לולאה מקוננת שמשווה כל מספר מוגרל מול כל מספר שהגשנו. match עולה בכל התאמה שהיא, בלי קשר למיקום. זה שובר לגמרי את ההיגיון של "6 במקומות הנכונים".
בואו נבין את העוצמה. נניח שכל 6 המספרים שלנו זהים, כולם הערך X:
submit = [X, X, X, X, X, X]
for every drawn number lotto[i] that equals X:
the inner loop goes over all 6 submissions, all X, and all match
-> match increases by 6 at once
meaning: match = 6 * (how many drawn numbers equal X)
מספיק שמספר מוגרל אחד מתוך השישה יהיה שווה ל-X, וכבר match == 6 - ניצחנו. במקום להצטרך שכל שישה יתאימו, אנחנו צריכים התאמה בודדת שנספרת שש פעמים. זה מקפיץ את הסיכוי מזעום לסביר לגמרי.
יש פה עדינות אחת: התנאי הוא match == 6 בדיוק. אם שני מספרים מוגרלים שווים ל-X, נקבל match == 12 ונפסיד. לכן ההגשה של שישה בתים זהים מנצחת בדיוק כשמספר מוגרל אחד (לא אפס, לא שניים) שווה ל-X. הסתברות לזה היא בערך 12.5% לניסיון - קצת פחות מ-1 ל-8. וזה בסדר גמור: פשוט נכתוב סקריפט שמנסה שוב ושוב עד הזכייה.
וכאן נכנס גם באג הטווח שהזכרנו: המספרים המוגרלים הם בטווח 1 עד 45 (כי (byte % 45) + 1). אם נגיש את הבתים כתווי ASCII, למשל נקליד "123456", הבתים בפועל הם 0x31 עד 0x36, כלומר 49 עד 54 - כולם מעל 45, וכולם לעולם לא יתאימו. לכן חייבים לשלוח בתים גולמיים בטווח 1 עד 45. עדיף אפילו לבחור ערך בטווח 1 עד 31, כי בגלל ה-% 45 הערכים 1 עד 31 יוצאים מעט יותר תדיר מהערכים 32 עד 45, מה שמעלה קצת את הסיכוי. הבחירה \x01 מצוינת.
חוסר בדיקת גבולות במספרים - integer bounds¶
הדפוס האחרון, זה של blackjack, הוא בדיקת גבולות חסרה על מספר שלם. משחק בלאק-ג'ק קורא את סכום ההימור כ-int ובודק אותו כך:
int cash = 500;
int bet;
int betting() {
printf("Enter Bet: $");
scanf("%d", &bet);
if (bet > cash) { // upper-bound check only
printf("You cannot bet more money than you have.\n");
printf("Enter Bet: ");
scanf("%d", &bet); // the bug: reads again without checking again!
return bet;
}
return bet;
}
יש כאן שתי בעיות שמשלימות זו את זו:
- אין גבול תחתון: הבדיקה היא רק
bet > cash. הימור שלילי עובר בשקט. אם מפסידים יד עם הימור שלילי, ה-cashמחוסר בסכום שלילי, כלומר גדל. - הענף השני לא נבדק: כשמהמרים יותר מ-
cash, התוכנית מתלוננת וקוראתscanfפעם שנייה - אבל מחזירה את הערך החדש בלי לבדוק אותו שוב. אז נהמר בפעם הראשונה סכום גדול מהמזומן שיש לנו כדי לעורר את ההודעה, ובפעם השנייה נזין מספר אסטרונומי שיתקבל כמו שהוא.
אחרי שהתקבל הימור ענק, מספיק לנצח יד אחת. לוגיקת הזכייה מוסיפה את ההימור ל-cash:
cash = cash + bet; // we won a hand -> cash jumps past the winning threshold
if (cash >= 1000000) {
system("/bin/cat flag");
}
הימור אחד של מיליון, זכייה אחת, וה-cash עובר את הסף שמדפיס את הדגל. שוב - אין כאן שום שיבוש זיכרון, רק בדיקת קלט חסרה.
איך ניגשים לאתגר לוגי - the workflow¶
התהליך מול אתגר כזה שונה מהחזית של buffer overflows, והוא בעיקר קריאה:
- קראו את קוד המקור עד הסוף. באתגרי pwnable.kr קובץ ה-
.cשם, לרוב לצד הבינארי. אל תדלגו על שורה. - סמנו כל תנאי, השמה ולולאה. על כל אחד שאלו: מה הטווח של כל משתנה? מה קורה בקצה? האם הקדימות היא מה שהמתכנת חשב?
- חשדו במקומות שבהם המשתמש שולט בשני צדי ההשוואה. אם אנחנו מספקים גם את "הסוד" וגם את הניחוש (כמו ב-mistake), ניצחנו לפני שהתחלנו.
- בדקו הרשאות. הבינארים האלה הם בדרך כלל setuid למשתמש עם הדגל, ולכן ריצה שלהם קוראת את קובץ ה-flag בשמנו.
ls -lיראה את ה-sבהרשאות.
הכלי המרכזי כאן הוא לא דיבאגר אלא עיניים. עדיין שווה להריץ checksec על הבינארי, ולו כדי לפתח את ההרגל ולהפנים ששום הגנת זיכרון לא רלוונטית לבאג לוגי.
קלט גולמי עם pwntools¶
הרבה מהאתגרים האלה דורשים לשלוח בתים גולמיים (לא ASCII) או לשחזר ניסיון בלולאה. הנה שלד קצר עם הספרייה pwntools שממחיש את שני הדברים - שליחת בתים גולמיים וחזרה על ניסיון עד הצלחה:
from pwn import *
context.log_level = 'error' # quiet, we're in a loop
tries = 0
while True:
tries += 1
p = process('./chal') # or remote(host, port) / ssh(...).process(...)
p.recvuntil(b'bytes : ') # waiting for the prompt
p.send(b'\x01' * 6) # raw bytes, not the string "111111"
out = p.recvall(timeout=2)
p.close()
if b'Jackpot' in out:
success(out.decode(errors='replace'))
log.info('won after %d attempts', tries)
break
שימו לב: p.send(b'\x01' * 6) שולח שישה בתים שערכם 1, ולא את המחרוזת "111111". זה בדיוק ההבדל בין בית גולמי לתו ASCII שדיברנו עליו.
סיכום¶
- באג לוגי הוא פער בין כוונת המתכנת לסמנטיקה של הקוד, בלי שום שיבוש זיכרון. הmitigations כמו canary, NX, ASLR ו-PIE חסרי משמעות מולו.
- קדימות אופרטורים ב-C היא מלכודת:
=נמוך מכל אופרטורי ההשוואה, ולכןfd = open(...) < 0הופך ל-fd = (open(...) < 0)ומציב0(שהואstdin) ל-fd. זה הבאג של mistake. - בלבול בין
=ל-==, ובין השמה להשוואה, נותן תנאים שתמיד אמת או ערכים שגויים בשקט. - על כל השוואה בדקו את טווחי הערכים: signed מול unsigned, בית גולמי מול תו ASCII, וקלט שחורג מהטווח הפנימי.
- לולאת השוואה מקוננת שסופרת התאמה בכל מיקום (במקום במקום המקביל) שוברת את ההיגיון - הגשת ערכים זהים גורמת להתאמה בודדת להיספר פעמים רבות. זה הבאג של lotto, יחד עם באג הטווח 1 עד 45.
- בדיקת גבולות חסרה על
int(אין גבול תחתון, ענף חוזר שלא נבדק שוב) מאפשרת הימור שלילי או ענק. זה הבאג של blackjack. - הכלי המרכזי הוא קריאת קוד מקור איטית ומדוקדקת, לא דיבאגר. שאלו על כל תנאי מה קורה בקצוות.