Posts Tagged ‘ReverseEngineering’


Phần 27 này sẽ giải quyết bài tập (IDA_STRUCT.7z – do chính thầy Ricardo Narvaja biên soạn) mà tôi đã gửi kèm ở phần trước. File nén này gồm một file thực thi là ConsoleApplication4.exe và một file pdb đi kèm là ConsoleApplication4.pdb (mục đích để các bạn kiểm tra lại sau khi đã tự reverse code).

Thông thường, khi load chương trình vào IDA, IDA sẽ kiểm tra và phát hiện ra file mà chúng ta đang phân tích có link tới symbol file và hỏi xem có muốn load file này không? Ví dụ như hình dưới đây:

Tuy nhiên, trong các tính huống thực tế thì ta sẽ không có file pdb này. Do vậy, để đúng với thực tế tôi sẽ chọn No để không load kèm file. IDA sau khi phân tích xong sẽ dừng lại tại đây:

Rõ ràng là khi không có symbol file, IDA sẽ không phát hiện ra được vị trí chính xác của hàm main(). Nhưng vì đây là một chương trình dạng console, ta có thể tìm được hàm main() thông qua việc tìm kiếm các tham số argv hoặc argc hoặc là tìm kiếm theo các strings của chương trình.

Khi chạy chương trình, tương tự như ở phần trước, bạn sẽ nhận được thông báo sau “Please Enter Your Number of Choice:”. Dựa vào thông tin này, ta tìm được chuỗi tại tab Strings của IDA:

Nhấp đúp vào chuỗi này, IDA sẽ đưa ta đến đây:

Tại địa chỉ 0x402100, ta thấy chuỗi có một tham chiếu tới (XREF), bằng cách di chuyển chuột tới địa chỉ tham chiếu, hoặc nhấn ‘X’, ta sẽ có được các kết quả như sau:

Chuyển tới đoạn code có sử dụng tới chuỗi “Please Enter Your Number of Choice:”:

Quan sát trên hình, có thể thấy ta đang ở trong một hàm, bên dưới chuỗi là một lời gọi để in nó ra màn hình, nhưng vì chúng ta không load pdb file nên IDA không nhận biết được sub_401220() chính là hàm printf(). Nếu ta quan sát code tại sub này sẽ thấy nó lại gọi tới các hàm khác nữa:

Chuyển sang xem ở chế độ proximity:

Như trên hình, sub_401220() gọi tới 3 hàm là sub_401000(); sub_4010F0() và __acrt_iob_func(), nhưng cả hai sub_401000() và __acrt_iob_func() là các hàm thực hiện nhiệm vụ nào đó và trả về kết quả, các hàm này không gọi tới các hàm con nào khác. Chỉ riêng sub_4010F0() là gọi tới hai hàm khác và một trong số đó là __stdio_common_vfprintf(). Như vậy, thay vì phải đi phân tích lần lượt từng hàm thì với sự trợ giúp của chế độ proximity, ta có thể kết luận sub_401220() chính là hàm printf(). Vì vậy, tôi đổi tên cho sub_401220() thành printf():

Tiếp tục phân tích code, ta sẽ gặp lời gọi tới sub_401260(), mà nếu căn cứ theo ví dụ trước ta có thể đoán nó chính là hàm scanf():

Tiếp tục sử dụng chế độ proximity view, có được kết quả như hình:

Do vậy, ta đổi tên sub_401260() thành scanf():

Quan sát code của sub_00401080(), ta nhận thấy dấu hiệu có thể là một struct. Bời vì khi một địa chỉ được truyền vào như một tham số của hàm, sau đó nó được lấy ra và cộng thêm một giá trị offset để truy cập tới các trường tại những nơi mà nó được sử dụng thì khả năng cao đó là địa chỉ của một struct.

Tìm kiếm các tham chiếu gọi tới sub_00401080():

Kết quả có hai chỗ gọi tới:

Ta thấy rằng, tham số của hàm trong cả hai trường hợp là một địa chỉ nào đó, điều này đưa lại ý tưởng liên quan tới các struct. Tuy nhiên, nhận thấy đây là hai địa chỉ khác nhau, vậy khả năng chúng có thể là hai cấu trúc cùng kiểu. Ta sẽ bắt đầu tạo struct đầu tiên, lúc này không hề có thông tin gì về size của struct, không biết về các trường hay bất cứ thứ gì thuộc về struct. Ta sẽ thực hiện reverse từ từ, từng chút một.

Căn cứ vào code tại sub_00401080(), tạm thời có thông tin offset tối đa là 0x14. Do vậy, đầu tiên tôi sẽ tạo ra một struct có độ dài như vậy, nếu như trong quá trình phân tích nó lớn hơn thì ta sẽ mở rộng thêm sau. Chuyển tới tab Structures của IDA:

IDA hỗ trợ nhiều cách khác nhau để tạo một struct: có thể sử dụng tab Local Types (Shift+F1) để tạo struct dưới dạng mã C, hoặc là phân tích stack và sử dụng Create struct from selection để tạo struct, tương tự như phần trước đã làm. Ở phần này tôi sẽ tạo struct từ tab Structures, và để tạo struct ta nhấn phím Insert:

Bạn có thể đặt tên bất kỳ mà bạn muốn, ở đây tôi đặt là MyStruct:

Rõ ràng ta chưa định nghĩa trường nào trong struct nên nó được tạo ra với kích thước bằng 0x0. Do chưa có thông tin về các trường mà mới chỉ biết được offset lớn nhất là 0x14, nên đầu tiên tôi nhấn phím D để tạo một trường bất kỳ đã:

Như các bạn thấy trên hình, ta có được 1 trường thuộc struct có kích thước là 1 byte (db). Nếu tôi nhấn D một lần nữa tại trường này tôi sẽ thay đổi size của nó thành word (dw) và tiếp nữa sẽ thành dword (dd). Nhưng do không biết chính xác nên tạm thời cứ để nguyên và nhấn chuột phải vào struct, chọn Expand struct type… (Ctrl+E).

Như đã nói, ta đã biết có một trường nằm ở offset 0x14 và trường đó kích thước là 1 dword (dựa vào lệnh gán giá trị thanh ghi edx vào trường này):

Vì vậy, để tạo một trường có size là 1 dword, lại nằm ở offset 0x14, tôi sẽ thêm vào 0x17 bytes. Tổng cộng struct lúc này sẽ có 0x18 bytes (0x17 bytes thêm vào + 1 byte ban đầu):

OK! Với cách tạo như trên, tạm thời chúng ta có được struct là MyStruct, có kích thước là 0x18, nếu cần thiết ta có thể mở rộng tiếp.

Theo phân tích ở đầu bài viết, sub_00401080() sẽ được gọi hai lần. Lần thứ nhất được truyền tham số là một địa chỉ của một cấu trúc kiểu MyStruct, ta có thể đặt tên là struct1 và lần thứ hai cũng là một tham số là địa chỉ của cấu trúc thứ hai cũng cùng kiểu MyStruct, ta đặt tên là struct2. Mã nguồn gốc như hình dưới, để phân biệt giữa các biến có cùng kiểu MyStruct thì một biến được đặt tên là struct1 còn biến kia là struct2, các biến này được truyền địa chỉ của chúng như là tham số cho các hàm.

Quay lại lời gọi tới sub_00401080() đầu tiên tại địa chỉ 0x00401196, như đã phân tích cũng như nhìn vào mã nguồn gốc thì hàm này sẽ nhận vào địa chỉ của cấu trúc đầu tiên (struct1) và lưu vào arg_0 (do IDA không nhận biết được nên nó đặt tên cho tham số lưu địa chỉ này là như thế) và lần gọi thứ hai tại địa chỉ 0x004011BA thì sub_00401080() sẽ nhận địa chỉ của cấu trúc thứ hai (struct2). Do vậy, ở đây tôi đổi lại tên tham số arg_0 thành một tên chung cho cả hai struct, ví dụ _struct.

Khi nhấn F5 để decompile hàm này, ta sẽ thấy rằng nó vẫn chưa đúng như ý muốn:

Dựa theo kết quả decompile thì thấy tham số _struct được nhận biết và định nghĩa đơn giản là biến kiểu int. Nó không giống như đã thấy ở mã nguồn gốc khi tham số này là địa chỉ của một cấu trúc. Để điều chỉnh lại, ta nhấn chuột phải tại tham số này và chọn như hình:

Cửa sổ Select a structure xuất hiện, lựa chọn struct vừa tạo là MyStruct:

Kết quả prototype của hàm sẽ được chuyển thành:

Tại main() lúc này biến var_24 tự động được đổi tên thành struct, và rõ ràng struct này chính là struct1. Quan sát struct tại cửa sổ biểu diễn Stack của main(), để thông báo cho IDA biết struct có kiểu là MyStruct, ta nhấn ALT + Q (Edit > Struct var…) tại struct.

Làm như vậy sẽ gán struct là kiểu MyStruct, kết quả có được như hình dưới:

Ta đổi tên struct thành struct1. Ở lần gọi sub_00401080() thứ hai, địa chỉ của var_44 được truyền cho hàm, biến này cũng sẽ là kiểu MyStruct, làm tương tự như trên để chỉ định nó là biến kiểu MyStruct đồng thời đổi tên luôn thành struct2:

Tới thời điểm này ta đã có hai cấu trúc là struct1 và struct2 có kiểu MyStruct. Trở lại code của hàm:

