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 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, StackOverflow.

Overflow thì dễ rồi, dịch ra là tràn, mình đã nêu một ví dụ ở trên. Thế còn StackBuffer? Để 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ớ).

Memory Segmentation

Bộ nhớ của một chuơng trình đã được dịch phân chia thành 5 phần: text, data, bss, heapstack, 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 segmentbss 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\'\n", buffer_two, buffer_two);
    printf("[BEFORE] buffer_one is at %p and contains \'%s\'\n", buffer_one, buffer_one);
    printf("[BEFORE] value is at %p and is %d (0x%08x)\n", &value, value, value);
    printf("\n[STRCPY] copying %d bytes into buffer_two\n\n", strlen(argv[1]));
    
    strcpy(buffer_two, argv[1]);
    printf("[AFTER] buffer_two is at %p and contains \'%s\'\n", buffer_two, buffer_two);
    printf("[AFTER] buffer_one is at %p and contains \'%s\'\n", buffer_one, buffer_one);
    printf("[AFTER] value is at %p and is %d (0x%08x)\n", &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_onebuffer_two có kích cỡ là 8 chars (là 8 bytes), một biến intvalue 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_onebuffer_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

Exploitation

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>\n", argv[0]);
		exit(0);
	}
	if (check_authentication(argv[1]))
	{
		printf("\n-=-=-=-=-=-=-=-=-=-=-=-=-=-\n");
		printf("Access Granted.\n");
		printf("-=-=-=-=-=-=-=-=-=-=-=-=-=-\n");
	}
	else
	{
		printf("\nAccess Denied.\n");
	}
}

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 😄 chắc hẳn thời mới học ai ai cũng viết một đoạn chuơng trình đăng nhập như thế này.

Compile và chạy:

đơn giản, nhưng nó vẫn hoạt động : - )

Giờ thử cái này xem:

Chúc mừng, bạn đã vừa "hack" thành công! đơn giản quá mức tưởng tượng!

Ok, hãy cùng tìm hiểu điều gì vừa xảy ra, ở đây mình sẽ sử dụng gdb để debug, mình đặt 2 break points ở dòng 9 và 16:

breakpoint đầu tiên được đặt trước khi hàm strcpy() được gọi. Ở đây khi chúng ta kiểm tra con trỏ password_buffer, ta thấy nó đang chứa những dữ liệu ngẫu nhiên tại địa chỉ 0xbffff7a0, biến auth_flag đang ở địa chỉ 0xbffff7bc và giá trị 0. Sử dụng câu lệnh print, ta cũng có thể thấy địa chỉ của auth_flag cách địa chỉ của password_buffer là 28 bytes.

Tiếp tục với breakpoint tiếp theo, ở đây xuất hiện một thông tin quan trọng: password_buffer đã tràn tới phần bộ nhớ của auth_flag và sửa đổi 2 bytes của auth_flag sang giá trị 0x41. Do vậy, chuơng trình sẽ coi giá trị của biến này là một số nguyên (integer) và giá trị của nó là 16705.

Sau quá trình overflow này, hàm check_authentication() sẽ trả về giá trị 16705 thay vì 0. Trong C, khi câu lệnh if thấy một giá trị khác 0, nó sẽ tiến hành chạy câu lệnh bên trong, và Access Granted

Conclusion

vừa rồi, mình đã giới thiệu với các bạn khái niệm và các kiến thức liên quan đến lỗ hổng buffer overflow, và đặc biệt hơn, chúng ta đã "hack" thành công một chuơng trình rất cơ bản, nhưng nó là vừa đủ để có thể chúng ta hiểu hơn về những thứ thực sự xảy ra trong bộ nhớ máy tính.

Mong rằng qua bài này, các bạn sẽ có thể có cái nhìn sâu sắc và chi tiết hơn về loại lỗi này, từ đó đưa ra được các phuơng án phòng chống hiệu quả!

Chúc các bạn thành công!