Wednesday, August 17, 2005

Lỗi tràn bộ đệm (3)

3. Buffer overflow trong cấu hình Intel 32-bit

Bạn có thể tham khảo thêm về quá trình gọi hàm và tổ chức stack trong các manuals của Intel cho cấu hình IA-32, đặc biệt là chương 6 của Volume 1: Basic Architecture. Ở đây tôi chỉ tóm tắt các chi tiết chính
.

Trong miền bộ nhớ của một process, đáy stack bắt đầu từ một vị trí nhất định trong bộ nhớ, đỉnh stack thay đổi theo thời gian và được trỏ tới bởi stack pointer ( SP). Giá trị của biến SP nằm trong một thanh ghi (ESP) để truy cập nhanh. Stack chứa vài stack frames. Khi một hàm được gọi, stack frame tương ứng sẽ được PUSH(ed) vào stack. Stack frame của hàm được gọi chứa các tham số, biến cục bộ, và dữ liệu dùng để quay về hàm gọi.

Vì nhiều lý do, các bộ vi xử lý của Intel (IA-32, IA-34), Motorola, SPARC, và MIPS lưu giữ thêm một biến nữa gọi là frame pointer (FP, còn gọi là frame base pointer) trỏ về đáy của frame hiện tại trong stack. Trong cấu hình Intel, thanh ghi chứa base pointer là EBP. Thông thường thì hàm được gọi sẽ chép nội dung của ESP vào EBP trước khi PUSH các biến cục bộ lên stack. Các biến cục bộ, tham số, ... thường được truy cập theo địa chỉ tương đối từ FP.

Trong cấu hình i686 như tôi (và đa số các bạn xài PC) đang dùng thì stack phình xuống dưới vùng địa chỉ thấp, heap phình lên trên địa chỉ cao.

Để nhìn rõ hơn các khái niệm này, ta dịch ví dụ 1 ra Linux assembly dùng
gcc . Trình dịch gcc dùng ngữ pháp AT&T cho assembly file.

 hqn@hanoi (~/BO/Examples) % gcc -S -o e1.s e1.c


Xem file "e1.c", vài dòng đầu tiên của hàm foo là:

foo:
pushl %ebp
movl %esp, %ebp
subl $56, %esp

Biến trong thanh ghi (register) %ebp chính là FP cũ (trước khi gọi hàm foo), thanh ghi %esp chứa SP. Khi gọi một hàm mới, ta

  1. Ghi lại %ebp cũ bằng cách PUSH nó vào stack:
         pushl   %ebp

  1. Chép %esp vào %ebp để có FP mới (cho hàm sắp gọi):
         movl    %esp, %ebp

  1. Rồi chuyển SP "lên" đỉnh stack


(Lưu ý: thông thường thì là thế, nhưng các trình dịch không nhất thiết phải đi theo các bước này, nhất là khi ta chọn cho trình dịch tốt ưu hóa chương trình.)

Như vậy là cái stack frame mới cho foo có kích thước 56 bytes. Tại sao 56 bytes trong khi ta chỉ cần 20 bytes cho biến buffer, 8 bytes cho các biến "i" và "c", và 8 bytes nữa cho FP cũ và return address (tổng cộng 36 bytes)?

Để hiểu rõ hơn ta phải tham khảo các tài liệu về gcc và các yêu cầu về memory alignment của họ i686. Trình dịch gcc của GNU có một thuật toán allocate memory riêng cho từng cấu hình. Chi tiết này không quan trọng trong thảo luận của chúng ta. (Trong cấu hình i386 và i686, bạn có thể dùng chọn lựa

-mpreferred-stack-boundary

của gcc để ép trình dịch align memory theo số bytes nhất định. Các bộ vi xử lý khác cũng có chọn lựa tương tự.)

Điều ta biết (sau khi thí nghiệm vài lần) là trong stack của hàm foo, địa chỉ của buffer sẽ ở 44 bytes thấp hơn địa chỉ trả về của hàm foo (40 bytes từ buffer đến FP cũ, 4 bytes cho FP cũ). Ta có thể tận dụng kiến thức này để viết một đoạn chương trình lạ lùng như sau:

                                   - oOo -

/* ---------------------------------------------------------------------
* Vi' du. 2:
* ---------------------------------------------------------------------
*/
#include <stdio.h>

void foo() {
unsigned char buffer[20]; int i=0; int c;

/* ... ddoa.n ma~ gi` o+? dda^y cu~ng ddu+o+.c */

(*((int *) (buffer+44))) += 5;
}

int main() {
int x=0; foo(); x=1;
printf("x = %d\n", x);
return 0;
}
- oOo -

Dịch và chạy chương trình cho kết quả:

hqn@hanoi (~/BO/Examples) % e2
x = 0

Hừm ..., x=0 chứ không phải 1 ? Cái dòng lệnh

  (*((int *) (buffer+44))) += 5; 

đã làm gì nhỉ? Số là ta đã truy cập đến địa chỉ trả về của hàm foo và tăng nó lên 5, bỏ qua dòng gán x=1. Tại sao ta biết nhảy lên 5 bytes? Hãy thử disassemble chương trình e2 bằng gdb:

                                   - oOo -

hqn@hanoi (~/BO/Examples) % gdb e2
GNU gdb 5.2.1-2mdk (Mandrake Linux)
Copyright 2002 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"...
(gdb) disas main
Dump of assembler code for function main:
0x8048352 <main>: push %ebp
0x8048353 <main+1>: mov %esp,%ebp
0x8048355 <main+3>: sub $0x8,%esp
0x8048358 <main+6>: and $0xfffffff0,%esp
0x804835b <main+9>: mov $0x0,%eax
0x8048360 <main+14>: sub %eax,%esp
0x8048362 <main+16>: movl $0x0,0xfffffffc(%ebp)
0x8048369 <main+23>: call 0x804833c <foo>
0x804836e <main+28>: movl $0x1,0xfffffffc(%ebp)
0x8048375 <main+35>: sub $0x8,%esp
0x8048378 <main+38>: pushl 0xfffffffc(%ebp)
0x804837b <main+41>: push $0x80483d8
0x8048380 <main+46>: call 0x8048268 <printf>
0x8048385 <main+51>: add $0x10,%esp
0x8048388 <main+54>: mov $0x0,%eax
0x804838d <main+59>: leave
0x804838e <main+60>: ret
0x804838f <main+61>: nop
End of assembler dump.

- oOo -

Ta thấy sau khi gọi foo ở dòng

 0x8048369 <main+23>:    call    0x804833c <foo>

thì dòng kế tiếp gán 1 vào x. Lệnh này chiếm 5 bytes (từ 0x804836e đến 0x8048375). Dòng lệnh ngộ nghĩnh của chúng ta đã tăng địa chỉ trả về lên 5 bytes! Ta sẽ thử tìm cách dùng ý tưởng này để chạy một shell sau.