REVERSING WITH IDA FROM SCRATCH (P7)

Phần này sẽ là một phần khá thú vị về các lệnh liên quan đến việc kiểm soát luồng thực thi của chương trình hay còn gọi với một thuật ngữ chuyên ngành là flow control. Như các bạn đã biết rằng thanh ghi EIP luôn trỏ vào lệnh tiếp theo sẽ được thực hiện và khi được thực thi thì EIP sẽ trỏ tới lệnh kế tiếp.

Nhưng bản thân các chương trình cũng có các lệnh dùng để kiểm soát luồng thực thi, từ đó có thể chuyển hướng thực hiện đến một lệnh mong muốn. Trong phần này, chúng ta sẽ xem xét các trường hợp cụ thể của lệnh.

Lệnh nhảy không điều kiện

Lệnh JMP

JMP A; giống như lệnh goto trong lập trình bậc cao Lệnh JMP là một lệnh nhảy không phụ thuộc vào điều kiện và A sẽ là một địa chỉ bộ nhớ mà chúng ta muốn chương trình nhảy tới.

JMP SHORT là một lệnh nhảy ngắn gồm có 2 bytes, có khả năng nhảy về phía trước và ngược lại. Hướng nhảy được chỉ định bởi giá trị của byte thứ hai vì byte đầu tiên là opcode (0xEB) của lệnh. Lệnh này không thể nhảy quá xa.

Nếu thiết lập tùy chọn trong IDA để hiển thị opcode của các lệnh, chúng ta sẽ thấy opcode EB tương ứng với lệnh JMP và lệnh này sẽ nhảy 5 bước về phía trước kể từ vị trí kết thúc lệnh. Nghĩa là địa chỉ đích của lệnh được tính như sau:

Lấy địa chỉ bắt đầu của lệnh cộng với 2 là số bytes chiếm bởi lệnh và sau đó cộng thêm 5 (byte thứ hai – bước nhảy). Rõ ràng, việc nhảy tới hoặc lùi với một byte duy nhất không cho chúng ta đạt được bước nhảy xa. Bước nhảy cao nhất sẽ là 0x7f, chúng ta sẽ lấy một ví dụ bên dưới.

Bên lề: Khi chúng ta thực hiện một số thay đổi sẽ phá vỡ cấu trúc của hàm, để thuận tiện ta nên tiến hành tạo một bản chụp (snapshot) cơ sở dữ liệu nhằm giúp ta quay trở lại trạng thái trước khi thay đổi. Hãy làm việc này vào bất cứ lúc nào bạn nghi ngờ về việc thay đổi sẽ có thể làm phá vỡ cấu trúc hàm và không biết cách nào để khôi phục lại.

IDA sẽ yêu cầu chúng ta đặt tên cho database được snapshot. Có thể quản lý snapshot này thông qua View > Database Snapshot Manager:

Tại đây, chúng ta có thể xem danh sách tất cả các snapshot và ngày chụp, cùng với nút Restore cho phép chúng ta có thể trở về trạng thái chúng ta muốn từ những bản snapshot mà chúng ta đã lưu.

Hãy xem điều gì sẽ xảy ra nếu tôi thay 5 thành 7F:

Sử dụng tính năng Patch Bytes của IDA để thực hiện thay đổi lệnh như trên hình. Sau khi patch xong, ta thấy bước nhảy sẽ dài hơn và vượt ra ngoài hàm. Nhấn phím space bar để thoát khỏi chế độ đồ hoạ, chuyển sang chế độ Text:

Chúng ta thấy rằng lệnh nhảy của chúng ta vẫn OK và nhảy tới địa chỉ là 0x4013a5. Tiếp theo, nếu tôi thay 0x7f bằng 0x80 thì sẽ thế nào? Quay về chế độ đồ họa và thực hiện thay đổi thành 0x80. Sau đó lại chuyển về chế độ text, ta có kết quả như hình:

Chúng ta thấy rằng bây giờ lệnh nhảy đã chuyển thành lệnh có bước nhảy lùi lớn nhất.

Trong trường hợp này, do ta thực hiện bước nhảy lùi, để đảm bảo cho công thức tính toán và bởi Python không biết được đây là bước nhảy tiến hay nhảy lùi từ giá trị này, ta phải sử dụng giá trị -0x80 (được biểu diễn bằng một dword ở hệ thập lục phân là 0xFFFFFF80) và sau đó thực hiện AND kết quả tính toán được với 0xFFFFFFFF nhằm xóa toàn bộ các bit lớn hơn một số 32 bit. Kết quả ta có được địa chỉ nhảy đến là 0x4012a6.

Nếu tôi sử dụng giá trị 0xFF thì ta sẽ có một bước nhảy tối thiểu vì giá trị hex 0xFF là biểu diễn của -1. Ở đây, tôi thay bằng 0xFFFFFFff. Luôn nhớ rằng ta cần cộng thêm 2 bytes (đó là độ lớn của lệnh), do đó, kết quả tính toán sẽ có được địa chỉ cần nhảy tới là 0x401325.