Ta thấy đầu tiên code của hàm lấy ra địa chỉ base của struct và cộng với offset 0x10 để truy cập trường tại vị trí 0x10. Trường này có kích thước là 1 dword, và nó sẽ nhận giá trị của hàm scanf(). Do đó, đi đến MyStruct và tại offset 0x10 chúng ta nhấn D cho đến khi nó có kiểu dword (dd). Kết quả sẽ có được như sau:

Dựa trên thông báo yêu cầu nhập vào một số, tôi đổi tên cho trường này thành number. Đoạn code tiếp theo truy cập tới trường 0x14, được sử dụng trong vòng lặp để loại bỏ 0A. Tôi đổi tên trường này thành c.

Tại offset 0x14 của MyStruct, nhấn D cho đến khi chuyển thành một DWORD, ta đổi tên trường vừa tạo thành c.

Quay trở lại code và lựa chọn struct offset, kết quả ta sẽ được như sau:

Sau khi thực hiện xong ta đổi tên hàm sub_401080() thành enter().

Tiếp tục phân tích tiếp sub_401010() (lời gọi tới hàm này tại địa chỉ 0x4011A2). Tại đây, cũng đổi tên arg_0 thành struct:

Hàm này cũng được gọi hai lần và cũng được truyền vào địa chỉ của cả hai cấu trúc. Do đó, áp dụng cách tương tự như trên, nhấn F5 để decompile hàm này:

Sau đó, chọn biến struct và nhấn chuột phải chọn Convert to struct *:

Sau khi convert xong, đối với các trường đã biết, ta sẽ lựa chọn offset cho nó:

Ở đây, ta lại thấy lại đoạn code so sánh giống như ở phần 26. Giá trị số mà ta nhập vào được lưu tại trường number, và bởi vì so sánh ở đây là số có dấu nên trường number này có thể chứa giá trị 0xffffffff (-1), nhỏ hơn 0x10 và do đó sẽ vượt qua được đoạn kiểm tra. Hàm gets_s lúc này sẽ sử dụng giá trị của trường number như là tham số thứ hai đại diện cho kích thước của buffer. Như vậy, tham số thứ nhất phải là buffer và buffer này nằm tại đầu của struct. Ta chuyển tới định nghĩa của MyStruct, tại offset 0x0, nhấn phím D một lần để tạo một trường single byte.

Sau đó nhấn chuột phải tại trường này và chọn Array. Thiết lập kích thước cho array này:

Và đổi tên trường thành Buffer:

Code được decompile lúc này sẽ rõ ràng hơn nhiều:

Tương tự như đã phân tích ở phần 26, hàm gets_s() chắc chắn sẽ bị overflow vì đơn giản ta có thể truyền vào một số âm 0xffffffff (-1) để bypass đoạn code so sánh và được sử dụng như là size của hàm. Nhưng khi được sử dụng tại hàm gets_s() thì size lúc này sẽ lại trở thành số dương lớn nhất. Ta sẽ đổi tên hàm sub_401010() thành check().

Đi tới hàm tiếp theo trong main() là sub_401060(). Thực hiện đổi tên của tham số, nhấn F5 để decompile và chuyển đổi tham số này thành kiểu struct:

Quay trở lại mã asm của hàm, sẽ thấy struct của chúng ta phải có thêm một trường nữa vì code của chương trình đang cố gắng so sánh [EAX + 0x18] với một giá trị mặc định. Do ban đầu tại cấu trúc MyStruct ta mới chỉ định nghĩa trường cuối cùng tại offset 0x14, để bổ sung thêm trường tại offset 0x18 thì ta phải mở rộng struct này ra.

Chuyển tới chỗ định nghĩa MyStruct, chuyển xuống cuối của struct này tại từ khóa “ends” và nhấn D cho đến khi một trường DD DWORD mới được tạo ra có tên là field_18:

Đổi tên trường này thành cookie:

Chuyển về code và lựa chọn offset của trường vừa tạo:

Đi xuống bên dưới, ta thấy code của hàm lại truy cập một trường khác nữa tại offset 0x1C, và trường này có kích thước là 1 byte:

Tiếp tục mở rộng MyStruct và tạo một trường có kích thước 1 byte tại offset 0x1C, đồng thời đổi tên luôn thành flag:

Quay lại code và lựa chọn offset, kết quả cuối cùng như sau:

Nhìn vào đoạn code trên, nếu giá trị tại trường cookie bằng 0x99989796 thì sẽ gán cho trường flag bằng 1. Do vậy, đổi tên sub_401060() thành decision():

Sau toàn bộ các thay đổi như trên, quay trở lại hàm main(), có vẻ các thay đổi này không có tác dụng tới toàn chương trình nên các biến tại hàm main() sẽ bị hỏng, tương tự như hình dưới đây:

Để có thể sửa lỗi này, chuyển lên đầu hàm main() và nhấn U (Undefine):

Nhấn Yes để đồng ý:

Sau đó, vẫn tại cùng vị chí đã chọn ở trên, nhấn C để IDA build lại code của hàm:

Cuối cùng, nhấn chuột phải và chọn Create Function. Kết quả có được sẽ như hình dưới đây, lỗi đã được fix hoàn toàn:

Quay lại cửa số biểu diễn Stack của hàm main(), nhận thấy rằng sau mỗi cấu trúc sẽ có thêm 3 bytes trống vì trường cuối cùng chỉ chiếm 1 byte, lúc này các biến không còn giữ tên mà ta thay đổi nữa mà đã bị đổi thành:

Đổi lại thành struct1 và struct2 như đã đặt ban đầu.

Trong hình trên, còn một hàm nữa vẫn chưa phân tích là sub_401040(). Hàm này nhận tham số truyền vào là địa chỉ của struct2. Đi vào hàm này sẽ thấy code của nó tương tự như hàm decision() đã phân tích ở trên, chỉ khác là lúc này [eax+0x18] sẽ được so sánh với một giá trị mặc định khác là 0x33343536h. Sử dụng phím tắt ‘T’ để lựa chọn các trường của struct, có được kết quả như sau:

Đổi tên sub_401040() thành decision2(). Tổng kết lại, trường cookie thuộc struct1 phải có giá trị 0x99989796, còn trường cookie của struct2 phải là 0x33343536, từ đó cả hai trường flag của mỗi cấu trúc sẽ được thiết lập bằng 1. Các trường flag này sau đó sẽ được đem so sánh với 1, nếu bằng sẽ hiển thị thông báo “Genious you are the man\n“, còn không sẽ hiển thị “Not not and not\n“:

Như vậy, để tới được thông báo “Genious…” thì bắt buộc cả hai trường flag phải được gán bằng 1.

Việc thực hiện overflow cho bài này sẽ có nhiều cách khác nhau, tùy giải pháp của từng người. Với tôi, tôi sẽ tìm cách ghi đè giá trị 1 vào trường flag, làm thế sẽ khiến flag luôn có giá trị 1 ngay cả khi các giá trị cookie không bằng với các giá trị mặc định. Để làm được như vậy, tôi sẽ tính toán xem cần phải vượt qua bao nhiêu bytes để ghi đè lên trường flag. Phân tích cấu trúc MyStruct:

Với thông tin có được trên hình, để ghi đè vào flag, tôi cần ghi dữ liệu vào 16 bytes của Buffer, cộng thêm 3 dwords nữa (tức là 12 bytes), tương ứng với các trường number, c và cookie. Tổng cộng sẽ là: 16 + 12 = 28 bytes. Như vậy user_input= 28* “A” + “\x01”. Script thực hiện sẽ như sau:

from subprocess import *
import struct
p = Popen([r'ConsoleApplication4.exe', 'f'], stdout = PIPE, stdin = PIPE, stderr = STDOUT)

print "Attach to the debugger and press Enter\n"
raw_input()

num = "-1\n"
p.stdin.write(num)

user_input = "A" * 28 + "\x01" + "\n"
p.stdin.write(user_input)

num = "-1\n"
p.stdin.write(num)

user_input = "A" * 28 + "\x01" + "\n"
p.stdin.write(user_input)

testresult = p.communicate()[0]

print user_input
print(testresult)

Như vậy, với mỗi cấu trúc ta truyền vào số -1 cho hàm scanf() để vượt qua đoạn kiểm tra 0x10 và biến user_input để truyền cho hàm gets_s(). Kết quả khi thực hiện script:

Khi chúng ta thực hiện debug để kiểm tra, sẽ thấy giá trị -1(0FFFFFFFFh) được lưu tại trường number của struct1.

Với giá trị này sẽ vượt qua đoạn kiểm tra và đi tới hàm gets_s để nhận user_input.

Kiểm tra thông tin của trường Buffer thuộc struct1, có kết quả như sau:

Như vậy ta thấy hàm gets_s() đã nhận user_input mà chúng ta truyền vào. Tại đây, ta chuyển nó thành structure bằng cách nhấn ALT+Q và chọn MyStruct:

Kết quả có được như hình dưới, ta sẽ thấy trường flag lúc này chứa giá trị là 1, còn trường cookie đang có giá trị là 0x41414141:

Tiếp tục trace code ta tới đây:

Chương trình thực hiện so sánh cookie với 0x99989796, bằng nhau thì sẽ gán flag bằng 1. Nhưng vì ta đã có flag bằng 1 rồi nên không cần quan tâm đến kết quả của việc so sánh nữa. Cũng tương tự khi trace vào trong code của hàm desicion2().

Như vậy, các trường flag của cả hai cấu trúc lúc này đều đã được gán bằng 1. Các trường này sẽ được đem đi so sánh với 1:

Sau khi vượt qua hai đoạn so sánh, cuối cùng cũng tới được đoạn code hiển thị thông báo “Genious you are the man\n”.

Như vậy, tôi đã hoàn thành xong bài tập này. Phần 27 đến đây là kết thúc, hẹn gặp lại các bạn trong phần 28!

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


Trong phần 26 này, chúng ta tiếp tục tìm hiểu thêm về struct. Hãy xem ví dụ bên dưới đây, đó là toàn bộ code của hàm main():

Trong ví dụ trên, tôi có một cấu trúc là MyStruct. Cấu trúc này được khai báo global (toàn cục) thay vì được khai báo local (cục bộ) bên trong thân hàm main(). Việc khai báo như vậy có thể cho phép khả năng xử lý tốt hơn giữa các hàm khi truyền vào biến có kiểu struct. Ta khai báo biến values có kiểu MyStruct. Đây là biến local và được khai báo trong hàm main(), như vậy biến này chỉ xuất hiện trong Stack của hàm main() mà thôi.

Cấu trúc MyStruct được định nghĩa bên ngoài hàm main() cùng với các hàm khác như trong hình dưới đây:

Nhìn vào code tại main(), bạn sẽ hiểu ý tưởng của chương trình là truyền một con trỏ tới cấu trúc values, từ đó có thể đọc hoặc thay đổi giá trị các trường thuộc cấu trúc này thông qua ba hàm:

Sau khi biên dịch và load file vào trong IDA, tuy nhiên ta sẽ không load kèm theo file .pdb được sinh ra trong quá trình compile, lúc đó IDA sẽ không có thông tin căn cứ để nhận biết rằng biến values có kiểu cấu trúc:

Tại hàm main(), lúc này ta chỉ thấy chương trình thực hiện lấy địa chỉ của biến var_20, sau đó truyền địa chỉ của biến này cho các hàm sub_401090, sub_401010, sub_401050. Như vậy, ta mới chỉ biết được các hàm này nhận chung một tham số truyền vào. Khi truyền địa chỉ của biến vào cho một hàm như trên thì như vậy tham số của hàm này phải có kiểu pointer (ví dụ: int  *ip; ip = &var;). Do đó, ta đi tới các hàm này và tạm thời thay đổi prototype của chúng tương tự như sau:

Kết quả tạm thời có được như hình dưới:

Nhấp đúp chuột vào biến var_20 sẽ chuyển tới cửa sổ biểu diễn Stack của hàm main(),  quan sát bên dưới biến này ta thấy có nhiều từ khóa db, nên tạm đoán đây có thể là một buffer. Thực hiện chuyển đổi nó sang kiểu array, độ dài của mảng sẽ do IDA tự động nhận biết và đưa ra gợi ý:

Kết quả, ta sẽ có được một mảng có kích thước là 16 phần tử. Như tôi đã nói, nếu chúng ta không biết được mã nguồn gốc của chương trình, thì tới đây ta chỉ có thể suy đoán đó một buffer và không hề có ý tưởng hay suy nghĩ rằng nó là biến có kiểu struct. Nhấn OK để chấp nhận những gì IDA đã gợi ý, đồng thời đổi luôn tên biến thành buf:

Quay trở lại hàm main(),  ta thấy có các biến cục bộ khác được khởi gán giá trị ban đầu:

Tất cả các biến đều được khởi tạo bằng 0 và không được sử dụng lại ở đâu khác trong code của chương trình. Đối với một biến cục bộ điều này tạo ra điểm bất thường vì ít khi nào biến được tạo ra, được khởi tạo giá trị nhưng lại không được sử dụng. Bên cạnh đó ta thấy có biến var_4, như một thói quen ta đổi tên nó thành Canary:

Tiếp tục kiểm tra lại toàn bộ code của hàm main(), ngoài các biến đã phân tích ở trên thì không còn biến nào khác. Tuy nhiên, chúng ta không thể đổi tên ba biến cục bộ đã được khởi gán bằng 0, bởi các biến này không được sử dụng trong hàm. Do đó, ta không biết ý nghĩa cũng như mục đích của chúng nên rất khó để đặt các tên sao cho có nghĩa. Tất nhiên, nếu không biết thì có thể đặt là temp, temp1, temp2 v..v…

Tạm thời bỏ qua các biến này, ta đi vào phân tích hàm đầu tiên sub_401090:

Ta thấy, chương trình lấy địa chỉ của biến buf và truyền địa chỉ của biến này như là một tham số cho hàm sub_401090. Đi vào hàm này, ta thấy nó yêu cầu nhập vào một số bất kì: “\nPlease Enter Number of Choice: \n”. Đọc qua code thì tạm thời đoạn được hàm này nhận giá trị do người dùng nhập vào và lưu giá trị vào buffer, do đó đổi tên hàm này thành enter(), tham số arg_0 của hàm này như phân tích chính là một pointer, nó chứa địa chỉ của biến buf, nên ta đổi tên nó thành pBuf:

Quay trở lại hàm main() ta sẽ thấy được sự thay đổi nhỏ như sau:

Kết quả này chỉ là để khẳng định lại chính xác rằng thanh EAX chứa địa chỉ của biến buf (thông qua lệnh LEA). Quay lại với hàm enter():

Trong đoạn code trên, một lần nữa ta lại thấy một điểm bất thường khác trong code của chương trình. Như ở trên, ta đã phán đoạn biến buf có chiều dài là 16 bytes hay 0x10, ở đây địa chỉ biến buf được gán vào eax và đem cộng với 0x10, sau đó giá trị tại eax sẽ được truyền làm tham số cho hàm scanf để lưu giá trị mà người dùng nhập vào từ bàn phím. Như vậy, ta thấy dường như giá trị mà người dùng nhập vào sẽ không được lưu vào buf mà lưu vào bên dưới của buf (buf + 0x10).

Điều này đã cho tôi suy nghĩ, nếu bạn truyền một con trỏ tới buffer, sau đó lại ghi dữ liệu vào, đó thường là dấu hiện điển hình của một struct. Ta truyền địa chỉ ban đầu (base_address) cho một hàm và từ đó có thể truy cập bất kỳ trường nào, trong trường hợp này là một trường nào đó bên dưới trường đầu tiên của buf.

Kiểm tra tại Stack của main() sẽ thấy có biến var_10 là biến nằm ngay dưới buf, tương ứng với việc lấy buf làm địa chỉ base và cộng thêm 0x10:

Như vậy, ta hiểu rằng hàm enter() sẽ ghi giá trị vào biến var_10. Do đó, chỉ có thể xảy ra khả năng duy nhất là buf và var_10 đều là các trường của cùng một struct.

Nhiều người sẽ thắc mắc tại sao tôi phải quan sát và phân tích tại Stack của hàm main() thay vì kiểm tra tại hàm? Vấn đề ở đây là khi ta truyền địa chỉ Buffer, mà đó là địa chỉ bắt đầu và của cùng Buffer trong hàm main(), và nếu tôi cộng thêm 0x10 vào địa chỉ này, tôi sẽ truy cập tới biến var_10 của hàm main(). Do var_10 là một biến cục bộ của main() nên sẽ không có ý nghĩa bên trong hàm enter(), nhưng  nó sẽ có ý nghĩa nếu biến thuộc về một trường của một cấu trúc.

OK, như vậy bạn đã hiểu rồi chứ … tại hàm enter() chúng ta thấy rằng:

Chương trình yêu cầu ta nhập vào một số và lưu nó vào một trường trong struct được gọi là var_10, thông qua hàm scanf_s. Do vậy, tôi sẽ đổi tên biến var_10 thành number, nhưng vì sau khi phân tích tôi biết rằng có một struct cùng với ba trường chắc chắn được truy cập bởi các hàm, tôi sẽ tạo ra struct bao gồm các trường này. Lựa chọn các biến trừ biến Canary:

Sau đó truy cập menu Edit > Create structure from selection, kết quả như sau:

Struct được tạo ra có tên là buf, tôi sẽ đổi tên nó thành values. Tiếp theo tôi đổi tên struct_0 thành MyStruct tại tab Structures:

Tiếp theo, tôi đổi tên biến var_10 thành number như đã phân tích:

Sau toàn bộ quá trình thay đổi như trên, ta xem lại code tại hàm main lúc này sẽ thấy rõ ràng hơn nhiều:

Lại tiếp tục với hàm enter(), tôi đổi tên thành lại pvalues hàm ý là trỏ tới values và nhấn ‘Y’ để đổi lại định nghĩa mới cho hàm:

Tiếp tục reverse các đoạn code tiếp theo trong hàm enter():

Trong đoạn code trên, ta thấy nó có sử dụng một trường của struct vì nó sử dụng lệnh mov để gán địa chỉ base của struct vào eax và cộng thêm 0x14 để lưu giá trị đang có tại thanh ghi ecx. Lúc này ta không cần phải đoán nữa, tại lệnh được khoanh như trên hình tôi nhấn phím ‘T’ (Structure offset):

Chọn trường tương ứng như trên hình, kết quả có được như sau:

Như vậy, bạn thấy rằng thông qua việc nhận biết đó là một trường thuộc struct, nhưng vì ở đây IDA không biết trường này thuộc về cấu trúc nào nên nó sẽ đưa ra rất nhiều gợi ý, vì vậy ta sẽ phải lựa chọn đúng nó là offset của struct mà mình biết. Đoạn code trên là vòng lặp được sử dụng để lọc kí tự 0xA sau lệnh scanf, kí tự nhận được sau hàm getchar() sẽ được lưu vào biến var_44 và trường thuộc MyStruct, ta đổi tên lại như sau:

Tiếp tục các bạn sẽ thấy cơ chế tương tự để truy cập các trường thuộc struct: Lấy địa chỉ base của struct, sau đó cộng với một offset để truy cập tới trường mong muốn:

