KỸ THUẬT KHAI THÁC LỖI TRÀN BỘ ĐỆM
trang này đã được đọc lầnTó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
Để 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:
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). |