Nếu chúng ta tiếp tục với một giá trị khác, ví dụ 0xFE, tức là nhảy ngược -2, vậy theo công thức sẽ cộng thêm 0xFFFFFFFE.

Với giá trị này thì lệnh nhảy sẽ nhảy tới chính câu lệnh đó hay còn được gọi là Infinite Loop, bởi vì nó luôn luôn lặp đi lặp lại chính nó và không thể thoát được.

Bên lề: 2 bytes “0xEB 0xFE” được gọi là 2 bytes “thần thánh”. Chúng được sử dụng trong quá trình Unpacking, Debug Malware. Thông thường malware sẽ tạo ra các thread hoặc bằng các kĩ thuật Process Hollowing/ RunPE để thực thi malicious code, lúc này ta sẽ tìm cách patch bytes tại entry point thành 0xEB 0xFE để tạo infinite loop (lưu ý nhớ lại byte gốc của EP), sau khi patch xong để process thực thi bình thường và rơi vào vòng lặp vô tận, tiến hành attach tiến trình mới vào một trình debugger khác để debug tiếp.

Cứ như vậy, nhảy -3 sẽ là 0xFD, vì vậy nó sẽ nhảy đến địa chỉ 0x401323.

Rõ ràng với những bước nhảy ngắn, chúng ta không thể nhảy tới bất kỳ địa chỉ nào vì bị giới hạn ở một vài byte xung quanh nơi chúng ta đang sử dụng nó, do đó ta cần sử dụng bước nhảy dài.

Như trên hình, ta thấy một vài lệnh nhảy dài. Tiền tố loc_ đứng đằng trước hàm ý rằng đó là địa chỉ cần nhảy tới:

Trong hình, ta thấy một lệnh nhảy dài, khoảng cách giữa 0x4026ae0x4029b3 là lớn hơn nhiều so với những gì chúng ta có thể đạt được với một bước nhảy ngắn.

Khoảng cách sẽ được tính bằng công thức lấy địa chỉ cuối cùng – địa chỉ ban đầu – 5 (là chiều dài của lệnh) (Final address – start address – 5), kết quả có được là 0x300. Đó chính là dword đứng cạnh opcode của bước nhảy dài 0xe9.

Nếu tôi sử dụng plugin Keypatch để thay đổi địa chỉ đích của lệnh nhảy đến một hướng ngược lại, ví dụ 0x400000:

Tuy nhiên, nó được đánh dấu màu đỏ bởi đó không phải là một địa chỉ hợp lệ. Thử áp dụng công thức tính toán trong Python:

Kết quả có được là -0x26b3:

Chuyển sang hexa là FFFFD94D, đó là các bytes đứng cạnh opcode 0xe9, được bố trí theo kiểu Little-endian:

Lệnh nhảy có điều kiện (Jcc)

Thông thường, các chương trình phải đưa ra các quyết định rẽ nhánh thực thi chương trình, điều này sẽ căn cứ vào việc so sánh các giá trị để chuyển hướng thực hiện chương trình sang một điểm khác.

Ta có lệnh so sánh:

CMP A, B; so sánh toán hạng thứ nhất với toán hạng thứ hai và bật các cờ trên thanh ghi EFLAGS dựa theo kết quả tính toán (việc tính toán tương tự như lệnh SUB, nhưng khác ở chỗ kết quả tính toán không được lưu lại).

Tôi cần chương trình thực hiện so sánh giữa A và B. Dựa vào mối quan hệ giữa chúng, chương trình sẽ thực hiện một công việc nào đó nếu thỏa mãn, còn không chương trình sẽ thực hiện một công việc khác. Vì vậy, bình thường sau khi so sánh sẽ làm thay đổi các FLAGS, căn cứ vào trạng thái của cờ, lệnh nhảy có điều kiện sẽ quyết định có thực hiện hay không.

Trong hình trên, chúng ta thấy một ví dụ về lệnh nhảy có điều kiện là JZ. Lệnh này sẽ thực hiện nếu cờ ZF được bật. Điều này được quyết định bởi lệnh CMP trước đó. Khi hai thanh ghi EAX và EBX bằng nhau, CMP thực hiện phép trừ hai thanh ghi, kết quả sẽ bằng 0 và do đó cờ ZF được bật thành 1. Khi cờ ZF được kích hoạt thì sẽ thực hiện lệnh nhảy theo hướng mũi tên màu xanh lá cây, còn nếu hai thanh ghi khác nhau thì sẽ đi theo hướng của mũi tên đỏ.

Nếu sử dụng debugger để debug chương trình, chúng ta có thể tương tác để thay đổi kết quả trên các cờ. Tuy nhiên, điều quan trọng bây giờ cần phải nhớ có những lệnh nhảy khác nhau có thể xuất hiện trong một chương trình như hình minh họa dưới đây:

Ngoại trừ các lệnh JMPNOP được liệt kê trong bảng, các lệnh còn lại đều là các lệnh nhảy có điều kiện. Các lệnh nhảy này đều căn cứ vào kết quả của câu lệnh so sánh trước đó.

  • Các lệnh nhảy Above / Below được sử dụng cho so sánh số không dấu (unsinged comparison)
  • Các lệnh nhảy Greater than / Less than được sử dụng cho so sánh số có dấu (singed comparison)
  • Mặc dù có nhiều lệnh nhảy có điều kiện nhưng may mắn là trong số đó có các lệnh cùng một mục đích. Ví dụ: JNE == JNZ (Nhảy nếu không bằng nhau, Nhảy nếu không bằng 0, cả hai cùng kiểm tra cờ (ZF == 0)).
Instruction Description Aliases Flags
jz Jump if zero je zf = 1
jnz Jump if not zero jne zf = 0
jl Jump if less jnge sf = 1
jle jump if less or equal jng zf=1 or sf=1
jg jump if greater jnle zf=0 and sf=0
jge jump if greater or equal jnl sf = 0
jc jump if carry jb, jnae cf = 1
jnc jump if not carry jnb, jae cf = 0

Bên lề: bên cạnh việc so sánh sử dụng câu lệnh CMP, một câu lệnh khác cũng rất hay được sử dụng là TEST. Bản chất của lệnh TEST là tính toán logic thông qua việc AND hai toán hạng, căn cứ trên kết quả để bật cờ. Kết quả tính toán sẽ không được lưu lại.

Lệnh CALL và RET

Các lệnh tiếp theo mà tôi sẽ đề cập đến là lệnh CALL, dùng để gọi một hàm và lệnh RET, dùng để trở quay trở về lệnh tiếp theo sẽ được thực hiện sau lệnh Call.

Trên hình, chúng ta thấy một ví dụ của lệnh CALL, lệnh này sẽ nhảy đến địa chỉ 0x4013d8 để thực hiện hàm tại đó (chúng ta thấy tiền tố sub_ ở phía trước của địa chỉ 0x4013D8 thông báo cho ta biết đây là một hàm).

Lệnh CALL thực hiện sẽ lưu vào đỉnh của ngăn xếp (Stack) đỉa chỉ trở về sau khi thực hiện xong hàm, tức là địa chỉ bên dưới của lệnh Call mà trong trường hợp này là 0x40123d. Sau đó, nó sẽ thay đổi địa chỉ của thanh ghi EIP bằng địa chỉ đã chỉ định trong câu lệnh. Địa chỉ đích có thể được chỉ định theo nhiều cách:

  • Giá trị trực tiếp (immediate value): call   0x401da8
  • Thanh ghi dùng chung: call eax
  • Vị trí bộ nhớ: call    dword ptr [0x40202c]

Ta có thể truy cập các lệnh bên trong CALL bằng cách nhấn Enter tại lệnh CALL đó:

Khi kết thúc hàm, sẽ thực hiện một lệnh RET, lệnh này có nhiệm vụ lấy địa chỉ trở về được lưu tại đỉnh của stack là 0x40123d, đưa vào thanh ghi EIP và nhảy tới địa chỉ này để tiếp tục thực hiện lệnh sau CALL.

Bên lề: liên quan đến hai lệnh Call & Ret chúng ta cần biết thêm về tập quán gọi hàm (hay từ chuyên môn là calling convention), bởi vì việc gọi hàm có thể khác nhau trong mã lệnh asm. Nó bao gồm thứ tự các tham số được đẩy vào ngăn xếp hay các thanh ghi, hàm gọi (caller) hay hàm được gọi (callee) chịu trách nhiệm dọn dẹp ngăn xếp khi hoàn tất hàm. Tùy thuộc vào trình biên dịch, có hai tập quán gọi hàm hay gặp nhất là cdecl (C declaration) và và stdcall. Ngoài ra, còn rất nhiều tập quán gọi hàm khác mà các bạn có thể xem thêm tại đây: https://en.wikipedia.org/wiki/X86_calling_conventions

cdecl – tập quán gọi hàm phổ biến nhất:

  • Tham số của hàm được đẩy vào stack theo chiều từ phải qua trái.
  • Kết quả trả về của hàm thường lưu vào thanh ghi EAX.
  • Hàm gọi (caller) chịu trách nhiệm dọn dẹp stack.

Ví dụ:

_cdecl int function(int arg1, int arg2, int arg3)

Stdcall – thường được sử dụng bởi Microsoft C++, đặc biệt là Win32 API functions:

  • Tham số của hàm được đẩy vào stack theo chiều từ phải qua trái.
  • Kết quả trả về của hàm thường lưu vào thanh ghi EAX.
  • Hàm được gọi (callee) chịu trách nhiệm dọn dẹp stack.

Ví dụ:

_stdcall int function(int a, int b, int c)

Như vậy, tới phần này chúng ta đã tìm hiểu một số câu lệnh ASM hay sử dụng nhất. Nó là bước đệm để các tôi và các bạn đi tiếp các phần sau.

Phần 7 xin được dừng lại tại đây. Hẹn các bạn gặp lại ở phần 8 sẽ thú vị hơn!

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

One thought on “REVERSING WITH IDA FROM SCRATCH (P7)

  • March 11, 2019 at 8:56 pm
    Permalink

    Trân trọng cảm ơn các bạn đã ủng hộ “Bỉm sữa”!!

    Regards,

    Reply

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: