REVERSING WITH IDA FROM SCRATCH (P21)

m4n0w4r
tradahacking
Published in
17 min readSep 25, 2019

Ở phần 20, tôi đã đưa ra một bài tập nhỏ để các bạn phân tích xem chương trình có khả năng bị vuln hay là không. Trong phần 21 này, tôi sẽ cùng với các bạn tìm hiểu, phân tích và đưa ra câu trả lời. Mã nguồn gốc của chương trình được thầy Ricardo Narvaja cung cấp lại dưới đây để các bạn có hình dung rõ ràng hơn về chương trình. Các file .idb và file thực thi để bạn có thể reverse bằng IDA và thực hiện debug nếu cần có thể download tại: https://mega.nz/#!uXQExSYD!6GT0LsA803BVGoXp82muCnk-GsCEGBEuT7BZwrhSBBA

Đây là toàn bộ mã nguồn của chương trình:

Giống như ở các phần trước, trước tiên ta sẽ tiến hành việc phân tích tĩnh chương trình bằng IDA. Sau khi load vào IDA, ta dừng lại tại hàm main(). Tại đây, giống như phân tích ở phần trước, ta đã biết được nhiệm vụ của biến var_4, do đó đổi tên nó thành CANARY như đã làm.

Nhấp đúp vào biến Buf, IDA sẽ chuyển tới cửa sổ biểu diễn Stack của hàm main(). Tiếp theo, chuột phải tại biến Buf và chọn Array:

IDA tự động nhận diện được kích thước của buffer là 16 bytes. Nhấn OK để chấp nhận, ta có được Stack layout của hàm main() như sau:

Theo lý thuyết đã tìm hiểu, nếu như chúng ta có thể ghi dữ liệu vào buffer này nhiều hơn 16 bytes thì chương trình sẽ có khả năng bị lỗi. Các bạn đừng bị nhầm lẫn giữa các lỗi phổ biến hoặc lỗi crash chương trình với các lỗ hổng, không phải cứ gây crash cho một chương trình thì khẳng định ngay đó là lỗ hổng. Ví dụ, tôi có các câu lệnh sau:

XOR ECX, ECX 
DIV ECX

Đây là một lỗi vì ta biết câu lệnh XOR sẽ xóa thanh ghi ECX hay cụ thể hơn sau lệnh XOR thì ECX sẽ bằng 0x0. Khi thực hiện câu lệnh DIV ECX, tức là thực hiện phép chia cho số 0, điều này sẽ gây ra một ngoại lệ ( exception), nếu exception này không được xử lý thì sẽ gây crash chương trình. Như đã nói, có nhiều loại lỗ hổng khác nhau và ở bài viết này tôi chỉ tập trung vào một lỗi đơn giản là Buffer Overflow.

Khi xảy ra tràn tại một Buffer của chương trình tức là ta có thể ghi dữ liệu vượt ra ngoài không gian dành riêng cho Buffer đó, chương trình lúc đó sẽ được xem là Vulnerable bởi vì lỗi Buffer Overflow có thể xảy ra. Tiếp theo, ta sẽ phải kiểm tra xem bên cạnh việc chương trình có Vulnerable thì có thể Exploitable hay không? Vì nhiều lúc chương trình có thể có Vulnerable nhưng lại không thể Exploitable bởi chương trình đã có biện pháp phòng tránh hoặc các cơ chế mitigation của hệ thống như Stack Canary giúp ngăn chặn việc khai thác lổ hổng của chương trình. Tuy nhiên, đó sẽ một chủ đề khác ở các bài viết tiếp theo.

Vì vậy, ý tưởng ở đây là ta sẽ phân tích nếu buffer (cụ thể ở ví dụ này) với kích thước 16-bytes mà bị tràn, bên dưới nó sẽ là biến CANARY, nếu ta ghi đè được dữ liệu lên biến này khi đẩy dữ liệu vào Buf thì rõ ràng là sẽ có lỗiBuffer Overflow.

Tại IDA, bên dưới lệnh in ra màn hình chuỗi “Please Enter Your Number Of Choice” ta bắt gặp khối lệnh sau:

Toàn bộ đoạn code asm trên hình tương ứng với câu lệnh sau trong mã nguồn gốc:

while ((c = getchar()) != '\n' && c != EOF);

