Archive for August 23, 2019


Vulnerabilities

Trong phần này, chúng ta sẽ tìm hiểu về các lỗ hổng bảo mật và cách phân tích một số lỗi dễ khai thác nhất.

Các ví dụ minh họa trong bài viết download tại đây: https://mega.nz/#!SOAzVCCK!rPFdjLSpDGFlNCho-xkkcWOj0Sy3GbEzShHwGtiqwCE

Lỗ hổng là gì?

Trong lĩnh vực bảo mật máy tính, từ “vulnerability” ám chỉ đến điểm yếu trong một hệ thống cho phép kẻ tấn công có thể xâm phạm tính bảo mật, tính toàn vẹn, tính sẵn sàng của hệ thống đó, từ đó có thể kiểm soát truy cập và tính nhất quán của hệ thống hoặc dữ liệu và các ứng dụng.

Vulnerabilities” là kết quả của lỗi hoặc tính toán sai trong thiết kế của một hệ thống. Mặc dù, xét theo nghĩa rộng hơn, chúng cũng có thể là kết quả của những hạn chế về mặt công nghệ, bởi vì, về nguyên tắc, không có hệ thống nào là an toàn tuyệt đối 100%. Do đó, sẽ có những lỗ hổng trên lý thuyết và các lỗ hổng thực tế có thể bị khai thác.

Quy tắc tương tự áp dụng cho các chương trình. Một chương trình có khả năng bị khai thác lổ hổng là chương trình có lỗi và tùy thuộc vào loại lỗi, chúng có thể bị khai thác để thực thi mã khai thác trong chương trình đó. Bên cạnh đó, các cơ chế kiểm soát của chương trình cũng có thể bị lỗi và cho phép người sử dụng thực hiện các hành động bất hợp pháp, gây crash cho chương trình hoặc thông qua đó có thể nâng quyền, v…v…

Tất nhiên, ở mức độ lỗi liên quan đến bộ nhớ thì đơn giản nhất là tràn bộ đệm. Lỗi này xảy ra khi một chương trình dành ra một vùng nhớ hay vùng đệm để phục vụ lưu trữ dữ liệu và vì lý do nào đó kích thước của dữ liệu được sao chép vào vùng nhớ này không được kiểm tra đúng đắn, dẫn đến lỗi tràn bộ đệm xảy ra, sao chép dữ liệu vượt quá kích thước dành riêng, dẫn tới có thể ghi đè lên các biến, các tham số và các con trỏ trong bộ nhớ. Một trong những lỗi tràn bộ đệm kinh điển nhất là stack overflow (tràn ngăn xếp), xảy ra khi có tràn bộ đệm trong ngăn xếp. Trong mã nguồn của một chương trình C, một buffer có thể được khai báo đơn giản như sau:

char buf[xxx];

Trong đó xxx là kích thước của bộ đệm. Ở ví dụ dưới đây, buffer được khai báo có kích thước là 0x300 bytes (vì là kiểu char), như vậy trong ngăn xếp sẽ dành ra cho buffer này 0x300 bytes để lưu dữ liệu.

Ngoài việc khai báo một buffer thì đoạn code trên không thực hiện thêm công việc nào khác, nhưng tôi vẫn thực hiện biên dịch và phân tích nó trong IDA. File này có tên là Compiled_1.exe (Compilado_1.exe). Sau khi load vào IDA, tại cửa sổ Names, thực hiện tìm kiếm các tham số argc hoặc argv mà hàm main() sử dụng. Bằng cách này ta sẽ tìm được hàm main() của chương trình, kết quả có được như sau:

Nhấp đúp vào một trong hai kết quả được đánh dấu trên hình, IDA sẽ đưa ta tới đây:

Tiếp tục nhấp đúp chuột vào thông tin tại DATA XREF được highlight như trên hình:

Sử dụng tính năng xrefs của IDA, chúng ta sẽ đến với khối lệnh gọi tới hàm main (call sub_401000) của chương trình:

Đi vào hàm sub_401000(), ta thấy trong code của hàm không dành ra bất kì không gian nào cho biến buf bởi vì rõ ràng ở code của chương trình không hề sử dụng tới buffer này, nó chỉ thực hiện thiết lập thanh ghi EAX = 0 tương đương với câu lệnh return 0 trong hàm main().

Để chương trình thực hiện việc dành ra không gian cho bộ đệm thì trong hàm main() chúng ta buộc phải sử dụng tới buffer đã khai báo này. Do đó, thực hiện sửa lại code của chương trình như sau:

Ở đây tôi sử dụng hàm gets_s() để nhận thông tin mà người dùng gõ tại màn hình console và lưu thông tin đó vào trong bộ đệm buf mà chúng ta đã khai báo trong chương trình. Dữ liệu lưu tại buf có thể được sử dụng sau này.

Như trên hình, ta thấy hàm gets_s() nhận hai tham số truyền vào: một buffer để lưu input từ người dùng và số kí tự tối đa mà người dùng có thể nhập nhằm tránh không bị tràn (overflow). Như vậy, trong ví dụ này rõ ràng là không có overflow xảy ra, vì kích thước được sao chép không được lớn hơn 0x300 bytes của buffer đã tạo.

Căn cứ vào đoạn code trên thì bạn có thể thấy rằng sẽ không có khả năng để xảy ra overflow, vì dữ liệu được phép copy vào biến buf chỉ nhỏ hơn hoặc bằng 0x300 bytes mà thôi. Hãy phân tích thử trong IDA xem thế nào, file này có tên là Compiled_2.exe (Compilado_2.exe). Nếu được load kèm theo pdb file thì kết quả tại IDA sẽ tương tự như sau:

Với việc load kèm file pdb như vậy thì IDA sẽ nhận diện được các tham số và các biến tốt hơn nhiều. Chúng ta hãy phân tích một chút trong IDA, nhấp đúp vào một biến hoặc một tham số bất kỳ ta sẽ chuyển tới cửa sổ cung cấp thông tin về Stack của hàm main():

Tại đây, ta thấy ba tham số chính của hàm main()envp, argvargc, tuy nhiên trong thân của hàm main ta không sử dụng tới bất kì tham số nào. Các tham số này được truyền vào hàm main thông qua các câu lệnh push, trước khi chương trình có lời gọi thực sự tới hàm main().

Như vậy, với các lệnh push này sẽ truyền ba tham số vào Stack trước khi thực hiện lệnh CALL. Ta biết rằng, trước khi thực hiện hàm thì chương trình cũng sẽ lưu địa chỉ trở về (return address) vào Stack, mục đích của việc làm như vậy là để chương trình biết nơi mà nó sẽ trở về sau khi thoát khỏi hàm. Trong ví dụ này, địa chỉ trở về sẽ luôn có giá trị cố định là 0x401200 (nếu như ta không sử dụng cơ chế bảo vệ ASLR).

Như vậy, chương trình sẽ quay trở về địa chỉ này sau khi thực hiện xong hàm main(). Vì vậy, phải lưu lại giá trị 0x401200 (r) vào stack và nó nằm ở trên 3 tham số của hàm main():

Sau khi thực hiện truyền tham số cho main() và lưu lại địa chỉ trở về, chương trình bắt đầu thực hiện hàm main(). Việc đầu tiên mà tại mỗi hàm luôn thực hiện chính là lệnh PUSH EBP.

Lệnh này sẽ lưu vào stack giá trị của thanh ghi EBP, giá trị này đang được sử dụng bởi hàm đã gọi hàm main(). Do đó, giá trị này sẽ nằm ngay phía trên địa chỉ trở về là 0x401200. Chúng ta không biết được giá trị này bởi vì nó sẽ thay đổi khi thực thi chương trình. Ta chỉ cần nhớ rằng câu lệnh này sẽ lưu lại giá trị EBP của hàm gọi (caller) lên stack và nó sẽ nằm ngay trên địa chỉ trở về (r), hay ngắn ngọn hơn là lưu lại old frame vào Stack.

Câu lệnh tiếp theo được thực hiện là:

Lệnh này làm nhiệm vụ thiết lập lại thanh ghi EBP trở thành Base (cơ sở) của hàm main(), bằng cách gán nó bằng với ESP. Lệnh mov ở đây chỉ thay đổi giá trị của EBP, không tác động gì tới ngăn xếp. Sau khi thiết lập xong EBP, hàm main thực hiện lệnh sub esp, 0x304, lệnh này có nhiệm vụ di chuyển thanh ghi ESP để dành ra không gian cho các biến cục bộ và bộ đệm trong ngăn xếp. Các biến cục bộ và các buffer này sẽ nằm phía trên giá trị EBP vừa được lưu.

Như trên hình ta thấy không gian dành cho các biến cục bộ và bộ đệm sẽ nằm ngay phía trên s (Stored EBP). Tại hàm main(), ta sẽ thấy biến đầu tiên hầu như luôn luôn xuất hiện nếu chương trình áp dụng cơ chế bảo vệ Stack (stack canary), trong trường hợp này nó được đặt tên là var_4:

Như quan sát trong đoạn code trên, ta thấy nó đọc giá trị của __security cookie và lưu vào thanh ghieax, đây là một giá trị ngẫu nhiên, được sinh ra khác nhau mỗi khi chương trình được thực thi. Sau đó, nó được đem xor với EBP và lưu lại vào biến var_4 như chúng ta đã thấy. Do đó, ta đổi tên nó thành CANARY.

Tiếp theo, ta quan sát và thấy ở trên biến CANARY này là biến Buf. Kiểm tra thông tin của Buf tại cửa số Stack của hàm main().

Một kinh nghiệm nhỏ khi phân tích các biến của một hàm đó là, nếu ta thấy bên dưới của một biến là không gian trống liên tục (gồm toàn các db ? tương tự như trên hình) tại cửa sổ Stack của hàm, thì rất có thể đó là một buffer. Do đó, tôi nhấn phải chuột tại Buf và chọn Array:

Ta thấy IDA tự động tính toán được kích thước của mảng này là 768 (ở hệ thập phân), và mỗi phần tử của mảng có kích thước là 1 byte. Nếu ta chuyển đổi giá trị 768 sang hệ hexadecimal thì sẽ chính là 0x300. Ta nhấn OK để chấp nhận thông tin mà IDA đã cung cấp. Như vậy, ta đã có được biến Buf như trong mã nguồn gốc của chương trình, được định nghĩa là một bộ đệm có kích thước 0x300 bytes ở hệ hexa hay 768 ở hệ thập phân.

Tiếp theo, bên dưới ta sẽ thấy lời gọi tới hàm gets_s(), hàm này nhận hai tham số truyền vào như đã phân tích ở trên. Trong đó, tham số thứ hai là kích thước tối đa, ở đây là 0x300 và tham số thứ nhất là địa chỉ của buffer, địa chỉ này có được thông qua lệnh lea:

Với thông tin như vậy ta đã xác minh được rằng kích thước của Buf0x300 bytes và thông qua hàmgets_s() thì dữ liệu sao chép vào buffer này tối đa là 0x300. Như vậy, rõ ràng là nếu chúng ta có thể làm tràn bộ đệm này bằng cách ghi vào đó nhiều hơn 0x300 bytes, thì dữ liệu ghi vào sẽ ghi đè lên biến CANARY, giá trị của EBP (đã lưu bằng lệnh push ebp) và địa chỉ trở về r (return address) nằm ngay bên dưới buffer:

Tuy nhiên, ở ví dụ này thì ta sẽ không thể thực hiện overflow được vì code của chương trình đã kiểm soát tốt vùng buffer. Ta xem xét một ví dụ khác như sau:

Trong ví dụ tiếp theo này, chương trình yêu cầu người dùng nhập vào một số tùy ý và số đó sẽ tương ứng với kích thước tối đa của dữ liệu được phép sao chép vào buffer. Nếu như kích thước này được kiểm tra kĩ lưỡng thì chương trình sẽ tránh được lỗi tràn bộ đệm. Nếu ta thay lại kích thước của biến buf trên thành 0x10 byte hay tương ứng với 16 ở hệ thập phân và yêu cầu người dùng nhập vào kích thước của buffer, kích thước này sau đó sẽ được sử dụng trong hàm gets_s.

