Lỗi tràn bộ đệm (5): căn bản về shellcode
5. Căn bản về shellcode
Để tận dụng khả năng thay đổi điều khiển của một chương trình bằng cách thay đổi địa chỉ trả về của một hàm, ta cần biết cách làm thế nào để bỏ một đoạn mã vào stack và bắt chương trình chạy đoạn mã đó. Hãy xét ví dụ sau đây:
/* ---------------------------------------------------------------------
* Example 3: "Hello, World!" using bytecode
* ---------------------------------------------------------------------
*/
char bytecode[] =
"\xeb\x1e\x59\xbb\x01\x00\x00\x00\xba\x0e\x00\x00\x00\xb8\x04\x00"
"\x00\x00\xcd\x80\xbb\x00\x00\x00\x00\xb8\x01\x00\x00\x00\xcd\x80"
"\xe8\xdd\xff\xff\xff\x48\x65\x6c\x6c\x6f\x2c\x20\x57\x6f\x72\x6c"
"\x64\x21\x0a";
int main() {
int *ret;
ret = (int *) &ret + 2;
(*ret) = (int) bytecode;
}
Dịch và chạy cho kết quả sau:
[NQH]:~/BO$ make 3 gcc -g ex3.c -o ex3 [NQH]:~/BO$ ./ex3 Hello, World! [NQH]:~/BO$
Tuyệt đẹp! Ở đây ta thay địa chỉ trả về của hàm main() cho nó trỏ vào đoạn mã bytecode - mã máy. Câu hỏi đầu tiên dĩ nhiên là: làm thế nào để viết mã máy? Chẳng ai có thể thuộc lòng tất cả các ánh xạ từ assembly sang mã máy cả, đó là chưa kể các ánh xạ này thay đổi tùy theo hệ điều hành và CPU.
Muốn biết viết bytecode như thế nào, ta phải biết assembly. Hai assembler thông dụng nhất cho cấu hình IA-32 là gas và nasm, trong đó gas dùng ngữ pháp AT&T giống như gdb, còn nasm dùng ngữ pháp Intel. Ngữ pháp Intel dễ đọc hơn, nên tôi sẽ dùng nasm làm ví dụ.
Có rất nhiều cách để tìm mã máy của một đoạn lệnh mà ta muốn thực thi, bao gồm các cách sau:
- Cách dài dòng: viết một đoạn C, dùng gdb dịch ra assembly xem thế nào, sau đó viết assembly và dịch ra mã máy.
- Cách ngắn: sau một thời gian tìm hiểu bằng C, nếu ta đã khá quen thuộc với assembly thì viết thẳng bằng assembly luôn rồi dịch sang mã máy.
- Cách lười: chép bytecode của người khác viết sẵn lấy về dùng (ví dụ bạn có thể lấy đoạn bytecode trên tôi đã viết mà không cần biết chi tiết).
- Cách chuyên nghiệp: xây dựng một thư viện bytecode cho riêng mình.
Dùng cách lười thì mình không biết thật sự cái đoạn bytecode đó làm gì, có khi bị chơi khăm có virus trong đó thì tiêu tán thoòng.
Tôi minh họa cách dài trước. Chương trình in “Hello, World!” bằng C trên Linux có thể viết như sau:
int main() { write(1, "Hello, World!\n", 14); }
Ở đây ta dùng system call của Unix để sau này minh họa cách viết shellcode (cần system call execve). Hãy xem tiếp (các chú thích sau các dấu “;” là tôi thêm vào để giải thích.)
[NQH]:~/BO$ gcc -static -g hw.c -o hw [NQH]:~/BO$ ./hw Hello, World! [NQH]:~/BO$ gdb hw GNU gdb 6.2-2.1.101mdk (Mandrakelinux) Copyright 2004 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i586-mandrake-linux-gnu"…Using host libthread_db library "/lib/i686/libthread_db.so.1". (gdb) disas main Dump of assembler code for function main: 0×080481f4: push %ebp 0×080481f5 : mov %esp,%ebp 0×080481f7 : sub $0×8,%esp 0×080481fa : and $0xfffffff0,%esp 0×080481fd : mov $0×0,%eax 0×08048202 : add $0xf,%eax 0×08048205 : add $0xf,%eax 0×08048208 : shr $0×4,%eax 0×0804820b : shl $0×4,%eax 0×0804820e : sub %eax,%esp 0×08048210 : sub $0×4,%esp ; now, push arguments of write() onto the stack ; last argument (14) of write() 0×08048213 : push $0xe ; next argument of write, pointer to “Hello, World!” 0×08048215 : push $0×808e4a8 ; first argument (1) of write() 0×0804821a : push $0×1 ; call write 0×0804821c : call 0×804db80 0×08048221 : add $0×10,%esp 0×08048224 : leave 0×08048225 : ret End of assembler dump. (gdb)
Bạn nhớ dùng liên kết tĩnh (-static), nếu không thì mã của hàm write() sẽ không được viết vào mã xuất mà sẽ được liên kết lúc load chương trình, và như thế thì ta khó dùng gdb để xem write() làm gì.
(gdb) disas write Dump of assembler code for function write: 0x0804db80: cmpl $0×0,0×80a4844 0×0804db87 : jne 0×804dbaa 0×0804db89 : push %ebx ; move last argument of write() into %edx 0×0804db8a : mov 0×10(%esp),%edx ; move next argument into %ecx 0×0804db8e : mov 0xc(%esp),%ecx ; move first argument into %ebx 0×0804db92 : mov 0×8(%esp),%ebx ; copy write()’s system call number into %eax 0×0804db96 : mov $0×4,%eax ; switch to kernel’s mode 0×0804db9b : int $0×80 0×0804db9d : pop %ebx 0×0804db9e : cmp $0xfffff001,%eax 0×0804dba3 : jae 0×8050010 <__syscall_error> 0×0804dba9 : ret 0×0804dbaa : call 0×804e2a0 <__librt_enable_asynccancel> 0×0804dbaf : push %eax 0×0804dbb0 : push %ebx 0×0804dbb1 : mov 0×14(%esp),%edx 0×0804dbb5 : mov 0×10(%esp),%ecx 0×0804dbb9 : mov 0xc(%esp),%ebx 0×0804dbbd : mov $0×4,%eax 0×0804dbc2 : int $0×80 0×0804dbc4 : pop %ebx 0×0804dbc5 : xchg %eax,(%esp) 0×0804dbc8 : call 0×804e2e0 <__librt_disable_asynccancel> 0×0804dbcd : pop %eax 0×0804dbce : cmp $0xfffff001,%eax 0×0804dbd3 : jae 0×8050010
Xem có vẻ phức tạp, nhưng ý chính rất đơn giản. Để gọi một system call như write(), ta bỏ mã số của write() vào thanh ghi %eax (write có mã số là 4). Sau đó lần lượt chép các tham số còn lại vào %ebx, %ecx, %edx, rồi chuyển sang kernel mode bằng lệnh int 0×80.
Mã số của các system calls có thể tìm được ở đây:
[NQH]:~/BO$ more /usr/include/asm/unistd.h #ifndef _ASM_I386_UNISTD_H_ #define _ASM_I386_UNISTD_H_ /* * This file contains the system call numbers. */ #define __NR_restart_syscall 0 #define __NR_exit 1 #define __NR_fork 2 #define __NR_read 3 #define __NR_write 4 #define __NR_open 5 #define __NR_close 6 … #define __NR_remap_file_pages 257 #define __NR_set_tid_address 258 #define __NR_timer_create 259 …
Dùng ý tưởng vừa học được này, ta có thể viết trực tiếp chương trình in “Hello, World!” bằng assembly như sau:
section .data ; section declaration hello db "Hello, World!", 0x0a ; "Hello, World!\n" section .text ; section declaration global _start ; default entry point for ELF linking _start: mov eax, 4 ; write() system call number mov ebx, 1 ; 1 is standard output mov ecx, hello ; pointer to "Hello, World!\n" mov edx, 14 ; length of output string int 0x80 ; finally, invoke write() ; prepare for exit(0) mov ebx, 0 ; argument for exit() mov eax, 1 ; system call number of exit() int 0x80 ; invoke exit(0)
Dịch và chạy cho kết quả
[NQH]:~/BO$ nasm -f elf hello_world.asm [NQH]:~/BO$ ld hello_world.o [NQH]:~/BO$ ./a.out Hello, World! [NQH]:~/BO$
Viết được “Hello, World!” bằng assembly rồi, nhưng có một vấn đề quan trọng ta phải giải quyết trước khi có thể chuyển nó thành bytecode thật sự. Trong bytecode thì ta không thể để chuỗi “Hello, World\n” vào data segment của chương trình đang chạy (vì data segment không ghi lên được). Ta phải tìm cách nào đó viết “Hello, World!” mà không dùng đến data segment.
Như vậy, chuỗi “Hello, World\n” phải được để ở chỗ nào đó trong text segment của chương trình. Nhưng ta lại cần địa chỉ trên bộ nhớ của chuỗi này để bỏ vào ecx trước khi gọi write(). Có vài cách để giải quyết vấn đề này, bao gồm hai cách sau đây. (Bạn có thể tự sáng tạo thêm cách khác.)
- Phối hợp jmp và call
- PUSH chuỗi cần dùng lên stack trong thời gian chạy
Trong các bài tới tôi sẽ minh họa cả hai cách.
