Trao đổi với tôi

http://www.buidao.com

3/3/10

[Hacking] 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 [1]nasm [2], 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:

  1. 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.
  2. 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.
  3. 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).
  4. 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:
0x080481f4
: push %ebp
0x080481f5
: mov %esp,%ebp
0x080481f7
: sub $0x8,%esp
0x080481fa
: and $0xfffffff0,%esp
0x080481fd
: mov $0x0,%eax
0x08048202
: add $0xf,%eax
0x08048205
: add $0xf,%eax
0x08048208
: shr $0x4,%eax
0x0804820b
: shl $0x4,%eax
0x0804820e
: sub %eax,%esp
0x08048210
: sub $0x4,%esp
; now, push arguments of write() onto the stack
; last argument (14) of write()
0x08048213
: push $0xe
; next argument of write, pointer to "Hello, World!"
0x08048215
: push $0x808e4a8
; first argument (1) of write()
0x0804821a
: push $0x1
; call write
0x0804821c
: call 0x804db80
0x08048221
: add $0x10,%esp
0x08048224
: leave
0x08048225
: 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 $0x0,0x80a4844
0x0804db87
: jne 0x804dbaa
0x0804db89
: push %ebx
; move last argument of write() into %edx
0x0804db8a
: mov 0x10(%esp),%edx
; move next argument into %ecx
0x0804db8e
: mov 0xc(%esp),%ecx
; move first argument into %ebx
0x0804db92
: mov 0x8(%esp),%ebx
; copy write()'s system call number into %eax
0x0804db96
: mov $0x4,%eax
; switch to kernel's mode
0x0804db9b
: int $0x80
0x0804db9d
: pop %ebx
0x0804db9e
: cmp $0xfffff001,%eax
0x0804dba3
: jae 0x8050010 <__syscall_error>
0x0804dba9 : ret
0x0804dbaa
: call 0x804e2a0 <__librt_enable_asynccancel>
0x0804dbaf : push %eax
0x0804dbb0
: push %ebx
0x0804dbb1
: mov 0x14(%esp),%edx
0x0804dbb5
: mov 0x10(%esp),%ecx
0x0804dbb9
: mov 0xc(%esp),%ebx
0x0804dbbd
: mov $0x4,%eax
0x0804dbc2
: int $0x80
0x0804dbc4
: pop %ebx
0x0804dbc5
: xchg %eax,(%esp)
0x0804dbc8
: call 0x804e2e0 <__librt_disable_asynccancel>
0x0804dbcd : pop %eax
0x0804dbce
: cmp $0xfffff001,%eax
0x0804dbd3
: jae 0x8050010

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.


Article printed from Blog Khoa Học Máy Tính: http://www.procul.org/blog

URL to article: http://www.procul.org/blog/2005/10/29/l%e1%bb%97i-tran-b%e1%bb%99-o%e1%bb%87m-5-cnn-b%e1%ba%a3n-v%e1%bb%81-shellcode/

URLs in this post:

[1] gas: http://www.gnu.org/software/binutils/manual/gas-2.9.1/as.html

[2] nasm: http://nqh.blogspot.com/2005/10/li-trn-b-m-5-cn-bn-v-shellcode.html