REVERSING WITH IDA FROM SCRATCH (P14)

m4n0w4r
tradahacking
Published in
13 min readMay 24, 2019

Vài dòng lan man:

Tôi đoán có nhiều bạn trông chờ và đặt nhiều kì vọng vào bộ tuts này như kiểu một tài liệu sẽ làm thay đổi cuộc đời :P. Nó sẽ biến bạn trở thành một “chuyên gia hàng đầu”, được nhiều người săn đón 😦

Đứng kì vọng để rồi thất vọng!!!

Không hiểu sao tôi rất dị ứng với từ chuyên gia, mỗi lần bị giới thiệu như vậy trong người tôi nó thất bứt rứt lắm….. Nhiều bạn cũng có thắc mắc sao lâu tôi chưa ra bài mới. Điều này rất khó nói 😀:
— Nó phụ thuộc vào khung thời gian trống còn lại trong ngày.
— Hơn nữa cũng phụ thuộc nhiều vào người đọc. Đọc giả có tâm, đọc, góp ý/thắc mắc những chỗ tôi viết chưa ổn và hơn hết họ đọc tới đọc đến tận chỗ “bỉm sữa” :). Đọc giả có tâm hơn nữa, họ không cần đọc mà kéo roẹt xuống chỗ có dòng “bỉm sữa” :D. Còn phần đa thì chắc là đọc đến chỗ “ấy” thì dừng lại….

Tuyển tập các bài viết này sẽ thường xuyên thay đổi nội dung và sẽ bao gồm các chủ đề khác nhau liên quan đến reversing như: static reversing, debugging, unpacking và exploiting.

Trong phần này, chúng ta sẽ unpack file PACKED_CRACKME.exe ( tải tại đây), được packed bằng phiên bản UPX mới nhất ( tính đến thời điểm viết bài). Tuy nhiên, điều này không có nghĩa là tôi sẽ viết nhiều bài khác chỉ nói về chủ đề unpacking, chúng ta sẽ thay đổi và trộn lẫn các chủ đề khác nhau để không bị nhàm chán. Do đó, bên cạnh việc tìm hiểu Unpacking bằng IDA thì đồng thời ta sẽ vẫn nghiên cứu thêm các chủ đề khác.

File bị Packed?

Định nghĩa đơn giản về tập tin packed đó là một tập tin được ẩn mã thực thi gốc của chương trình và lưu lại bằng cách áp dụng các kỹ thuật nén hoặc mã hóa để tránh không bị reverse một cách dễ dàng. Bên cạnh đó nó cũng được chèn thêm một đoạn Stub hay một section, để khi thực thi chương trình, đoạn stub này sẽ nhận code đã được mã hóa, giải mã nó trong bộ nhớ, lưu code giải mã vào trong bất kỳ section nào hoặc là chính nó và cuối cùng nhảy tới vùng code này để thực thi ( đó chính là code gốc ban đầu của file).

Có rất nhiều biến thể của các trình packers, và đa phần trong số chúng là các trình protectors, sử dụng các kĩ thuật như hủy bảng IAT hay import table, hủy thông tin về Header của file. Bổ sung thêm các cơ chế anti-debugger để tránh việc unpack và khôi phục lại tập tin ban đầu.

Thực hành

Ví dụ đơn giản nhất mà các chuyên gia hay sử dụng để minh họa về packer đó chính là UPX. Trình packer này không áp dụng các kĩ thuật anti-debug, hay các tiểu xảo nào khác được đề cập ở trên, tuy nhiên nó lại giúp ta khởi đầu với những kĩ năng đơn giản nhất trong quá trình thực hiện unpacking. Để tiện cho các bạn trong việc thực hành, file PACKED_CRACKME.EXE đã được đính kèm link tải về ở trên.

Load file vào IDA và chọn Manual load vì tùy chọn này sẽ giúp chúng ta có thể nạp tất các sections của file, đồng thời bỏ lựa chọn Create imports segment ( Thầy Ricardo khuyến nghị nên bỏ chọn khi làm việc với các packed files). Kết quả sau khi load vào IDA như hình dưới:

Ta đang dừng tại địa chỉ bắt đầu hay còn gọi là Entry Point (EP) của PACKED_CRACKME.exe. Với file bị packed thì địa chỉ EP là 0x409be0, trong khi ở file gốc thì ta sẽ dừng tại địa chỉ EP là 0x401000, như hình minh họa bên dưới:

Ngoài ra, so sánh các sections hay segments của cả hai file, ta thấy rằng dưới header của file sau khi bị packed có thêm một section mới với tên là UPX0, có kích thước trong bộ nhớ lớn hơn so với file gốc ban đầu.

File gốc

File bị PACKED

Theo quan sát trên hình, section UPX0 của file bị packed kết thúc tại địa chỉ 0x409000, trong khi toàn bộ các sections ở file gốc đều nằm trong vùng nhớ bắt đầu từ 0x401000 đến 0x408200. Ở đây, ta đang đề cập đến bộ nhớ ảo, đó là khi một chương trình thực thi, nó có thể chiếm dung lượng 1k trên ổ đĩa (HDD) nhưng sẽ chiếm 20k hoặc hơn thế nữa trong bộ nhớ.

Điều này có thể thấy được khi phân tích trong IDA, ví dụ, tại địa chỉ bắt đầu 0x401000 của file gốc ta thấy các thông tin như sau:

Trong hình trên ( Section size in file) chiếm 0x600 byte, trong khi trong bộ nhớ ( Virtual size) chiếm 0x1000. Trở về file bị packed, nếu chúng ta chuyển tới địa chỉ 0x401000 — đó là nơi bắt đầu của section UPX0.

Chúng ta thấy rằng Section size in file chiếm 0x0 byte trên đĩa, nhưng trong bộ nhớ nó chiếm 0x8000. Điều này có nghĩa là, nó dành ra một không gian trống phục vụ cho việc khôi phục lại mã chương trình gốc ban đầu tại đây và sau đó nhảy tới đây để thực hiện lệnh. Như vậy, nó dành đủ không gian để thực hiện việc khôi phục code này.

Ta cũng thấy rằng địa chỉ 0x401000 có kèm theo tiền tố dword_ ở phía trước nghĩa là nội dung của nó là một DWORD. Dấu (?) hàm ý nó cần được dành riêng và không chứa bất kỳ giá trị nào, từ khóa dup có nghĩa là dword đó được nhân với 0xc00, như vậy kết quả là sẽ dành ra 0x3000 bytes:

Tiếp theo tại 0x404000, ta thấy có 1400h dup(?). Có nghĩa là cần dành ra vùng nhớ:

Do đó, tổng số cần có 0x8000 bytes sẽ được dành riêng trong bộ nhớ để đặt chương trình vào đó. Tại dword_401000 ta thấy có một tham chiếu trong mã thực thi, ta sẽ xem xét câu lệnh này làm gì sau.

Tiếp theo bên dưới, ta thấy file packed có thêm một section thứ hai là UPX1, section này có kích thước trên đĩa là 0xe00 và trong bộ nhớ 0x1000. Đây có khả năng là nơi chương trình lưu một số kĩ thuật mã hóa đơn giản nhằm để che dấu mã gốc.

Nếu kiểm tra thông tin tham chiếu tại địa chỉ bắt đầu của section là 0x409000, ta có kết quả:

Chúng ta thấy có một tham chiếu bên dưới ( Down) trong code thực thi của chương trình. Chuyển tới vùng code đó:

Tại Stub trên, sau địa chỉ Entry Point, chương trình nạp địa chỉ 0x409000 vào thanh ghi ESI ( Ta biết lấy địa chỉ là bởi có tiền tố offset ở phía trước). Chuyển qua chế độ Text mode bằng cách nhấn phím space bar, tại đó chúng ta thấy như sau:

Đoạn stub này nằm cùng section UPX1, bên dưới code của chương trình gốc đã packed. Nghĩa là tại section UPX1, trình packer đã thực hiện lưu các bytes đã encrypted của chương trình gốc tại đây và stub code bắt đầu từ 0x409be0.

Ta có thể dễ dàng nhận ra vùng Stub sẽ đọc các bytes từ 0x409000, sau đó áp dụng một số thao tác tính toán và lưu lại kết quả sau tính toán vào 0x401000. Ta thấy thanh ghi EDI = ESI-0x8000:

Nói cách khác, chương trình sẽ sử dụng vùng nhớ trỏ bởi ESI ( như là source), từ đó đọc dữ liệu ra và áp dụng các tính toán nhất định, sau đó lưu vào vùng nhớ trỏ bởi EDI ( như là dest) để khôi phục lại code ban đầu của chương trình.

Chúng ta đã biết tại 0x401000 có một tham chiếu trong mã thực thi, nếu chúng ta nhấp đúp vào tham chiếu đó:

Ta tới vùng code chứa một lệnh nhảy (jmp) tới 0x401000:

Jmp near là một lệnh nhảy trực tiếp đến địa chỉ, do vậy nó sẽ nhảy thẳng đến 0x401000. Rõ ràng, ở đây sau khi thực thi toàn bộ mã lệnh tại Stub và tái tạo lại mã gốc ban đầu của chương trình, chương trình sẽ nhảy tới OEP tại địa chỉ 0x401000 (Entry Point gốc), không giống như Entry Point của Stub0x00409BE0.

Ta sẽ gọi tắt là OEP hay Original Entry Point để hàm ý rằng đó chính Entry Point (EP) của chương trình gốc ban đầu. Điều này là hiển nhiên vì khi một chương trình bị packed, ta hoàn toàn không biết địa chỉ này ở đâu và chỉ khi ta có file gốc thì ta mới có biết được EP ban đầu là 0x401000 như hình minh họa dưới đây:

Tóm lại, khi chúng ta làm việc với một chương trình bị packed, ta sẽ không biết OEP của nó ở đâu bởi ta không có file gốc ban đầu, do đó chúng ta sẽ phải áp dụng các kĩ thuật để tìm ra OEP. Quay lại với phân tích ở trên, khi Stub hoàn thành tất cả các thủ thuật của nó và khôi phục lại mã gốc, nó sẽ nhảy tới OEP để từ đó thực thi chương trình.

Chính vì thế, ta có thể đặt một Breakpoint tại lệnh Jmp tới OEP để xem code của chương trình gốc có được khôi phục như ta đã suy đoán như trên không. Thử đặt một BP như hình dưới:

Sau đó chọn debugger là Local Win32 Debugger và nhấn Start debugger. Ngay lập tức, ta sẽ dừng tại BP vừa đặt ở trên:

Nhấn F8 để trace qua lệnh này:

IDA sẽ hiển thị một thông báo như trên, cứ nhấn Yes để thông báo cho IDA biết và nhận diện lại section UPX0 ban đầu như là CODE ( ban đầu nó được định nghĩa là DATA).

Ta thấy rằng stub đã giải nén code và nhảy tới 401000 để thực thi. So sánh thì thấy code này rất giống với code tại địa chỉ 0x401000 ở file gốc, nhưng tuy nhiên ta lại không thể chuyển sang chế độ đồ họa bởi vì lúc này nó không được định nghĩa như là một function ( chỉ là loc_401000).

Để chuyển được sang chế độ đồ họa, có một tùy chọn ẩn ở góc dưới bên trái của màn hình IDA, bằng cách nhấp chuột phải tại đó và chọn Reanalyze program:

Bằng cách này, địa chỉ loc_401000 đã thay đổi thành sub_401000 cho biết bây giờ nó đã được hiểu như là một hàm. Vì vậy, ta có thể chuyển sang chế độ đồ hoạ bằng cách nhấn phím tắt space bar:

Ta thấy, code giờ đây đã đẹp hơn 🙂

Tuy nhiên, quan sát kĩ một chút ta sẽ thấy có sự khác biệt, ở file gốc tại địa chỉ 0x401002 sẽ hiển thị lời gọi tới hàm API CALL GetModuleHandleA, trong khi tại file ta đang phân tích chỉ hiển thị lệnh CALL sub_401056. Đi vào lệnh Call này để xem code của nó là gì:

Ta lại thấy sự khác biệt với bản gốc. Nếu tại file gốc khi ta truy cập vào CALL GetModuleHandleA:

Ta thấy có một lệnh nhảy gián tiếp tới hàm API. Vậy còn tại file đang đang phân tích thì sao? Hàm API đã đi đâu? Thử follow theo lệnh nhảy tại file đang phân tích, ta có được thông tin như sau:

Như trên hình, nội dung tại 0x403028 là một offset ( off_), đó là địa chỉ của API GetModuleHandleA và tại file gốc, cũng quan sát địa chỉ này tại section .idata thì ta thấy cũng chứa địa chỉ của cùng một hàm API.

Mặc dù, ta thấy chúng đều nhảy tới cùng một địa chỉ, tuy nhiên có một sự khác biệt rất quan trọng mà chúng ta sẽ tìm hiểu sau. Như vậy, có thể nói tới thời điểm này code gốc của chương trình đã được unpack hoàn toàn.

Có một cách khác nữa để tìm OEP, đó là tìm lệnh được thực thi đầu tiên ở section đầu tiên. Tuy nhiên, phương pháp này không phải lúc nào cũng có hiệu quả :). Để làm theo cách này, ta khởi động lại target trong IDA, cấu hình debugger để dừng lại tại Entrypoint khi thực thi. Sau đó, cho thực thi file packed, ta sẽ dừng lại ở entry point:

Tiếp theo, ta chuyển tới section đầu tiên bắt đầu tại địa chỉ 0x401000.

Tại đó, tôi nhấn F2 để đặt một breakpoint và cấu hình để dừng khi thực thi ( Execute) — có nghĩa là chương trình sẽ break chỉ khi thực thi lệnh mà không dừng khi nó đọc hoặc ghi dữ liệu, đó là điều tôi kì vọng. Và vì tôi không biết chính xác lệnh nào sẽ được thực thi đầu tiên nên Breakpoint on execute mà tôi đặt sẽ bao gồm toàn bộ section ( 0x8000 bytes). Cụ thể như hình minh họa dưới đây:

Sau khi thiết lập breakpoint như trên xong, nhấn OK, lúc này toàn bộ section sẽ được đánh dấu bằng màu đỏ như hình:

Sau đó, nếu có breakpoint nào mà các bạn đã đặt trước đó thì hay tiến hành vô hiệu hóa bằng cách truy cập Debugger->Breakpoint->Breakpoint List.

Nhấn chuột phải tại breakpoint và chọn Disable breakpoint, tương tự như hình:

Sau đó cho thực thi chương trình. Khi chương trình break, ta thấy sẽ dừng lại tại lệnh đầu tiên ở section vừa được tạo, trong trường hợp này, 0x401000 chính là OEP mà tôi tìm được:

Sau khi đã đạt được mục đích, ta vô hiệu hóa breakpoint đã đặt. Sau đó tiến hành phân tích lại toàn bộ chương trình, kết quả có được tương tự như những gì đã làm ở trước:

OK, qua toàn bộ phân tích trên chúng ta đã có được OEP gốc của chương trình thông qua hai cách thực hiện khác nhau. Công việc tiếp theo mà ta cần làm là dump file và rebuild lại toàn bộ IAT để đảm bảo file sau khi unpacked thực thi được một cách bình thường như file gốc ban đầu. Tuy nhiên, công việc đó sẽ làm ở các phần tiếp theo, phần hôm nay là đủ rồi, các bạn có thể tạo một bản Snapshot để có thể quay lại vào phần tới.

Phần này xin được kết thúc tại đây. Hẹn gặp các bạn ở phần 15!

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

--

--