Tiếp tục nhấn ‘T’ để lựa chọn đúng offset:

Ta đi đến những dòng code cuối cùng của hàm enter(), nó không thực hiện thêm công việc gì khác cũng như không trả về giá trị cho thanh ghi eax. Hàm này tóm lại chỉ thực hiện nhiệm vụ là nhận số mà ta nhập vào và lưu nó vào trường number trong MyStruct.

Ta phân tích hàm tiếp theo là sub_401010(). Tại hàm này ta cũng thực hiện đổi tên tham số arg_0 thành pvalues giống như đã làm với hàm enter() đồng thời thay đổi luôn cả prototype của hàm. Kết quả có được như hình dưới đây:

Trong đoạn code trên, ta thấy nó truy cập tới một trường trong struct và so sánh giá trị của trường đó với 0x10, nhấn T tại lệnh đó và chọn đúng offset:

Như vậy, hàm sẽ so sánh số ta nhập vào với 0x10. Qua đó, ta thấy rằng khi chúng ta quản lý chúng như một cấu trúc, các trường trong cấu trúc có thể nhận giá trị qua một hàm, được kiểm tra giá trị bằng một hàm khác và sau đó có thể được xử lý ở một hàm thứ ba nào đó. Rõ ràng, nếu chúng ta đi theo con trỏ tới cấu trúc, chúng ta luôn có thể xác định được trường nào trong struct mà không cần phải debug, còn nếu ta xem nó như một biến thì sẽ rất phức tạp để xác định rằng nó luôn có giá trị như nhau.

Tiếp tục reverse code của hàm sub_401010(). Tiếp tục lựa chọn struct cho offset:

Vậy là trường number được sử dụng như là kích thước của buffer khi gọi hàm gets_s, và trường number này có thể chứa giá trị 0xffffffff vì lệnh kiểm tra trước sẽ kiểm tra số có dấu (căn cứ lệnh nhảy jle), như vậy giá trị 0xffffffff sẽ là -1, nhỏ hơn 0x10 và do đó sẽ vượt qua được đoạn kiểm tra ở trước. Hàm gets_s lúc này được truyền địa chỉ của pvalues, mà trường đầu tiên sẽ là buf, do đó hàm gets_s sẽ ghi dữ liệu vào buffer này. Nếu với trường number được gán giá trị 0xffffffff thì sẽ có overflow tại buffer. Ta đổi tên hàm sub_401010() thành check():

Ta phân tích hàm cuối cùng tại sub_401050(). Đổi tên biến arg_0 thành pvalues và thay đổi lại prototype của hàm:

Trong code có truy xuất tới một trường của struct, lựa chọn offset cho nó và đổi lại tên của trường từ var_8 thành cookie:

Ở đây nếu trường cookie có giá trị bằng giá trị mặc định là 0x45934215 thì code của hàm sẽ rẽ tới nhánh để in ra màn hình thông báo “You are a winner man”. Do vậy, ta đổi sub_401050 tên thành decision.

Với các thông tin sau khi phân tích, chúng ta sẽ có ý tưởng để có thể tới được đoạn code in ra thông báo “You are a winner man”. Tại màn hình bố trí Stack của hàm main(), ta đã biết được các trường buffer và cookie đều thuộc struct là MyStruct. Chuyển tới tab Structures để tính toán kích thước của từng trường:

Với thông tin trên, ta sẽ phải ghi vào buf 16 kí tự (ví dụ 16 chữ ‘A’), sau đó ghi đè lên hai dwords là number và c, cuối cùng là cookie:

user_input = “A” * 16 + number + c + cookie

Ta có script như sau:

from subprocess import *
import struct
p = Popen([r'Struct.exe', 'f'], stdout = PIPE, stdin = PIPE, stderr = STDOUT)

print "Attach to the debugger and press Enter\n"
raw_input()

num = "-1\n"
p.stdin.write(num)

number = struct.pack("<L", 0x1c)
c = struct.pack("<L", 0x90909090)
cookie = struct.pack("<L", 0x45934215)

user_input = "A" * 16 + number + c + cookie + "\n"
p.stdin.write(user_input)

testresult = p.communicate()[0]

print user_input
print(testresult)

Trong script trên, khi chương trình yêu cầu nhập 1 số, ta truyền cho nó số -1, như vậy sẽ vượt qua đoạn kiểm tra khi nó so sánh một số có dấu (số âm) với 0x10.

Khi đó hàm gets_s sẽ nhận được user_input truyền vào ở trên, qua đó sẽ ghi đè lên 16-byte của buf với 16 chữ ‘A’, trường number sẽ nhận giá trị 0x1c, trường c có thể nhận bất kỳ giá trị bất kì (ví dụ: 0x90909090) và sau đó là cookie với giá trị 0x45934215.

Kết quả khi chạy script:

Vâng với kết quả này, chúng ta đã kết thúc toàn bộ phần 26 ở đây. Tôi gửi kèm một bài tập gọi là IDA_STRUCT.7z (https://mega.nz/#!qf5RECST!ifBTTbawra5hGCrh_mmJ9lutmafg9a31vuRy7lUQ2xw) để các bạn phân tích xem liệu nó có vulnerable hay không và nếu có thì khai thác thế nào.

Hẹn gặp lại các bạn ở phần 27!

Image result for are u understand gif

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


Mặt trời là cái bếp lớn, còn tia nắng là than hồng
Mỗi ngày mà ta thức dậy, ta chỉ mong được an lòng
Hoàng hôn là dải lụa, còn màn đêm là tấm chăn
Mỗi đêm ta ngồi ta viết ta chỉ mong không bị cấm ngăn
_Đen_

STRUCTURES

Trong phần tiếp theo này, chúng ta sẽ bắt đầu tìm hiểu cách mà IDA Pro hỗ trợ để reverse các chương trình có sử dụng struct. Phần cuối bài viết sẽ là những giải đáp ngắn gọn về các bài tập IDA3.exeIDA4.exe đã gửi kèm ở phần trước.

Vậy structure là gì?

Nôm na như sau, các bạn đã biết về khái niệm một mảng (array) trong lập trình C, mảng là một tập hoặc một nhóm các phần tử (dữ liệu) có kiểu dữ liệu đồng nhất. Các phần tử của mảng được lưu tại các vùng nhớ liên tiếp. Tương tự như vậy, cấu trúc (structure) trong C cũng là một kiểu dữ liệu do người dùng định nghĩa, nhưng khác mảng, structure cho phép kết hợp các dữ liệu có kiểu khác nhau. Chi tiết hơn các bạn đọc thêm trong các tài liệu về lập trình C/C++.

Về cơ bản khi phân tích trong IDA, chúng ta thấy rằng arrays như kiểu một “thùng chứa” dữ liệu, được dành riêng một không gian liên tục trong bộ nhớ để chứa các trường có cùng kiểu dữ liệu. Vì vậy, ta có thể có mảng các bytes, words, dwords. Ý chính cần nhấn mạnh ở đây là mảng chỉ chứa các phần tử có cùng kiểu dữ liệu mà thôi. Ví dụ minh họa như sau:

Trong hình trên là ví dụ về một array có kích thước bằng 34, và mỗi phần tử có kích thước 2 bytes, tức là mỗi phần tử chiếm một word. Do đó, tổng chiều dài của mảng này sẽ là 34 * 2 hay 68 (ở hệ thập phân). Như vậy, trong ví dụ minh họa về mảng này, mỗi phần tử có kích thước 1 word, chiếm 2 bytes, nếu tôi muốn một mảng có các phần tử chỉ chiếm 1 byte thì tôi sẽ phải tạo ra một mảng khác, vì ta không thể trộn lẫn dữ liệu với kích thước hoặc kiểu khác nhau.

Để giải quyết hạn chế của mảng, cấu trúc ra đời cho phép kết hợp các loại dữ liệu khác nhau có kích thước khác nhau bên trong nó. Tóm lại, ta có thể xem structure cũng là một loại “thùng chứa” có thể chứa các loại dữ liệu khác nhau. Mỗi phần tử được lưu trữ bên trong cấu trúc được gọi là thành viên/trường (member) của cấu trúc này và nó sẽ thuộc về một loại dữ liệu nhất định. Như vậy, kích thước của structure sẽ là tổng kích thước của tất cả các thành viên hoặc các trường thuộc structure đó.

Ví dụ tôi có khai báo một structure đơn giản như sau:

struct MyStruct
{
	char * p_size;
	char size;
	int cookie;
	int leidos;
	int cookie2;
	int maxsize;
	int(*foo2)(char *);
	void * buf2;
	char buf[50];
};

Trong ví dụ trên, bạn thấy định nghĩa cho một cấu trúc có tên là MyStruct. Bên trong struct này có nhiều trường (biến) với các kiểu dữ liệu khác nhau (kiểu int; kiểu char hay một buffer có kích thước 50 bytes).  Nếu sử dụng Visual Studio, bạn có thể thấy được kích thước của toàn bộ struct này là 0x54.

Nếu các bạn cộng nhẩm bằng tay thì sẽ nhận thấy rằng kết quả sẽ nhỏ hơn một chút so với những gì mà VS hiển thị sau khi biên dịch. Đó là bởi vì trong quá trình compile code, trình compiler có thể thêm vào các padding bytes để căn chỉnh sao cho phù hợp. Do vậy, thông thường thì kích thước thực tế sẽ lớn hơn. Tuy nhiên, điều quan trọng hơn cả việc tính toán kích thước đó là để nhận ra được đâu là struct là rất khó khi đã được phân rã ở mã asm. IDA chỉ có thể nhận diện từng trường và kiểu dữ liệu một cách tự động, đặc biệt nếu chúng ta không có file pdb đi kèm thì rất khó để nhận diện.

Ta thử làm một ví dụ đơn giản về struct, sau đó sử dụng IDA để nhận diện cũng như quản lý struct trong IDA. Code của ví dụ như hình dưới đây:

Như các bạn thấy, code của chương trình khá đơn giản, nó sẽ nhận tham số truyền vào thông qua màn hình console khi chạy, nếu ta không truyền tham số, chương trình sẽ hiển thị thông báo “Bye Bye“.

Trường hợp có truyền tham số cho chương trình, kết quả khi thực thi sẽ như sau:

Nếu bạn chỉ nhấp đúp vào chương trình hoặc không truyền tham số cho nó, code trong chương trình sẽ kiểm tra thấy argc (số tham số truyền vào) khác 2 và sẽ thoát luôn:

if (argc != 2) 
{
        printf("Bye Bye");
        exit(1);
}

Khi ta có truyền tham số cho chương trình thì sẽ vượt qua được đoạn kiểm tra trên, vì lúc đó argc sẽ là 2. Số tham số của chương trình sẽ bao gồm tên của file thực thi (trong trường hợp này là struct.exe) và chuỗi “aaaaaaaaaa” mà ta truyền vào, tổng cộng sẽ là 2.

Để định nghĩa một cấu trúc nào đó ta sử dụng từ khóa là struct, trong ví dụ này là struct MyStruct. Sau khi có cấu trúc rồi thì nó cũng tương tự như một kiểu bình thường (int, float, char, …) và ta chỉ việc khai báo biến nữa là xong. Cũng giống như cách chúng ta khai báo một biến có kiểu integer, ta sẽ đặt kiểu dữ liệu phía trước tên biến, ví dụ:

int cookie2;

Tương tự với trường hợp của biến kiểu cấu trúc:

MyStruct values;

Với cách khai báo như trên thì biến values sẽ thuộc kiểu MyStruct, và sẽ có cùng định nghĩa, cùng độ dài và cùng các trường. Và như vậy, ta có thể tạo ra nhiều biến khác nhau có kiểu MyStruct:

MyStruct data;

Để truy cập đến các thành viên được khai báo bên trong của cấu trúc ta sử dụng toán tử chấm (.). Ví dụ:

values.size
data.size
values.cookie2

Khi đã truy xuất được tới các thành viên của cấu trúc thì mỗi thành viên đó được xem như là một biến bình thường và ta gán giá trị hoặc nhập xuất giá trị cho chúng như cách thông thường mà chúng ta vẫn làm. Ví dụ:

values.cookie2 = 0;
values.size = 50;

Tiếp theo, chương trình sử dụng hàm strncpy để copy chuỗi lưu tại biến argv[1] vào vùng đệm values.buf có kích thước là 50 bytes, với values.size là kích thước tối đa (giá trị của values.size là 50, hay cụ thể hơn chính là số lượng kí tự tối đa được copy từ chuỗi nguồn (lưu tại argv[1]) vào chuỗi đích tại values.buf).

strncpy(values.buf, argv[1], values.size);

Như vậy, rõ ràng là sẽ không xảy ra overflow vì chỉ được sao chép tối đa 50 bytes vào một bộ đệm có kích thước 50 bytes. Do vậy, không thể xảy ra overflow.

Chương trình sử dụng hàm printf để in ra màn hình nội dung chúng ta đã nhập mà hiện tại đang được lưu trữ trong values.buf:

printf(values.buf);

Và cuối cùng gọi hàm getchar() nhằm mục đích để chương trình không tự động đóng mà chờ cho đến khi ta nhập vào một phím bất kỳ. Mục đích là để ta có thể thấy được chuỗi in ra trên màn hình console.

Toàn bộ hoạt động của chương trình đơn giản chỉ có vậy. Bây giờ, ta hãy load file đã biên dịch vào trong IDA để phân tích và tìm hiểu cách để làm việc với struct như thế nào:

Ta thấy rằng khi phân tích file mà có kèm theo .pdb thì mọi thứ quá tuyệt vời. IDA sẽ dễ dàng nhận diện được biến có kiểu MyStruct. Thậm chí, ngay cả tên các trường (thuộc struct) được truy cập trong code của chương trình cũng được nhận diện một hoàn hảo thông qua các câu lệnh asm như hình dưới đây:

Và cả ở đoạn code này:

Hơn nữa, ngay cả khi chuyển tới Tab Structures của IDA (Shift+F9), ta cũng thấy MyStruct được định nghĩa tại đây:

Để xem nội dung của MyStruct ta có thể nhấn đúp chuột hoặc nhấn phím tắt là CTRL-NUMPAD+. Kết quả có được gần giống như ta đã định nghĩa struct ở VS:

struct MyStruct
{
	char size;		// 1 byte
	int cookie2;	// 4 bytes
	char buf[50];	// 50 bytes
};

Các bạn thấy rằng tên trường và kích thước của từng trường trong struct tương ứng với những gì đã định nghĩa trong mã nguồn của chương trình. Biến size có kiểu char, kích thước là 1 byte sẽ tương ứng với từ khóa là db, biến cookie2 kiểu int, có kích thước là 4 bytes sẽ tương ứng với từ khóa là dd và biến buf là một mảng có 50 bytes. Điểm khác biệt ở chỗ là khi compile chương trình thì compiler sẽ tự động chèn thêm các padding bytes vào, nên các bạn sẽ thấy có các “db” nằm ở giữa các biến như trên hình. Do vậy, kích thước của struct sau khi compile xong sẽ khác với những gì mà ta tính toán thủ công bằng cách tính tổng cộng size của từng trường.

Bên cạnh đó trong IDA còn cung cấp cho chúng ta một tab khác có tên là Local Types. Tab này cho phép ta có thể chỉnh sửa và thêm các struct theo định dạng C++. Để mở tab này các bạn có thể thông qua menu là Open subviews hoặc nhấn phím tắt là Shift + F1. Tại đây IDA sẽ hiển thị rất nhiều kết quả, để tìm được struct của chúng ta thì các bạn nhấn CTRL + F và nhập vào tứ khóa cần tìm (ví dụ: My), kết quả có được như sau:

Sau đó, nhấn chuột phải tại MyStruct và chọn Edit, hoặc nhấn phím tắt là Ctrl + E:

Kết quả hiển thị đúng như những gì ta đã khai báo tại VS.

Nhưng “cuộc sống chả giống cuộc đời”… thực tế mọi thứ sẽ không giống như những gì bạn đã thấy ở trên. Dễ thế Đông Lào người ta mở lớp dạy reverse đầy, đâu cần phải có người viết dạo như tôi 😦. Đến chuyên gia tây lông người ta còn phải đăng status như thế này là đủ hiểu rồi…

Khi bạn phân tích một file binary, bạn chỉ có được duy nhất binary đó thôi, lấy đâu ra cái file .pdb đi kèm để nó hiển thị rõ ràng như thế trong IDA. Khi không có .pdb file thì IDA cũng điếc, vì IDA sẽ không thể phát hiện ra tên các biến cũng như các struct của chương trình. Tuy nhiên, IDA là một công cụ “interactive”, nó sẽ cung cấp cho chúng ta khả năng tương tác tuyệt vời nhất có thể để chỉnh sửa hoặc định nghĩa struct trong quá trình phân tích.

Giờ ta thử load lại file mà không load kèm theo file .pdb xem thế nào:

Các bạn thấy rằng lúc này IDA không còn phát hiện ra struct nữa và các trường của struct lúc này được IDA nhận diện như là các biến riêng lẻ, độc lập. Tuy nhiên, chẳng ai có thể phản đối được việc IDA đảo ngược code kiểu như thế này.

Vấn đề ở chỗ đây chỉ là một ví dụ nhỏ, có duy nhất một hàm main() cùng với một struct được định nghĩa rất đơn giản. Sau này, khi các bạn làm nhiều sẽ gặp các chương trình có nhiều hàm cùng các cấu trúc được định nghĩa rất phức tạp, chúng được truyền trong cùng một hàm hoặc từ hàm này sang hàm khác thông qua địa chỉ bắt đầu của struct, khi đó rất khó để biết đó là struct cũng như các trường tương ứng trong struct đó.

Đây là một điều không thể tránh đươc, do đó bắt buộc bạn phải biết về struct, và để biết thì không có cách nào khác ngoài việc luyện tập. Bây giờ, trong trường hợp của ví dụ này, việc ta reverse các trường như các biến riêng lẻ hoàn toàn được, nhưng tuy nhiên chúng ta sẽ thực hiện như thể chúng ta đã biết rằng đó là một struct. Ở các phần sau tôi sẽ nêu cách để phát hiện/ nhận biết khi nào nó là một struct và khi nào nó là biến cục bộ.

OK, tiến hành reverse dần dần, đầu tiên ta biết được biến var_4 chính là CANARY (random cookie value), do đó ta đổi tên nó:

Chương trình sẽ thực hiện so sánh tham số argc với 2, nếu bằng (tức là có truyền tham số) thì sẽ tiếp tục chạy, nếu không bằng in ra màn hình thông báo “Bye Bye” và gọi hàm exit() để thoát:

Phía trên đoạn so sánh, ta thấy chương trình gán giá trị 0x32 vào biến var_40 và sau đó sử dụng biến var_40 này như là biến đếm số lượng kí tự được phép copy, và truyền vào làm tham số cho hàm strncpy(). Do vậy, ta đổi tên biến này thành size tương ứng như trong mã nguồn của chương trình:

Để chuột tại biến này, IDA sẽ hiển thị thông tin nhận diện nó như là một biến kiểu byte (db):

Hoặc bằng cách nhấn chuột phải tại biến này, IDA sẽ cung cấp cách biểu diễn khác cho chúng ta để biết rằng nó là một biến kiểu byte:

Trong mã nguồn của chương trình, biến cookie2 ban đầu được khởi gán bằng 0 (values.cookie2 = 0) và không được sử dụng ở bất kì đâu khác trong chương trình. Trong mã asm, biến này sẽ tương ứng với biến var_3C. Đổi tên nó thành cookie2. Kể cả khi không biết nó dùng làm gì thì tôi khuyên các bạn cũng nên cung cấp cho nó một cái tên nào đó để gợi nhớ, ví dụ temp chẳng hạn, vì như ta thấy sau khi khởi gán, nó sẽ không được sử dụng nữa và như vậy không ảnh hưởng gì đến hoạt động của chương trình.

Tiếp theo, nhấp đúp chuột vào một biến bất kì để chuyển tới cửa sổ biểu diễn Stack của hàm main():

Tại đây ta thấy biến strDest, biến này được sử dụng làm tham số cho hàm strncpy() như là chuỗi đích. Quan sát thêm, các bạn thấy có không gian trống bên dưới với rất nhiều từ khóa db, điều này cho ta biết rằng khả năng nó có thể là một buffer, và khi ta nhấn ‘x’ để tìm kiếm các tham chiếu tới biến này:

Thông thường thì các buffer sẽ được truy xuất thông qua lệnh LEA, vì để điền nội dung vào buffer, ta sẽ phải truyền địa chỉ của buffer cho hàm. Như trong ví dụ này là hàm strncpy() và lệnh LEA có nhiệm vụ lấy ra địa chỉ của buffer. Do vậy, ta tiến hành chuyển đổi nó thành kiểu array:

Trong trường hợp này, kích thước của mảng sẽ là 52 thay vì 50 như ta khai báo trong mã nguồn. Tôi nghĩ là do khi biên dịch đã thêm padding bytes để đảm bảo chia hết cho 4. Kết quả có được như sau:

Sau khi thực hiện xong, ta đã có được thông tin layout hoàn chỉnh về Stack của hàm main(). Căn cứ vào thông tin này nhận thấy không thể overflow được, bởi vì ta biết rằng bộ đệm strDest có kích thước là 52 bytes và trong code của chương trình ta chỉ cho phép sao chép vào buffer này đúng 0x32 bytes thông qua hàm strncpy().

Trở lại mục tiêu ban đầu là xây dựng struct mặc dù ở ví dụ này không nhất thiết phải làm như thế, tuy nhiên chúng ta sẽ thực hiện điều này. Quay trở lại với biểu diễn của Stack của main(). Nếu như có một struct thì struct này sẽ không bao gồm biến CANARY, vì biến này do trình biên dịch tự động thêm vào trong quá trình compile code.

Như vậy, struct có thể sẽ bao gồm các trường như tôi đã khoanh ở trên hình. Đánh dấu các biến này, sau đó chọn meun Edit > Create struct from selection:

Kết quả, một struct với tên do IDA tự đặt là struct_0 được tạo ra và IDA sẽ tự động chuyển tới định nghĩa của struct này tại tab Structures:

Để cho giống như trong mã nguồn của chương trình, ta đổi tên của struct thành MyStruct:

Và trong phần biểu diễn của Stack, tại biến có kiểu MyStruct ta sẽ đổi tên nó thành values như trong mã nguồn của chương trình:

Sau khi tạo và thay đổi xong, quay lại màn hình Disassembly, chúng ta sẽ có được kết quả gần tương tự như khi ta load kèm file .pdb:

Như vậy, các bạn thấy rằng ít nhất trong hàm này, khi biến values được định nghĩa, các trường sẽ tự động thay đổi tên của chúng thành các values.xxxx. Rõ ràng, đây là một cách đơn giản nhất để tạo struct. Tuy nhiên, các bạn phải biết rằng đối với các struct phức tạp hơn, chúng ta sẽ cần reverse từng trường và tìm cách để có thể tạo một struct hoàn chỉnh nhất có thể.

Tạm thời như thế, trong phần tiếp theo tôi sẽ tiếp tục với các ví dụ phức tạp hơn về struct. Còn bây giờ là thời gian dành cho việc giải quyết bài tập IDA3.exeIDA4.exe.

Load IDA3.exe vào IDA, tương tự như phần trước, đầu tiên ta thấy biến var_5C được thêm vào bởi compiler, do dó đổi tên nó thành Temp. Biến này nằm trên biến Buffer:

Tại code của chương trình, ta thấy hai biến cookiecookie_2 được so sánh với các giá trị mặc định, như vậy ta phải tìm cách để ghi đè giá trị mong muốn lên hai biến này. Do chương trình sử dụng hàm gets(), nên ta hoàn toàn có thể làm được việc này:

Đầu tiên cần tính được số lượng byte cần thiết để ghi đè vào biến cookie. Do biến cookie nằm ngay dưới biến Buffer có kích thước là 68 bytes, nên cần phải truyền dữ liệu như sau:

68 * ‘A’ + ‘XXXX’

Với việc truyền như trên sẽ ghi đè được vào biến cookie, giá trị XXXX lúc này phải là 0x71727374h hay ‘trsq’:

Script để thực hiện tự động như sau:

from subprocess import *
import struct
p = Popen([r'IDA3.exe', 'f'], stdout=PIPE, stdin=PIPE, stderr=STDOUT)

cookie=struct.pack("<L",0x71727374)
cookie2=struct.pack("<L",0x1020005)
flag=struct.pack("<L",0x90909090)
string="I can do it!!\n"

print " Attach to the debugger and press Enter\n"
raw_input()

user_input=string + (68 -(len(string)))*"A" + cookie + flag + cookie2
p.stdin.write(user_input)

testresult = p.communicate()[0]

print user_input
print(testresult)

Kết quả sau khi chạy script như hình dưới đây:

Điểm khác biệt các bạn sẽ thấy so với script viết cho IDA2.exe là: string + (68 -(len(string)))*”A”

Với IDA3.exe như vậy là xong! Tiếp theo là IDA4.exe. Ta load file này vào IDA. Sau khi IDA phân tích xong ta sẽ thấy trong code của file này còn có thêm một hàm _check():

OK, tiến hành reverse hàm main(). Đầu tiên ta thấy chương trình sử dụng hàm printf() để in ra màn hình địa chỉ của 3 biến là buf, cookiecookie2 (“buf: %08x cookie: %08x cookie2: %08x\n”). Do vậy, ta sẽ đổi tên các biến tương ứng:

Sau đó, Buffer sẽ được truyền vào cho hàmgets(), do vậy ta biết rằng có thể overflow được buffer này. Mà để có thể overflow thì ta cần phải biết được chiều dài của Buffer.

Chuyển qua cửa sổ biểu diễn Stack và chuyển đổi Buffer này thành dạng array, ta sẽ biết được độ dài của nó là 50 bytes:

Kết quả sau chuyển đổi:

Như trên hình, ta sẽ thấy bên dưới Buffer là 2 biến cookie và cookie2, mỗi biến có kích thước là 4 bytes. Tiếp theo, ta sẽ thấy rằng hai biến cookie này được truyền là các tham số cho hàm check(), cộng thêm một biến nữa là var_4 mà chúng ta chưa biết là gì. Ta cứ tạm thời đổi tên nó thành flag để cho dễ nhìn:

Với kết quả trên hình thì có thể thấy tham số thứ 3 của hàm check() sẽ là flag, tham số thứ hai là cookie2 và tham số đầu tiên là cookie. Đi vào hàm check(), ta thấy hàm này có ba tham số tương ứng và một biến cục bộ:

Theo như phân tích về các tham số của hàm check(), ta chọn hàm và nhấn ‘Y’ để thiết lập lại kiểu của hàm như sau:

Kết quả có được như sau:

Quay trở về hàm main sẽ thấy IDA tự động comment các tham số truyền vào khớp như sau:

Đi sâu vào phân tích hàm check() ta sẽ có được kết quả biến cookie phải bằng 0x71725553h (“qrUS”), biến cookie2 phải có giá trị khác 0x1020D0Ah. Sau khi so sánh hai biến cookie với các giá trị mặc định, hàm check() sẽ trả về giá trị của flag:

Giá trị của flag sau khi thoát khỏi hàm check() sẽ được lưu vào thanh ghi eax. Khi quay trở lại hàm main(), giá trị này sẽ được lưu lại vào biến var_8. Biến var_8 sau đó sẽ được đem đi so sánh với một giá trị mặc định là 35224158h, nếu bằng giá trị này thì chương trình sẽ in ra màn hình thông báo “You win man”:

Tuy nhiên, quay trở lại Stack layout của main, ta thấy biến var_8 này nằm trên biến flag của chúng ta. Do đó để ghi đè giá trị 0x35224158h vào biến flag thì ta phải ghi đè một giá trị bất kỳ vào biến var_8 này:

Dựa vào toàn bộ kết quả đã phân tích ở trên, ta tạo script như sau:

from subprocess import *
import struct
p = Popen([r'IDA4.exe', 'f'], stdout = PIPE, stdin = PIPE, stderr = STDOUT)

cookie = struct.pack("<L", 0x71725553)
cookie2 = struct.pack("<L", 0x41424344)
temp = struct.pack("<L", 0x90909090)
flag = struct.pack("<L", 0x35224158)

print " Attach to the debugger and press Enter\n"
raw_input()

user_input = 50 * "A" + cookie + cookie2 + temp + flag
p.stdin.write(user_input)

testresult = p.communicate()[0]

print user_input
print(testresult)

Kiểm tra script này, kết quả như hình dưới đây:

Hẹn gặp lại các bạn trong phần 26 để tiếp tục tìm hiểu thêm về struct.

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

m4n0w4r

Đời có qua có lại thì mới toại lòng nhau
Người ta cho mình quá nhiều, mình thì cho cái mẹ gì đâu
Thấy nợ nần nhiều, như là người mà đang đi vay lãi
Bí quyết thành công, gói gọn trong hai từ “may vãi”

_Đen_

Ủ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


Phần 24 này tôi sẽ thực hành với file IDA2.exe đã gửi kèm ở phần trước. Cách làm với IDA2.exe cũng tương tự như cách đã thực hiện với file IDA1.exe, chỉ khác là trong file này có hai biến cần kiểm tra điều kiện, nếu thỏa mãn cả hai thì chúng ta sẽ tới được đoạn code hiển thị thông báo “you are a winnner man je\n”:

Như trên hình ta thấy biến var_14 được so sánh với giá trị 0x71727374h còn biến var_C được so sánh với giá trị 0x91929394, do đó ta phải tìm cách để có thể thay đổi giá trị các biến này. Trước tiên, tôi sẽ đổi tên chúng thành cookiecookie_2 để dễ dàng nhận biết trong code của chương trình:

Sau khi đổi tên xong, chọn lần lượt từng biến này và nhấn “X”:

Ta thấy rằng các biến này duy nhất được truy cập thông qua lệnh LEA trước khi thực hiện hàm printf(). Lệnh LEA sẽ lấy thông tin địa chỉ của các biến để in ra màn hình, bên dưới là câu lệnh CMP làm nhiệm vụ so sánh. Như vậy là giá trị của các biến này không hề bị thay đổi trong hàm main().

Do đó, theo suy luận tương tự như phần trước, ta phải thay đổi giá trị của các biến này để có thể tới được thông báo hiển thị “…winner…”. Mà như đã thấy ở trên hình thì các biến này đều được so sánh với một giá trị cụ thể do chương trình mặc định sẵn. Vì vậy, để có thể thay đổi các biến thì khả năng sẽ phải thực hiện overflow lên vùng buffer, để từ đó có thể ghi đè giá trị ta mong muốn lên các biến này.

Bên cạnh các biến cookie mà ta vừa phân tích ở trên, để ý một chút các bạn sẽ thấy trong file IDA2.exe này còn có thêm một biến khác là var_10. Ban đầu, biến này được khởi gán bằng 0:

Nhấn ‘X’ để xem biến này còn được sử dụng ở những chỗ nào khác:

Theo kết quả trên hình, ta thấy ngoài lệnh khởi gán nó bằng 0 thì biến này còn được truy cập ở hai vị trí khác nữa thông qua lệnh LEA. Chúng ta sẽ xem xét từng chỗ một, nhưng trước tiên ta thực hiện việc điều chỉnh lại các tham số được truyền cho các hàm APIs để cho dễ nhìn hơn. Áp dụng kỹ thuật đã làm với file IDA1.exe, kết quả có được như sau:

Sau khi thay đổi xong các bạn sẽ thấy code nhìn rõ ràng hơn nhiều so với ban đầu, qua đó ta cũng hiểu được cách mà chương trình truyền tham số cho hàm APIs. OK, giờ quay lại vấn đề lúc trước, ta kiểm tra các đoạn code khác có truy cập tới biến var_10:

Phân tích đoạn code trên hình, đầu tiên thông qua lệnh LEA thì eax sẽ có được địa chỉ của biến, sau đó thực hiện tăng nội dung hay tăng giá trị của nó lên 1 thông qua lệnh INC. Sau đó, lấy lại địa chỉ của biến một lần nữa và truyền nó như một tham số của hàm printf(). Như vậy, hàm printf() sẽ in ra địa chỉ của biến chứ không phải là giá trị của biến.

Dựa vào thông tin chương trình sẽ in ra màn hình dòng thông báo “flag %x”, tôi đổi tên biến var_10 thành flag. Tuy nhiên, sau quá trình phân tích thì thấy rằng biến này không có ảnh hưởng gì tới quá trình hoạt động của chương trình.

Tóm lại, chúng ta chỉ cần nghiên cứu biến Buffer vì các biến ở trên biến này được sinh ra trong quá trình compile chương trình, được sử dụng làm các biến tạm thời chứ không có tác động đến chương trình:

Nhấp đúp vào biến Buffer, ta sẽ chuyển tới màn hình cung cấp thông tin biểu diễn Stack của hàm main() như hình dưới đây:

Rõ ràng, biến Buffer này là một nơi được dành riêng trong bộ nhớ, được sử dụng để lưu thông tin mà người dùng nhập vào từ bàn phím thông qua giao diện console. Biến này được truyền vào là một tham số của hàm gets():

Để kiểm tra kích thước của buffer này ta nhấn chuột phải và chọn Array, IDA sẽ cung cấp thông tin mà nó phân tích được:

Như vậy Buffer có kích thước gồm 68 phần tử, mỗi phần tử là 1 byte. Ta nhấn OK để đồng ý chuyển đổi, kết quả có được như hình:

Với kết quả có được, ta biết rằng đầu tiên phải điền đầy buffer với 68 ký tự, và do hạn chế của hàm gets nên chúng ta có thể overflow buffer này và ghi đè thêm 4 bytes vào biến cookie nằm bên dưới của buffer. Tiếp theo là ghi đè lên 4 bytes của biến flag (một dword (dd)) và cuối cùng là 4 bytes của biến cookie_2:

Tổng kết lại: user_input= 68 *”A”+ cookie + flag + cookie2. Với thông tin như vậy, chúng ta có thể xây dựng một python script như sau:

from subprocess import *
import struct
p = Popen([r'IDA2.exe', 'f'], stdout=PIPE, stdin=PIPE, stderr=STDOUT)

cookie=struct.pack("<L",0x71727374)
cookie2=struct.pack("<L",0x91929394)
flag=struct.pack("<L",0x90909090)

print " Attach to the debugger and press Enter\n"
raw_input()

user_input=68 *"A"+ cookie + flag + cookie2
p.stdin.write(user_input)

testresult = p.communicate()[0]

print user_input
print(testresult)

Kiểm tra thử script, tôi có được kết quả như sau:

Như vậy là thành công!

Image result for deal with expert funny

Trong phần 24 này tôi gửi kèm thêm 2 file IDA3.exeIDA4.exe (https://mega.nz/#!bOxGWISb!kYzE2SsWPyo0YQg5tL72qy14ntuMlic02ML5bg7-GD0) để các bạn thực hành như một bài tập về nhà.

Hẹn gặp lại các bạn ở phần 25!

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


Trong phần 23 này tôi sẽ giải quyết câu hỏi đặt ra đối với file IDA1.exe đã được gửi kèm trong phần 22. Đầu tiên, load file vào IDA để phân tích, ta sẽ dừng lại tại hàm main():

Tại đây, bạn cuộn chuột xuống dưới sẽ nhìn thấy chuỗi “you are a winnner man je\n“. Đó chính là nơi mà chúng ta cần phải tới được. Tôi đổi màu cho block này để tiện cho việc nhận diện một cách dễ dàng.

Quan sát bên trên một chút, ta thấy rằng để có thể tới được đoạn code hiển thị chuỗi mong muốn thì phải vượt qua được đoạn code thực hiện so sánh giữa biến var_C với một hằng số 0x51525354. Nhấn chuột phải tại giá trị 0x51525354, IDA sẽ cung cấp cho chúng ta một loạt các lựa chọn chuyển đổi, ở đây chúng ta sẽ lựa chọn chuyển thành các kí tự ‘QRST’:

Các bạn để ý một chút sẽ dễ dàng có thể nhận ra, file IDA1.exe được biên dịch không sử dụng cơ chế CANARY protection, vì nếu có áp dụng thì ở của đầu hàm nó sẽ phải đọc dữ liệu từ một địa chỉ thuộc section data vào thanh ghi eax và đem xor với thanh ghi ebp, kết quả được lưu vào biến nằm ngay trên giá trị của ebp đã được lưu vào Stack trước đó. Trước khi kết thúc hàm biến này sẽ được đọc ra để kiểm tra lại. Tóm lại file IDA1.exe không áp dụng Stack Canary, ta sẽ phân tích tĩnh bố trí các biến và tham số tại Stack của hàmmain(), nhấp đúp vào bất kỳ biến nào ta sẽ có kết quả như sau:

Tại đây, ta thấy rằng biến duy nhất xuất hiện trong Stack, ngay bên trên giá trị của EBP được lưu, đó là biến var_C. Chọn biến này và nhấn “X”, sẽ thấy nó được sử dụng để so sánh với chuỗi “QRST”. Căn cứ trên kết quả so sánh sẽ đi tới đoạn code hiển thị thông báo “winner” hay là không:

Tóm lại, file này không áp dụng CANARY, vì như ta thấy rõ ràng trong trường hợp này thì đây là một lệnh kiểm tra thuộc mã nguồn của chương trình. CANARY không thể “trộn” lẫn nằm xen kẽ với các quyết định rẽ nhánh trong mã của chương trình, nó phải được bổ sung bởi chính trình biên dịch, và nằm ở bên ngoài code ban đầu.

Cho nên để tránh nhầm lẫn biến var_C với CANARY, ta có thể đổi tên nó thành var_Decision.

Như trên hình, biến này được sử dụng hai lần trong hàm main(). Vậy làm thế nào chúng ta có thể thay đổi giá trị của biến var_Decision để khiến cho chương trình rẽ nhánh đi tới đoạn code hiển thị thông báo “winner”?

Khi các bạn quan sát kĩ vào các dòng lệnh asm thì điều sẽ khiến bạn chú ý là có các biến được truy cập thông qua thanh ghi EBP và có những biến khác được truy cập thông qua thanh ghi ESP:

Toàn bộ các lệnh khởi tạo ban đầu được thêm vào bởi trình biên dịch nhằm thiết lập thanh ghi ESP nằm trên các biến cục bộ và buffer. Lệnh SUB ESP, 0xB8 làm nhiệm vụ dành ra không gian phục vụ cho các biến cục bộ và buffer được khai báo bên trong thân hàm main(). Để biết cụ thể thì các bạn có thể debug để hiểu rõ hơn. Bản thân IDA có một trợ giúp rất hữu ích là “Stack Pointer”, hỗ trợ hiển thị sự thay đổi của thanh ghi ESP kể từ điểm bắt đầu của hàm (giá trị tại đầu hàm được thiết lập bằng 0).

Kết quả tại code của chương trình sẽ xuất hiện thêm thông tin như hình dưới đây:

Quan sát trên hình các bạn sẽ thấy sự tác động của mỗi lệnh lên ngăn xếp bắt đầu từ đầu hàm, sau khi thực hiện PUSH EBP, thanh ghi ESP sẽ bị giảm đi 4, do vậy dòng lệnh thứ hai có thêm số 004 ở bên phải, hàm ý là stack pointer thay đổi. Tuy nhiên, ở câu lệnh thứ hai là MOV EBP, ESP, vì đây chỉ là một lệnh MOV nên lệnh này không làm thay đổi Stack, do vậy giá trị của ESP vẫn giữ nguyên, thanh ghi thay đổi là EBP.

Đó là lý do tại sao dòng lệnh thứ ba có cùng giá trị là 004 vì lúc này chưa thay đổi ESP. Như vậy, ta thấy rằng ở thời điểm này giá trị ESPEBP là giống nhau và thanh EBP từ đây có thể được sử dụng để truy xuất các biến cục bộ cũng như các tham số của hàm. Dòng lệnh thứ 3 thực hiện trừ ESP đi 0xB8, do vậy kết quả sẽ là giá trị 0xBC (so với điểm bắt đầu) và vì EBP vẫn ở mức 004 nên chênh lệch giữa hai thanh ghi EBPESP sẽ là 0xB8.

Quan sát kĩ hơn thì thấy khoảng cách này không thay đổi nữa ngay cả khi thực hiện các lời gọi hàm call __allocacall ___main (được tự động thêm vào trong quá trình biên dịch chương trình). Tóm lại, khoảng cách giữa EBPESP0xB8, và đây chính là khoảng không gian dành cho các biến cục bộ và buffer được khai báo trong thân của hàm main().

Tiếp tục đọc code, các bạn thấy có biến được IDA đặt tên là var_9c. Kiểm tra toàn bộ hàm main() thì thấy biến này không được sử dụng cho việc gì khác, vì vậy nó có thể là một biến tạm thời được tạo bởi trình biên dịch, ta sẽ đặt tên nó là Temp để dễ nhận biết:

Bên dưới biến Temp chúng ta có biến Buffer:

Nhấn chuột phải tại biến Buffer này và chuyển đổi sang kiểu array, ta sẽ được kết quả là một biến Buffer có kích thước là 140 phần tử, mỗi phần tử có kích thước 1 byte:

Nếu chúng ta có thể làm tràn biến Buffer bằng cách đẩy vào dữ liệu nhiều hơn 140 bytes, như vậy sẽ gây ra Buffer Overflow, lúc đó ta có thể ghi đè giá trị lên biến var_Decision và nếu có thể được sẽ ghi đè dữ liệu lên cả giá trị EBP đã được lưu và đè lên cả địa chỉ trở về (r). Điều này tùy thuộc lượng dữ liệu mà ta có thể nhồi vào biến Buffer.

Tiếp tục quá trình reversing, điểm gây chú ý tiếp theo liên quan tới trình biên dịch, đó là là cách nó truyền các tham số cho hàm. Ở target này, thay vì sử dụng lệnh PUSH để lưu các tham số vào Stack (như vẫn thường gặp), thì ở đây ta thấy chương trình lưu trực tiếp thông qua lệnh MOV. Các bạn quan sát lệnh printf, rõ ràng trong trường hợp này printf có một đối số duy nhất là địa chỉ của chuỗi “you are a winner man je\n” và không có tham số nào khác:

Ta thấy chương trình truy xuất địa chỉ của chuỗi trên vì có tiền tố offset ở phía trước và lưu vào biến có tên Format trong ngăn xếp. Nhưng cụ thể là ở đâu trong Stack? Lúc này chương trình không sử dụng thanh ghi EBP để truy cập đến tham số của hàm mà sử dụng chính thanh ghi ESP, tuy nhiên ký hiệu (esp+0B8h+Format) hơi khó hiểu, nếu chúng ta nhấn chuột phải tại đây IDA sẽ cung cấp cho ta thông tin thay thế như sau:

Nếu ta chọn cách biểu diễn như IDA gợi ý:

Qua việc chuyển đổi trên, ta có thể hiểu bản chất của câu lệnh mov sẽ thực hiện việc lưu địa chỉ của chuỗi vào trong nội dung thanh ghi ESP (đó chính là đỉnh của Stack), để sử dụng nó như một tham số của hàm. Do vậy, chương trình thay vì sử dụng lệnh PUSH thì nó sử dụng lệnh MOV để truyền tham số cho hàm, và theo logic thì lệnh PUSH sẽ thay làm đổi giá trị của ESP, trong khi lệnh MOV thì không, các bạn có thể thấy được thông qua giá trị 0xBC ngay trước hàm printf():

Cách truyền tham số này cũng được áp dụng đối với tất cả các hàm APIs khác mà IDA1.exe sử dụng. Áp dụng cách thức chuyển đổi tương tự như đã làm ở trên, ta sẽ có được các đoạn mã lệnh asm nhìn dễ hiểu hơn:

Tiếp tục quá trình phân tích, hàm printf() đầu tiên nhận ba tham số truyền vào cho nó. Vì sao lại biết được là có 3 tham số? Là bởi vì hàm in ra màn hình chuỗi sử dụng các giá trị để thay thế cho format string: “buf:%08x cookie:%08x \ n”. Các giá trị “%08x” sẽ được thay thế bằng hai tham số được truyền trước đó tại [esp+8][esp+4]:

Cách truyền tham số cho hàm thường sẽ là từ phải qua trái. Do đó, tham số thứ 3 của hàm printf() là địa chỉ của biến var_Decision, có được thông qua lệnh LEA và lưu tại thanh ghi EAX. Sau đó lưu địa chỉ này vào vị trí [ESP+8] trên Stack. Tham số thứ hai là địa chỉ của biến Buffer, cũng thực hiện theo cách tương tự nhưng được lưu vào bị trí [ESP+4] trên Stack và tham số đầu tiên được lưu tại [ESP] là địa chỉ của chuỗi “buf: %08x cookie: %08x\n”.

Nếu chạy trực tiếp file này tại màn hình console, ta sẽ thấy chuỗi được in ra màn hình cùng với địa chỉ của cả hai biến:

Tiếp theo, chương trình sử dụng hàm gets() để đọc các kí tự từ stdin (standard input) và lưu vào biến Buffer. Ta để ý ở đây số lượng kí tự nhập vào bằng bàn phím là tùy ý mà không có bất kỳ giới hạn nào, tức là ta có thể nhập vào nhiều hơn 140 ký tự mà không có vấn đề gì:

Đoạn code trên sẽ tương ứng với mã giả C như sau: gets(Buffer);

Vì vậy, giả sử nếu ta ghi vào Buffer này 140 chữ ‘A’ và kèm theo sau đó là chuỗi “TSRQ” (theo kiểu little endian), chương trình sẽ nhảy tới đoạn code hiển thị thông báo “winner” vì khi đó biến var_Decision nằm ngay bên dưới Buffer, sẽ được ghi đè bởi giá trị “QRST”. Kiểm tra bằng tay trước khi chúng ta thực hiện tự động bằng script:

Thực hiện lệnh tại Python bar của IDA, sau đó copy chuỗi được sinh ra:

“AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATSRQ”

Chạy chương trình và dán chuỗi này vào, kết quả có được như sau:

Kết quả đúng như mong đợi, ta tiến hành tạo script để chạy tự động, script này cũng giống như script đã tạo ở phần trước trước, chỉ khác ở này chỉ truyền một input cho chương trình thôi:

from subprocess import *
p = Popen([r'IDA1.exe', 'f'], stdout=PIPE, stdin=PIPE, stderr=STDOUT)

print "Attach to the debugger and press Enter\n"
raw_input()

user_input="A" *140 + "TSRQ\n"
p.stdin.write(user_input)

testresult = p.communicate()[0]

print user_input
print(testresult)

Kiểm tra kết quả sau khi thực hiện script:

Phần 23 đến đây là kết thúc. Tôi gửi các bạn bài tập IDA2.exe (https://mega.nz/#!jXpgGSKJ!jx8p41yH5Drqq78fKonMqVQhmQf2BqwbJh9aqHDwZ_o) để các bạn thực hành, nó tương tự như IDA1.exe.

Image result for that was easy funny gif office space

Hẹn gặp lại các bạn ở phần 24!

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


Ở 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ỗi Buffer 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 time
p = 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 size
print 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 getchar()) 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à -1) 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


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