Stack Overflow & Buffer Overflow: Introduction and Exploitation
Lỗ hổng Buffer Overflow đã tồn tại từ những ngày đầu tiên xuất hiện máy tính và vẫn còn tồn tại cho tới ngày nay. Rất nhiều worms trên internet sử dụng lỗ hổng này để tiến hành khai thác máy tính của nạn nhân. Hôm nay chúng ta sẽ cùng tìm hiểu cụ thể loại lỗi này và cùng nhau "hack" thử một chuơng ...
Lỗ hổng Buffer Overflow đã tồn tại từ những ngày đầu tiên xuất hiện máy tính và vẫn còn tồn tại cho tới ngày nay. Rất nhiều worms trên internet sử dụng lỗ hổng này để tiến hành khai thác máy tính của nạn nhân. Hôm nay chúng ta sẽ cùng tìm hiểu cụ thể loại lỗi này và cùng nhau "hack" thử một chuơng trình đơn giản để hiểu sâu hơn về nó.
Trong bài viết này, mình sẽ dùng C để minh họa. C là một ngôn ngữ lập trình cấp cao, nhưng tính toàn vẹn của dữ liệu trong C lại phụ thuộc vào lập trình viên. Nếu như việc này được xử lý bởi compiler, thì chuơng trình khi được dịch ra sẽ chạy rất chậm (vì phải nó phải thêm một bước kiểm tra tính toàn vẹn của mọi biến trong chuơng trình). Hơn nữa, C vốn nổi tiếng là cho lập trình viên can thiệp sâu nhất có thể vào chuơng trình, khiến họ có thể tinh chỉnh và điều khiển mọi thứ, nếu việc này được xử lý bởi compiler, chúng ta sẽ khó có thể sử dụng C một cách tự do và hiệu quả nhất.
Tuy nhiên điều gì cũng có 2 mặt. Chính vì C cho phép chúng ta có thể toàn quyền điều khiển chuơng trình, cho nên chúng ta có thể cho ra một chuơng trình bị dính các lỗ hổng buffer overflows và memory leaks nếu không sử dụng cẩn thận. Cụ thể hơn, khi một biến được cấp phát bộ nhớ, C không có một cơ chế rõ ràng nào để đảm bảo rằng giá trị của biến đó sẽ sử dụng đúng bộ nhớ vừa được cấp phát. Nếu chúng ta đặt một giá trị có dung lượng 10 btýe vào một biến chỉ được cấp phát 8 bytes, C vẫn cho phép chúng ta làm như vậy, và trong đại đa số trường hợp, nó sẽ gây crash chuơng trình. Đây chính là dạng cơ bản nhất của lỗ hổng Buffer Overflow, vì chúng ta đã tràn (overflow) ra 2 bytes dữ liệu ra khỏi bộ nhớ được cấp phát, và 2 bytes đó sẽ ghi đè (overwritten) lên bộ nhớ được cấp phát của nhưng đoạn dữ liệu tiếp theo. Nếu như dữ liệu quan trọng bị ghi đè, chuơng trình sẽ crash.
Trước khi đi vào code example và cách khai thác lỗ hổng này, chúng ta cần làm rõ khái niệm về Buffer, Stack và Overflow.
Overflow thì dễ rồi, dịch ra là tràn, mình đã nêu một ví dụ ở trên. Thế còn Stack và Buffer? Để hiểu rõ 2 khái niệm này, ta cần phải nắm rõ được cách mà bộ nhớ được cấp phát, tổ chức cho chuơng trình. Những điều này được gói gọn trong khái niệm Memory Segmentation (Phân đoạn bộ nhớ).
Bộ nhớ của một chuơng trình đã được dịch phân chia thành 5 phần: text, data, bss, heap và stack, hãy cùng đi sâu vào những khái niệm này:
- text segment: hay còn gọi là code segment, là phân đoạn bộ nhớ chứa mã máy đã được biên dịch từ chuơng trình nguồn. Những câu lệnh chứa trong bộ nhớ này không chạy liên tục, do thường ở chuơng trình nguồn, ta luôn có các hàm, các cấu trúc điều khiển, và những hàm và cấu trúc điều khiển đó khi biên dịch xuống mã máy sẽ thành những lệnh rẽ nhánh, nhảy,... Khi chuơng trình thực thi, EIP (extended instruction pointer - con trỏ lệnh) sẽ tìm đến vùng bộ nhớ này trước tiên. Bộ vi xử lý tiến hành theo quy trình sau:
- Đọc câu lệnh mà EIP đang trỏ tới
- Thêm dung lượng (tính theo bytes) mà câu lệnh cần
- Thực hiện câu lệnh đã đọc ở bước 1
- Trở lại bước 1
Vùng bộ nhớ này có đặc điểm quan trọng là chống ghi, vì nó không được sử dụng để chứa các biến mà chỉ là mã máy của chuơng trình nguồn. Nếu như bằng một cách nào đó, chúng ta cố tình ghi vào mộ nhớ này, thì chuơng trình sẽ crash. Một đặc điểm khác của vùng nhớ này là nó có một kích thước cố định, không thể co giãn, cũng là để đảm bảo được tính chống ghi.
- data segment và bss segment: dùng để lưu trữ các biến global và static của chuơng trình. Data segment chứa những biến đã được khỏi tạo giá trị, bss segment chứa những biến chưa được khởi tạo giá trị. Mặc dù vùng nhớ này không có tính chống ghi, nhưng nó vẫn có kích thước cố định. Điều này là do bất kể ở trong context nào của chuơng trình đang chạy, thì các biến này cũng không thay đổi.
- heap segment: là vùng nhớ mà chúng ta có thể điều khiển trực tiếp. Vùng nhớ này cho phép chúng ta có thể thay đổi kích thước tùy vào nhu cầu của chuơng trình. Dung lượng của vùng nhớ này được quản lý thông qua các thuật toán cấp phát (allocated) và thu hồi (deallocator).
- stack segment: là vùng nhớ được sử dụng để lưu trữ các biến local (được dùng trong hàm). Đây chính là nơi mà GDB sử dụng để có thể in ra các stacktrace để chúng ta debug. Khi chuơng trình gọi đến hàm chứa một số biến được truyền vào, EIP sẽ chuyển từ flow chuơng trình chính sang context của hàm, stack sẽ được sử dụng để ghi nhớ lại những biến đã được truyền vào. Vị trí của EIP sẽ được trả lại sau khi hàm được thực hiện xong. Tất cả những thông tin này đựoc lưu trên stack frame, một stack sẽ chứa nhiều stack frames.
OK, hơi nhiều lý thuyết, tạm thời bạn chỉ cần nhớ những điều sau:
- Bộ nhớ của một chuơng trình khi hoạt động sẽ chia thành 5 vùng.
- stack là nơi lưu trữ các biến local và context của function.
Như vậy, sau phần này, ta có thể hiểu lỗi stack overflow là lỗi liên quan đến tràn bộ nhớ tại vùng nhớ này.
Vậy còn buffer overflow? khái niệm này khá dễ hiểu nhưng hơi khó giải thích một chút, vì vậy hãy cùng xem xét đoạn code sau:
#include <stdio.h> #include <string.h> int main(int argc, char *argv[]) { int value = 5; char buffer_one[8], buffer_two[8]; strcpy(buffer_one, "one"); strcpy(buffer_two, "two"); printf("[BEFORE] buffer_two is at %p and contains '%s' ", buffer_two, buffer_two); printf("[BEFORE] buffer_one is at %p and contains '%s' ", buffer_one, buffer_one); printf("[BEFORE] value is at %p and is %d (0x%08x) ", &value, value, value); printf(" [STRCPY] copying %d bytes into buffer_two ", strlen(argv[1])); strcpy(buffer_two, argv[1]); printf("[AFTER] buffer_two is at %p and contains '%s' ", buffer_two, buffer_two); printf("[AFTER] buffer_one is at %p and contains '%s' ", buffer_one, buffer_one); printf("[AFTER] value is at %p and is %d (0x%08x) ", &value, value, value); }
giải thích qua về chuơng trình trên:
- ở đây mình khai báo 2 chuỗi là buffer_one và buffer_two có kích cỡ là 8 chars (là 8 bytes), một biến int là value với giá trị là 5.
- mình copy chuỗi "one" vào buffer_one và "two" vào biến buffer_two.
- in ra địa chỉ và nội dung hiện tại của các biến value, buffer_one và buffer_two. Ta có thể tạm hiểu, buffer ở đây chính là các biến để chứa giá trị nhập vào từ các tham số dòng lệnh.
Vậy còn buffer overflow?
Hãy thử chạy chuơng trình như sau: gcc buffer_overflow.c ./a.out 1234567890 bạn sẽ nhận được kết quả như sau
[BEFORE] buffer_two is at 0x7fff27526ec0 and contains 'two' [BEFORE] buffer_one is at 0x7fff27526eb0 and contains 'one' [BEFORE] value is at 0x7fff27526eac and is 5 (0x00000005) [STRCPY] copying 10 bytes into buffer_two [AFTER] buffer_two is at 0x7fff27526ec0 and contains '1234567980' [AFTER] buffer_one is at 0x7fff27526eb0 and contains 'one' [AFTER] value is at 0x7fff27526eac and is 5 (0x00000005) *** stack smashing detected ***: ./phuong terminated [1] 20073 abort (core dumped) ./phuong 1234567980
bạn khai báo 8 bytes cho buffer_two, nhưng lại truyền vào nó một giá trị 10 bytes -> vậy buffer của bạn sẽ bị "tràn" ra 2 bytes, đó chính là Buffer Overflow
OK, đầu tiên, chúng ta xây dựng một chuơng trình xác thực đơn giản:
#include <stdlib.h> #include <string.h> #include <stdio.h> int check_authentication(char *password) { int auth_flag = 0; char password_buffer[16]; strcpy(password_buffer, password); if (strcmp(password_buffer, "messi") == 0) auth_flag = 1; if (strcmp(password_buffer, "xavi") == 0) auth_flag = 1; return auth_flag; } int main(int argc, char *argv[]) { if (argc < 2) { printf("Usage: %s <password> ", argv[0]); exit(0); } if (check_authentication(argv[1])) { printf(" -=-=-=-=-=-=-=-=-=-=-=-=-=- "); printf("Access Granted. "); printf("-=-=-=-=-=-=-=-=-=-=-=-=-=- "); } else { printf(" Access Denied. "); } }
do trên viblo mình không rõ cách hiển thị số dòng nên mình sẽ cap tạm một phần của chuơng trình ở đây:
Bạn có thể thấy, đây là cơ chế xác thực đơn giản nhất có thể có, nhập vào password và so sánh nó với 1 chuỗi cho trước, đúng thì cho vào, không thì thôi