Tuy nhiên, trong code của chương trình lại không hề kiểm tra giá trị kích thước tối đa mà buf có thể chấp nhận, tức là kiểm tra giá trị nhập vào có lớn hơn kích thước khai báo của buf hay không. Nếu ta biên dịch chương trình và thực thi để kiểm tra (Compiled_3.exe (Compilado_3.exe)):

Có lỗi đã xảy ra và như vậy khả năng chương trình có thể có vuln. Thử load chương trình vào trong IDA (ngay cả khi chúng ta không có mã nguồn của nó), ta sẽ thấy code như sau:

Giống như ví dụ trước, ta đã biết được đâu là biến CANARY, ở ngay phía trên của CANARYBuf. Chiều dài hay kích thước của buffer là 16 byte tương ứng với 0x10 ở hệ hexa:

Vì vậy, nếu ta có thể sao chép nhiều hơn 16 bytes vào bộ đệm thì sẽ xảy ra overflow và dữ liệu sẽ được ghi đè lên các giá trị CANARY, Stored ebpReturn Address:

Ta xem xét tiếp biến Size:

Sau khi in ra màn hình thông báo “Please Enter Your Number” bằng hàm printf(),chương trình sử dụng hàm scanf_s() để đọc dữ liệu mà người dùng nhập vào từ bàn phím và lưu giá trị nhập vào đó vào biến Size. Đây là một biến có kích thước dword và địa chỉ của biến được lấy ra bằng lệnh LEA vào thành ghi eax như các bạn đã thấy trên hình.

Thông tin về hàm scanf_s() trên MSDN như sau:

Như vậy, hàm scanf_s() chịu trách nhiệm đọc dữ liệu từ stdin (standard input stream) và ghi dữ liệu vào vị trí được cung cấp bởi tham số truyền vào. Mỗi tham số của hàm này phải là một con trỏ tới một biến có kiểu tương ứng với kiểu dữ liệu được chỉ ra trong format. Do đó, hàm scanf_s() sẽ trái ngược với hàm printf(), thay vì in ra màn hình một chuỗi theo định dạng thì nó nhận dữ liệu do người dùng nhập vào, tham số lưu dữ liệu có kiểu trùng với kiểu định nghĩa bởi format. Ví dụ, trong trường hợp này là “%d” thì có nghĩa là nhận một số thập phân mà người dùng nhập vào, cho nên biến Size phải có kiểu là int.

Bằng cách này, khi bạn gọi hàm gets_s() mà sử dụng kích thước do người dùng nhập vào trước đó, thì gets_s() sẽ sao chép số byte tương ứng và nếu như lớn hơn 0x10 thì sẽ xảy ra tràn bộ đệm.

Một giải pháp khả thi để bảo vệ là sẽ tiến hành kiểm tra độ dài của kích thước mà người dùng nhập vào trước khi thực hiện hàm gets_s(), như trong ví dụ minh họa dưới đây:

Phần 20 xin phép được dừng lại ở đây. Trong phần tiếp theo chúng ta sẽ cùng phân tích xem với giải pháp trên thì có thể giúp chương trình hết vuln không hay là vẫn bị dính. Target đã được biên dịch sẵn với tên là VULNERABLE_o_NO.exe để các bạn phân tích thử. Chúng ta sẽ thảo luận về nó ở phần sau.

Hẹn gặp các bạn ở phần 21.

Xin gửi lời cảm ơn chân thành tới thầy Ricardo Narvaja!

m4n0w4r

Ủng hộ tác giả

Nếu bạn cảm thấy những gì tôi chia sẻ trong bài viết là hữu ích, bạn có thể ủng hộ bằng “bỉm sữa” hoặc “quân huy” qua địa chỉ:

Tên tài khoản: TRAN TRUNG KIEN
Số tài khoản: 0021001560963
Ngân hàng: Vietcombank