Lệnh này thường hay được sử dụng sau hàm scanf, sử dụng hàm getchar() đọc để đọc giá trị 0xA ( là mã ASCII tương ứng với New line — line break (ngắt dòng)) từ standard input (do người dùng nhập vào). Tóm lại, ở đây hàm getchar() sẽ trả về mã phím do người dùng ấn, ‘\n’ sẽ tương ứng với việc người dùng nhấn phím Enter. Vòng lặp while sẽ kiểm tra phím người dùng nhập vào, nếu nhập vào Enter thì thoát vòng lặp, còn nhập vào phím khác là nó quay lại vòng lặp. Về bản chất khi lập trình thì người ta có thể ngầm hiểu việc nhấn phím “ Enter” là sự kết hợp của hai mã ASCII là 0x13 (carriage return)0xA (line feed).

Bảng mã các kí tự ASCII (không thể in ra màn hình): 
Ascii code 00 = NULL (null character)
Ascii code 01 = SOH (Header start)Ascii code 02 = STX (Start of text)Code ascii 03 = ETX (End of text, heart stick English poker cards)Code ascii 04 = EOT (End of transmission, stick diamonds decks of poker)Code ascii 05 = ENQ (Consult, stick clubs English poker cards)Ascii code 06 = ACK (Recognition, stick cards poker cards)Ascii code 07 = BEL (Ring)Ascii code 08 = BS (Backspace)Ascii code 09 = HT (horizontal Tab)Ascii code 10 = LF (New line - line break)Ascii code 11 = VT (Vertical Tab)Ascii code 12 = FF (New page - page break)Ascii code 13 = CR (ENTER - carriage return)

Để tiện cho việc kiểm tra chương trình, ở đây chúng ta sẽ sử dụng một đoạn python script như sau:

from subprocess import *import timep = Popen([r'VULNERABLE_o_NO.exe','f'],stdout=PIPE,stdin=PIPE, stderr=STDOUT)print "Attach to the debugger and press Enter\n"raw_input()size="10\n"p.stdin.write(size)time.sleep(0.5)user_input="AAAA\n"p.stdin.write(user_input)testresult = p.communicate()[0]time.sleep(0.5)print(testresult)print sizeprint user_input

Script trên sử dụng module của python là subprocess để tương tác, quản lý process. Chi tiết về module này có thể xem tại đây: https://docs.python.org/2/library/subprocess.html. Đầu tiên script sử dụng Popen để khởi động process:

p = Popen([r'VULNERABLE_o_NO.exe','f'],stdout=PIPE,stdin=PIPE, stderr=STDOUT)

Kèm theo đó nó thực hiện chuyển hướng stdinstdout để từ đó ta có thể gửi các kí tự cho process này như thể là ta thực hiện nhập dữ liệu trên màn hình. Sau đó, ta sử dụng hàm raw_input() để sau khi process khởi động xong thì script sẽ dừng lại cho đến khi nhấn phím Enter. Việc này cho phép ta có thể dùng IDA để attach process, qua đó sẽ chờ thông tin nhập vào qua stdin.

Tiếp theo, script sẽ tự động thực hiện việc truyền dữ liệu cho process:

size="10\n"p.stdin.write(size)time.sleep(0.5)user_input="AAAA\n"p.stdin.write(user_input)

Trong mã nguồn gốc của chương trình ta thấy sẽ có hai lần nó yêu cầu người dùng nhập dữ liệu, vì vậy tại python script ta cũng thực hiện công việc tương tự. Đầu tiên, chương trình yêu cầu nhập vào một số tùy chọn và gán cho biến size, do đó trong script tôi truyền vào số 10. Tiếp theo, chương trình sử dụng gets_s() để nhận dữ liệu người dùng nhập vào với kích thước mà ta đã nhập trước đó, ở đây tôi truyền vào 4 chữ A, nhỏ hơn so với kích thước đã nhập.

Kiểm tra thử xem script có hoạt động đúng như chúng ta nghĩ hay không? Chạy thử script, nó sẽ dừng lại tại thông báo “ Attach to the debugger and press Enter “ để chờ ta nhấn Enter, như vậy ta sẽ có thể attach nó vào IDA để debug:

Hiện tại, ta vẫn đang mở file VULNERABLE_o_NO.exe để phân tích nó bằng Loader của IDA, do vậy để có thể attach được process thì chọn

sau đó vào menu Debugger và chọn Attach to process. Cửa sổ Choose process to attach to sẽ xuất hiện tương tự như sau:

Cuộn chuột xuống dưới để tìm tới process của chúng ta:

Chọn process cần attach và nhấn OK. Sau khi IDA attach xong sẽ dừng lại, lúc đó ta nhấn F9 để cho process tiếp tục thực thi. Sau đó, trước khi nhấn Enter tại màn hình thực thi python script, ta sẽ đặt một Breakpoint sau lệnh scanf_s, vì khi ta nhấn Enter thì script sẽ tự động truyền các input mà process yêu cầu. Đặt breakpoint tương tự như hình dưới:

Sau khi đặt bp xong, quay lại màn hình thực thi script và nhấn Enter. Lúc đó tại IDA sẽ break tại bp ta vừa đặt:

Với việc break tại breakpoint như trên, ta hi vọng biến Size lúc này sẽ chứa giá trị là 10 như ta đã thực hiện trong script. Để kiểm tra ta di chuyển chuột và trỏ vào biến Size, IDA sẽ cung cấp thông tin như sau:

Như vậy, ta thấy rằng biến Size đã nhận giá trị là 0xA ( tương đương với 10 ở hệ thập phân) đúng như giá trị đã truyền vào thông qua script. Nên nhớ rằng khi làm việc với disassembler/debugger thìcác số thập phân đều được chuyển đổi sang hệ hexa.

Nhấn F8 để trace tới hàm getchar(), các bạn sẽ thấy bên dưới có một lệnh so sánh thanh ghi eax ( lưu kết quả trả về của hàm ) với 0xA, mà thực tế là ta không hề truyền bất kỳ kí tự 0xA nào thông qua script cả.

Quay lại một chút các lệnh trong python script:

size="10\n"p.stdin.write(size)time.sleep(0.5)user_input="AAAA\n"p.stdin.write(user_input)

Nếu các bạn để ý thì với escape characternewline (\n) trong script sẽ tương ứng với ascii code là 10 (0xA) — Line Feed và CarriageReturn (\r)13 (0xD). Do đó, khi nhấn F8 để trace qua hàm getchar() và quan sát giá trị của thanh ghi eax, ta sẽ thấy kết quả như sau:

Như vậy ta thấy rằng thanh ghi eax sẽ lưu ascii code trả về từ hàm getchar()0xA. Do đó, chương trình ngầm hiểu rằng người dùng đã nhấn phím Enter nên sau khi so sánh sẽ thoát khỏi vòng lặp. Mục đích của đoạn code trên là khi đọc ra giá trị 0xA sẽ thực hiện loại bỏ nó ra khỏi stdin và xóa nó để phục vụ cho việc nhập dữ liệu tiếp theo từ bàn phím.

Sau khi thoát khỏi vòng lặp ta sẽ tới đoạn code so sánh kích thước ta truyền cho biến Size0xA với kích thước tối đa mà buffer có thể chấp nhận là 0x10. Và do lúc này giá trị của biến Size nhỏ hơn nên chương trình sẽ tiếp tục đi tới nhánh gọi hàm gets_s():

Tiếp tục trace bằng F8 qua lời gọi hàm gets_s():

Di chuyển chuột và lựa chọn biến Buf để kiểm tra xem sau khi thực hiện hàm gets_s() thì biến này đang lưu gì. Ta có kết quả như sau:

Ta thấy rằng biến Buf lúc này đang lưu các chữ cái ‘ A’ mà đã truyền vào thông qua script. Như vậy, script đã hoạt động rất tốt, qua đó cho phép ta có thể kiểm tra và debug những gì sẽ xảy ra trong chương trình. Tạm thời dừng tại đây chút. Bây giờ nếu tôi cho thực thi lại script và làm lại công việc trên từ đầu, tuy nhiên khi tới lời gọi tới hàm getchar() thì tôi bỏ qua nó bằng cách thay đổi giá trị của thanh ghi EIP để trỏ tới lệnh khác và bỏ qua đoạn đọc ra 0xA để so sánh:

Khi ta thay đổi EIP như trên thì lúc đó chương trình sẽ không thực hiện lọc ascii code 0xA nữa. Vậy ta sẽ xem điều gì xảy ra nếu bỏ qua đoạn code đó?

Trace code bằng F8 qua lời gọi hàm gets_s(), khi quan sát nội dung của biến Buf thì ta thấy rằng nó không có thông tin gì cả, như kiểu là ta chẳng nhập gì cho chương trình ( mặc dù rõ ràng script không hề thay đổi):

Điều này dẫn tới kết luận là kí tự 0xA vẫn còn trong stdin phải được lọc sau khi thực hiện nhận user input để nó không làm ảnh hưởng tới các đoạn code xử lý tiếp theo trong chương trình nếu muốn tiếp tục nhận user input, đó là lý do tại sao phải có dòng lệnh này:

while ((c = getchar()) != '\n' && c != EOF);

OK, quay trở lại nội dung chính, hiện ta đã có được script phục vụ việc test chương trình. Bây giờ ta sẽ chỉnh sửa script này một chút để có thể phân tích được crash xảy ra tại gets_s() khi chúng ta nhập vào kích thước tối đa, qua đó sẽ phân tích xem có điều gì khác lạ nữa không hay chỉ đơn giản là một crash bình thường. Tôi sửa lại script như sau:

size="16\n"p.stdin.write(size)time.sleep(0.5)user_input="A"*16 + "\n"p.stdin.write(user_input)

Ta sẽ xem điều gì sẽ xảy ra khi thay đổi lại script như trên. Thực hiện lại tương tự như trên, ta trace tới lệnh lệnh lea lấy địa chỉ của biến Buf:

Khi trace qua lệnh LEA, tôi ghi lại địa chỉ của biến Buf, trên máy của tôi lúc này là: 0x0115F9D4.

Tiếp theo, nhấp đúp chuột vào biến CANARY và nhấn phím D nhiều lần cho đến khi chuyển nó thành một dword (dd). Ta ghi lại địa chỉ của biến này, trên máy của tôi là 0x0115F9E4 và giá trị của CANARY lúc này trên máy tôi là 0x816077B5h:

Sau khi lưu lại các thông tin liên quan, nhấn F9 để thực thi:

Đây là một exception do API sinh ra, nhấn OK để chấp nhận.

Tại đây, ta nhấn G và nhập vào địa chỉ của biến BufferCANARY để kiểm tra xem có thay đổi gì với những biến này không. Kết quả ta thấy ta thấy rằng CANARY vẫn giữ nguyên giá trị ban đầu còn Buf được điền đấy các chữ cái ‘A ‘:

Như vậy, với việc nhập giá trị vào bằng với kích thước tối đa sẽ không gây ra tràn, một điều chắc chắn là vùng buffer lúc này đã được điền đầy đủ và không có kí tự null (zero) cuối cùng của chuỗi, vì thế có thể gây ra vấn đề nếu chương trình tiếp tục chạy. Tuy nhiên, trong trường hợp này khi có exception (ngoại lệ) thì exception đó đã không được kiểm soát, vậy nên chương trình bị close. Do đó, ở đây chỉ đơn giản là một crash ( tôi cũng nghĩ rằng nó đặt một số không ở đầu của bộ đệm nhằm để hủy bỏ chuỗi).

Nếu chương trình có xử lý ngoại lệ và tiếp tục, nó sẽ loại bỏ dữ liệu khỏi bộ đệm vì nếu nó lấy dữ liệu đó và sử dụng như là một chuỗi, nhưng lại không có null byte để báo hiệu kết thúc chuỗi, thì có thể sẽ nối thêm vào dữ liệu tiếp theo trong ngăn xếp và gây ra vấn đề. Nhưng bằng cách thiết lập giá trị 0 ở đầu của bộ đệm thì sẽ làm mất hiệu lực của dữ liệu.

Như vậy, ta biết rằng sẽ không xảy ra tràn ở đây, ta quay trở lại với việc phân tích tĩnh. Hãy tập trung vào đoạn code này:

Như đã đọc ở các phần trước, ta biết rằng lệnh nhảy JL hoặc JLE sẽ xem xét vào số có dấu hoặc EAX có thể là số âm. Ví dụ, nếu EAX có giá trị là 0xFFFFFFFF ( tức là ) thì nó sẽ nhỏ hơn 0x10. Như vậy là nếu ta sửa lại script để truyền vào biến Size giá trị -1 thì có thể sẽ vượt qua được đoạn so sánh ở trên. Script được sửa lại như sau:

size="-1\n"p.stdin.write(size)time.sleep(0.5)user_input="A" *0x2000 + "\n"p.stdin.write(user_input)

Chạy lại script với các giá trị đã thay đổi. Ta dừng lại tại breakpoint đã đặt trong IDA:

Lúc này ta kiểm tra giá trị của biến Size xem thay đổi có đúng mong muốn không:

Ta thấy rằng nó chứa giá trị là 0xFFFFFFFF, nhấp đúp chuột vào biến này và nhấn D để nhóm lại cho đến khi trở thành một dword:

Tiếp theo, ta trace code cho tới đoạn thực hiện so sánh với giá trị maximum:

Rõ ràng nếu xem 0xFFFFFFFF-1, thì khi thực hiện câu lệnh so sánh và xem xét dấu, cờ SF lúc này sẽ được bật và như vậy ta sẽ vẫn đi tới đoạn code thực hiện hàm gets_s(). Thực tế thì hàm gets_s cũng giống như là memcpy và bất kỳ hàm API nào thực hiện việc sao chép hoặc cấp phát, kích thước truyền vào cho hàm sẽ luôn được hiểu như làsố không dấu (unsigned), bởi vì đơn giản là không có kích thước âm, điều đó là không thể xảy ra, do vậy chương trình lúc này sẽ hiểu 0xFFFFFFFF là một số dương cực lớn được truyền cho hàm gets_s(), do vậy sẽ có tràn xảy ra.

Ta ghi lại địa chỉ của buffer, lần này trên máy tôi nó được cấp phát tại 0x004CFAF8.

Sau đó ta nhấn F9 để thực thi, chương trình sẽ dừng tại đây trong IDA:

Đi tới địa chỉ của buffer, kết quả có được như sau:

Tại đầu của buffer, ta nhấn phím ‘ A ‘ nhằm chuyển toàn bộ kí tự đơn lẻ này thành chuỗi để dễ nhìn hơn:

Nhìn trông ngon lành hơn nhiều lolz. Tiếp theo ta chuyển nó thành dạng Array:

Ta thấy rằng với số lượng kí tự ghi vào buffer như trên đã chiếm hết toàn bộ Stack, đồng thời ghi đè lên cả biến CANARY cũng như các giá trị khác. Như trong hình, bên dưới Stack là một section khác có tên là debug024 ( trên máy của tôi):

Bằng cách trên, ta đã xác minh được rằng chương trình này có vulnerable. Giờ làm cách nào để sửa được lỗi này? Rõ ràng là, thay vì sử dụng các lệnh nhảy như JL hoặc JLE ( căn cứ vào dấu) thì ta cần sử dụng lệnh nhảy JB hoặc JBE, vì các lệnh này không quan tâm nếu ta truyền vào là-1, nó sẽ là 0xFFFFFFFF, nhưng khi tới lệnh so sánh nó sẽ được xem là số dương và như vậy sẽ lớn hơn 0x10. Khi lớn hơn giá trị maximum của buffer thì sẽ thoát chương trình.

Do vậy, ta sửa lại mã nguồn của chương trình như sau:

Ta thay đổi lại khai báo kiểu của biến size thành “unsigned”, qua đó chương trình từ có Vulnerable sẽ thành Not Vulnerable. Sửa xong code, biên dịch lại chương trình và lưu với tên mới là NO_VULNERABLE.exe. Sau đó load file đã sửa vào IDA, ta thấy lệnh nhảy bây giờ đã thay đổi như hình dưới:

Như các bạn thấy, chỉ cần thay đổi kiểu biến thành “unsigned”, sau khi biên dịch, lệnh nhảy sẽ được đổi sang kiểu lệnh nhảy không còn quan tâm đến dấu của số như ban đầu nữa. Tiếp theo, ta sửa lại script chỉ đơn giản bằng cách đổi tên của file thực thi, lúc này là NO_VULNERABLE.exe, các lệnh khác trong script giữ nguyên không thay đổi.

Thực hiện toàn bộ quá trình đã làm như trên bằng cách cho chạy script và attach process vào IDA, sau đó ta trace code tới câu lệnh so sánh để xem điều gì xảy ra. Ta thấy biến Size lúc này vẫn giữ giá trị là 0xFFFFFFFF do script truyền vào:

Nhấn F8 để trace qua lệnh so sánh và dừng tại lệnh nhảy:

Ta thấy lúc này mũi tên màu đỏ sẽ nhấp nháy báo hiệu chương trình sẽ rẽ nhánh sang khối lệnh gọi hàm exit() để thoát chương trình và như vậy sẽ tránh được tràn, đó là vì lệnh nhảy JBE xem xét rằng giá trị 0xFFFFFFFF ( vì không quan tâm đến dấu) là một số dương rất lớn, do vậy sẽ lớn hơn giá trị 0x10.

Kết luận, câu trả lời cho bài tập này là đây là một chương trình có Vulnerable và nó đã được sửa lại bằng cách thay đổi kiểu của biến size thành “ unsigned int size”, từ đó sau khi biên dịch lại lệnh nhảy JLE được thay bởi lệnh nhảy JBE.

Phần 21 kết thúc tại đây, hẹn gặp lại các bạn ở phần 22!

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

--

--