18/09/2018, 15:18

PHÂN TÍCH MÃ ĐỘC X86 DISASSEMBLY (Phần 2)

Ở phần 1 của chủ đề Phân tích mã độc X86 DISASSEMBLY chuyên gia an ninh mạng Bùi Đình Cường đã gửi đến các bạn những phân tích về Các mức trừu tượng Dịch ngược cũng như Kiến trúc x86. Tiếp nối chủ đề này là những phân tích về các lệnh X86 đơn giản, Lệnh NOP, các phép toán số học, ...

các lệnh x86 1

Ở phần 1 của chủ đề Phân tích mã độc X86 DISASSEMBLY  chuyên gia an ninh mạng Bùi Đình Cường đã gửi đến các bạn những phân tích về Các mức trừu tượng Dịch ngược cũng như Kiến trúc x86. Tiếp nối chủ đề này là những phân tích về các lệnh X86 đơn giản, Lệnh NOP, các phép toán số học, ngăn xếp, các lời gọi hàm, bố trí trong stack, các lệnh điều kiện, rẽ nhánh, các lệnh Rep, phương thức main trong C và các Offset.

Các lệnh X86 đơn giản

Lệnh đơn giản nhất và phổ biến nhất chính là mov, được sử dụng để chuyển dữ liệu từ một vùng sang vùng khác. Nói cách khác, nó là lệnh thực hiện đọc và ghi trên bộ nhớ. Lệnh mov có thể chuyển dữ liệu vào các thanh ghi hoặc RAM. Cú pháp chung là mov des, src.

Bảng dưới cung cấp các ví dụ của lệnh mov. Các toán hạng đặt trong dấu ngoặc vuông ([operand]) được dùng như dữ liệu tại địa chỉ ô nhớ. Ví dụ, [ebx] là dữ liệu tại địa chỉ có giá trị EBX. Ví dụ cuối cùng trong bảng sử dụng một đẳng thức để tính toán giá trị địa chỉ ô nhớ. Ví dụ này là một cách tiết kiệm không gian vì nó không yêu cầu lệnh riêng để thực hiện tính toán bên trong dấu ngoặc vuông. Thực hiện tính toán như vậy bên trong một lệnh sẽ không khả thi trừ khi ta tính toán các giá trị địa chỉ ô nhớ. Ví dụ, mov eax, ebx+esi*4 (không có dấu ngoặc vuông) là một lệnh không hợp lệ.

Lệnh Mô tả
mov eax, ebx Chép nội dung thanh ghi EBX sang thanh ghi EAX
mov eax, 0x42 Truyền giá trị 0x42 vào thanh ghi EAX
mov eax, [0x4037C4] Chép 4 byte (32 bit) tại địa chỉ nhớ 0x4037C4 vào thanh ghi EAX
mov eax, [ebx] Chép 4 byte tại địa chỉ nhớ là giá trị của thanh ghi EBX vào thanh ghi EAX
mov eax, [ebx+esi*4] Chép 4 byte tại địa chỉ nhớ là giá trị kết quả của đẳng thức ebx+esi*4 vào thanh ghi EAX

Một lệnh tương tự mov là lệnh lea (load effective address). Cú pháp chung của lệnh là lea des, src. Lệnh này dùng để truyền một địa chỉ ô nhớ vào đích. Ví dụ, lea eax, [ebx+8] sẽ truyền giá trị EBX+8 vào thanh ghi EAX. Ngược lại, mov eax, [ebx+8] sẽ truyền dữ liệu tại địa chỉ ô nhớ EBX+8 vào EAX. Vì thế, lea eax, [ebx+8] tương đương mov eax, ebx+8; nhưng như đã đề cập, lệnh mov như vậy là không hợp lệ.

Hình dưới minh họa các giá trị của thanh ghi EAX và EBX ở bên trái, dữ liệu được lưu trữ trong bộ nhớ ở bên phải. EBX được set giá trị 0xB30040. Ở địa chỉ 0xB30048 là giá trị 0x20. Lệnh mov eax, [ebx+8] sẽ truyền giá trị 0x20 (lấy từ bộ nhớ) vào thanh ghi EAX, còn lệnh lea eax, [ebx+8] sẽ set giá trị 0xB30048 trên thanh ghi EAX.

các lệnh x86 1

Lệnh lea không chỉ dùng để chỉ giá trị địa chỉ nhớ. lea rất hữu dụng khi cần tính toán các giá trị vì bản thân nó yêu cầu rất ít lệnh. Ví dụ, ta thường thấy những lệnh kiểu lea ebx, [eax*5+5] eax khi đó thường là một số hơn là một địa chỉ ô nhớ. Lệnh này tương đương ebx = (eax+1)*5, nhưng dùng lea thì lệnh ngắn hơn và hiệu quả hơn đối với trình biên dịch thay vì phải sử dụng tổng cộng bốn lệnh (inc eax; mov ecx, 5; mul ecx; mov ebx, eax).

XEM THÊM: LÝ THUYẾT DEBUGGING TRONG PHÂN TÍCH MÃ ĐỘC

Các phép toán số học

Hợp ngữ x86 bao gồm nhiều lệnh dùng cho các phép toán số học, từ cộng trừ cơ bản tới các tính toán logic. Ta chỉ thảo luận về những lệnh phổ biến nhất.

Phép cộng hay phép trừ đơn giản là thêm hoặc bớt một giá trị nào đó từ toán hạng đích. Cú pháp lệnh cộng là add des, value. Cú pháp lệnh trừ là sub des, value. Lệnh sub thay đổi hai cờ quan trọng là ZF CF. Cờ ZF được set nếu kết quả sau phép trừ bằng 0 và cờ CF được set nếu giá trị toán hạng đích nhỏ hơn giá trị trừ. Các lệnh inc dec tăng 1 hoặc giảm 1 trên một thanh ghi. Bảng bên dưới minh họa một số ví dụ của các lệnh cộng và trừ.

Lệnh Mô tả
sub eax, 0x10 Trừ đi 0x10 từ EAX
add eax, ebx Thêm EBX vào EAX và lưu kết quả trên EAX
inc edx Giảm EDX đi 1
dec ecx Tăng ECX lên 1

Phép nhân và phép chia đều được thực hiện trên một thanh ghi định nghĩa trước, vì thế mà câu lệnh đơn giản chỉ là tên lệnh cộng với giá trị mà thanh ghi sẽ được nhân (mul) hoặc chia (div) bao nhiêu lần. Cú pháp của lệnh mul mul value. Tương tự, cú pháp lệnh div div value. Việc chỉ định lệnh mul hay div thực hiện trên thanh ghi nào có thể được tiến hành từ trước đó nhiều lệnh nên ta phải rà soát xuyên suốt chương trình để tìm ra nó.

Lệnh mul value luôn nhân eax lên value lần. Vì thế, EAX phải được chuẩn bị chính xác trước khi thực hiện lệnh nhân. Kết quả được lưu bằng một giá trị 64-bit trên hai thanh ghi: EDX và EAX. EDX lưu 32 bit trọng số cao hơn và EAX lưu 32 bit còn lại (trọng số thấp) của toán hạng. Hình dưới minh họa các giá trị của EDX và EAX khi lưu kết quả thập phân sau phép nhân là 5,000,000,000 và kết quả này quá lớn để lưu trong một thanh ghi đơn.

các lệnh x86 2

Lệnh div value hoạt động tương tự như lệnh mul nhưng theo hướng ngược lại: Nó chia 64 bit trên EDX và EAX cho value. Vì thế, thanh ghi EDX và EAX phải được chuẩn bị chính xác trước khi thực hiện lệnh chia. Kết quả lệnh chia được lưu trên thanh ghi EAX và phần dư được lưu trên EDX.

Ta có thể lấy được phần dư của một phép chia bằng phép tính modulo được biên dịch hợp ngữ thông qua việc sử dụng thanh ghi EDX sau khi thực hiện lệnh div. Bảng bên dưới minh họa sử dụng lệnh mul div. Lệnh imul idiv là phiên bản có dấu tương ứng với mul div.

Lệnh Mô tả
mul 0x50 Nhân EAX với 0x50 và lưu kết quả trên EDX:EAX
div 0x75 Chia EDX:EAX cho 0x75 và lưu kết quả chia trên EAX, lưu phần dư trên EDX

Các phép tính logic như OR, AND và XOR được sử dụng trong kiến trúc x86. Các lệnh tương ứng hoạt động tương tự như add và sub. Ta thường xuyên bắt gặp lệnh xor trong code hợp ngữ. Ví dụ, xor eax, eax là cách nhanh chóng và tối ưu để set giá trị thanh ghi EAX về 0. xor eax, eax chỉ cần 2 byte, trong khi mov eax, 0 yêu cầu tới 5 byte. 

Lệnh shr shl được dùng để dịch cách thanh ghi. Cú pháp lệnh shr shr des, count, và cấu trúc lệnh shl cũng tương tự. Hai lệnh này dịch các bit trong toán hạng đích sang phải hoặc trái với số bit dịch được chỉ định trong toán hạng count. Các bit dịch vượt quá giới hạn của toán hạng đích sẽ được dịch vào cờ CF. Các bit 0 được điền trong quá trình dịch. Ví dụ, nếu ta có số nhị phân 1000 và dịch phải 1 bit, kết quả là 0100. Ở bước cuối của lệnh dịch, cờ CF chứa bit cuối cùng được dịch vượt khỏi toán hạng đích.

Các lệnh quay, ror rol, cũng giống như các lệnh dịch, ngoại trừ việc các bit “rơi ra” sau lệnh dịch sẽ được quay sang đầu bên kia của toán hạng. Nói cách khác, sau lệnh quay phải (ror), bit có trọng số thấp nhất được đặt vào vị trí trọng số lớn nhất. Phép quay trái (rol) thì ngược lại. Bảng dưới đây minh họa kết quả thực hiện các lệnh vừa đề cập.

Lệnh Mô tả
xor eax, eax Xóa dữ liệu trong thanh ghi EAX
or eax, 0x7575 Thực hiện phép tính logic OR trên EAX với 0x7575
mov eax, 0xA

shl eax, 2

Dịch trái 2 bit trên thanh ghi EAX; kết quả sau khi thực hiện hai lệnh này là EAX = 0x28 vì 1010 (nhị phân của 0xA) dịch trái 2 bit là 101000 (nhị phân của 0x28)
 

mov bl, 0xA

ror bl, 2

Quay thanh ghi BL sang phải 2 bit; kết quả sau khi thực hiện hai lệnh này là BL = 10000010 vì 1010 quay phải 2 bit là 10000010

Phép dịch thường được sử dụng thay thế cho phép nhân vì sự đơn giản và nhanh hơn bởi ta không cần chuẩn bị các thanh ghi và chuyển dữ liệu qua lại. Sử dụng lệnh dịch là cách tối ưu để thực hiện một phép nhân. Lệnh shl eax, 1 cho kết quả tương đương với nhân EAX với 2. Dịch trái 2 bit tương đương phép nhân 4, dịch trái 3 bit tương đương phép nhân 8. Tổng quan lại, dịch trái n bit trên một toán hạng thì tương đương nhân toán hạng đó với 2n.

Khi phân tích mã độc, nếu ta bắt gặp một hàm chỉ chứa các lệnh xor, or, and, shl, shr, ror hay rol lặp lại và nhìn có vẻ được sử dụng ngẫu nhiên, có thể ta đang xử lý một hàm đã được mã hóa hoặc được nén. Đừng sa lầy khi cố phân tích từng lệnh trừ khi ta thực sự cần phải làm như vậy. Thay vào đó, cách tốt nhất trong hầu hết trường hợp là đánh dấu hàm đó lại (như là “bị mã hóa, bó tay!”) và chuyển sang phân tích các hàm khác.

Lệnh NOP

Lệnh cơ bản cuối cùng, nop, không làm gì. Khi gặp lệnh nop, CPU đơn giản là sẽ nhảy đến lệnh kế tiếp. nop thực chất là tên đại diện của lệnh xhcg eax, eax, nhưng vì trao đổi EAX với chính nó chính là chẳng làm gì nên ta dùng tên lệnh là NOP (no operation).

Opcode cho lệnh này là 0x90. NOP thường được dùng trong tấn công tràn bộ đệm khi hacker không có cách nào hoàn hảo để kiểm soát luồng chương trình độc hại. Hacker sẽ lấp đầy phần đầu của bộ đệm bằng các lệnh NOP, kế đó là shellcode. Hơn nữa, để không phải tính toán chính xác vị trí lưu con trỏ lệnh bảo lưu trên stack, hacker sẽ chỉ đặt shellcode ở khoảng giữa của bộ đệm, phần còn lại sẽ chứa toàn các giá trị địa chỉ bắt đầu của shellcode.

Ngăn xếp

Trong bộ nhớ, các tham số đầu vào của hàm, biến cục bộ và điều khiển luồng được lưu trên một ngăn xếp (stack). Stack là một cấu trúc dữ liệu đặc trưng bởi thao tác push pop. Ta push dữ liệu vào stack và sau đó sẽ pop chúng ra. Stack có cấu trúc LIFO. Ví dụ, nếu ta push các giá trị 1, 2 và 3 theo đúng thứ tự trên thì giá trị đầu tiên được pop sẽ là 3 vì nó là giá trị cuối cùng được push vào stack.

Kiến trúc x86 hỗ trợ sẵn cơ chế stack. Các thanh ghi hỗ trợ làm việc trên stack bao gồm ESP và EBP. ESP là con trỏ stack và thường chứa một địa chỉ ô nhớ trỏ tới đỉnh của stack. Giá trị của thanh ghi này thay đổi mỗi khi có một đối tượng được push hoặc pop từ stack. EBP là con trỏ có giá trị không đổi trong một hàm cho trước, vì thế mà chương trình coi nó như một “người giữ chỗ” để theo dõi các biến cục bộ và các tham số.

Các lệnh thao tác với stack gồm push, pop, call, leave, enter ret. Stack có dạng top-down (từ trên xuống dưới) trong bộ nhớ, tức là sử dụng các ô nhớ theo thứ tự địa chỉ từ cao đến thấp. Ô nhớ có địa chỉ cao nhất được chỉ định và sử dụng đầu tiên. Các giá trị được push vào stack sẽ sử dụng các ô nhớ có địa chỉ thấp hơn.

Stack chỉ được sử dụng để lưu trữ dữ liệu tạm thời, lưu các giá trị biến cục bộ, tham số đầu vào và địa chỉ trả về. Tác dụng cơ bản của nó là để quản lý dữ liệu trao đổi giữa những lời gọi hàm. Việc triển khai quản lý là khác nhau đối với từng trình biên dịch.

Các lời gọi hàm

Hàm là một đoạn code thực hiện một công việc cụ thể, độc lập với các phần code khác trong một chương trình. Code chính gọi hàm và thực thi tạm thời chuyển hướng đến hàm trước khi quay lại phần code chính. Ở đây, ta sẽ tập trung tìm hiểu stack convention phổ biến nhất, cdecl.

Nhiều hàm chứa một prologue  – mở đầu hàm – vài dòng code ở vị trí đầu hàm. Prologue sẽ chuẩn bị stack và các thanh ghi mà hàm sẽ sử dụng. Tương tự, một epilogue – kết thúc hàm ở cuối hàm sẽ khôi phục stack và các thanh ghi về trạng thái trước khi hàm được gọi.

Danh sách sau tóm tắt luồng thực hiện của lời gọi hàm trong hầu hết trường hợp.

  1. Các tham số đầu vào được đưa vào stack bằng lệnh push.
  2. Một hàm được gọi bởi call memory_location. Địa chỉ lệnh hiện tại (giá trị của thanh ghi EIP) sẽ được push vào stack. Địa chỉ này sẽ được dùng để trở về chương trình chính khi hàm đã hoàn tất thực thi. Khi hàm bắt đầu, EIP được set giá trị memory_location (điểm bắt đầu của hàm).
  3. Qua mở đầu hàm (prologue), không gian được chỉ định trên stack cho các biến cục bộ và EBP được push vào stack.
  4. Hàm bắt đầu thực hiện chức năng của mình.
  5. Qua kết thúc hàm (epilogue), stack được khôi phục về trạng thái ban đầu. ESP được điều chỉnh giá trị để giải phóng các biến cục bộ. EBP được khôi phục và hàm gọi đến có thể đánh đúng địa chỉ cho các biến của nó. Lệnh leave có thể được dùng như một kết thúc hàm vì nó set ESP bằng EBP và pop EBP khỏi stack.
  6. Hàm trả về giá trị bằng lệnh ret. Lệnh này pop địa chỉ trả về khỏi stack và truyền giá trị đó vào EIP, chương trình sẽ tiếp tục thực thi từ đoạn lời gọi ban đầu được gọi.
  7. Stack được điều chỉnh để xóa các tham số đã được gửi đi trừ khi các tham số đó sẽ được sử dụng lại về sau.

Bố trí trong stack

Như đã đề cập, stack được cấp phát theo kiểu top-down, sử dụng địa chỉ nhớ cao hơn trước. Hình bên trên thể hiện cách stack được tổ chức trong bộ nhớ. Mỗi khi một lời gọi hàm được thực hiện, một stack frame mới được tạo. Một hàm sẽ giữ stack frame của nó cho tới khi trả về kết quả, khi đó stack frame (caller’s stack frame) được khôi phục và luồng thực thi trở về hàm gọi ban đầu (calling function).

Hình bên dưới minh họa cấu trúc một stack frame. Địa chỉ ô nhớ của mỗi đối tượng dữ liệu cũng được hiển thị. Trong sơ đồ này, ESP trỏ tới đỉnh stack, tại vị trí 0x12F02C. EBP được set giá trị 0x12F03C trong suốt quá trình thực thi hàm, các biến cục bộ và tham số đầu vào của hàm vì thế mà sẽ được định vị bằng EBP. Các tham số đầu vào được push vào stack trước khi lời gọi xảy ra và được lưu ở đáy stack frame. Sau các tham số là địa chỉ trả về, được tự động push vào stack ngay sau khi thực hiện lệnh gọi hàm. Giá trị cũ của EBP được push vào stack ngay sau địa chỉ trả về, đây là giá trị EBP từ caller’s stack frame.

Khi một thông tin được push vào stack, ESP sẽ giảm. Trong ví dụ phía dưới, nếu thực hiện lệnh push eax, ESP sẽ giảm đi 4 và chứa giá trị mới là 0x12F028, và dữ liệu trong EAX sẽ được copy sang ô nhớ có địa chỉ 0x12F028. Nếu lệnh pop ebx được thực hiện, dữ liệu trong ô nhớ 0x12F028 sẽ được chuyển vào thanh ghi EBX và ESP sẽ tăng lên 4 (0x12F02C).

Ta có thể đọc dữ liệu từ stack mà không dùng lệnh push hay pop. Ví dụ, lệnh mov eax, ss:[esp] sẽ truy cập trực tiếp tới đỉnh stack. Lệnh này tương đương pop eax, ngoại trừ thanh ghi ESP không được dùng đến. Convention phụ thuộc vào trình biên dịch và cách cấu hình đối với trình biên dịch đó.

Kiến trúc x86 cung cấp các lệnh khác phục vụ việc pop và push, phổ biến nhất là pusha pushad. Các lệnh này push tất cả các thanh ghi vào stack, thường được dùng cùng với popa popad, pop tất cả các thanh ghi ra khỏi stack. Các hàm pusha pushad hoạt động như sau:

  • pusha push các thanh ghi 16-bit vào stack theo thứ tự: AX, CX, DX, BX, SP, BP, SI, DI.
  • pushad push các thanh ghi 32-bit vào stack theo thứ tự: EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI.

Ta thường gặp các lệnh này trong shellcode khi ai đó muốn lưu trạng thái hiện tại của tất cả các thanh ghi vào stack để có thể khôi phục về sau. Các trình biên dịch hiếm khi dùng các lệnh này nên chúng thường là dấu hiệu của đoạn mã hợp ngữ và/hoặc shellcode được viết thủ công của ai đó.

Các lệnh điều kiện

Mọi ngôn ngữ lập trình đều có thể tạo các phép so sánh và đưa ra quyết định dựa trên kết quả các phép so sánh đó. Lệnh điều kiện là các lệnh thực hiện phép so sánh.

Hai lệnh điều kiện phổ biến nhất là test cmp. Lệnh test cũng giống như lệnh and; tuy nhiên, các toán hạng liên quan không bị thay đổi sau khi thực hiện lệnh. Lệnh test chỉ tác động đến các cờ. Cờ zero (ZF) là cờ thường bị tác động đến nhất. Việc test một toán hạng với chính nó thường là cách để kiểm tra các giá trị NULL. Ví dụ, test eax, eax. Ta cũng có thể so sánh EAX với 0, nhưng test eax, eax sử dụng ít byte và ít chu kỳ CPU hơn.

Lệnh cmp thì tương tự lệnh sub; tuy nhiên, các toán hạng cũng không bị thay đổi sau khi thực hiện lệnh. Lệnh cmp được sử dụng chỉ để set các cờ, cụ thể là cờ ZF và CF. Giá trị các cờ ảnh hưởng bởi lệnh cmp được thể hiện trong bảng sau:

cmp dst, src ZF CF
dst = src 1 0
dst < src 0 1
dst > src 0 0

Rẽ nhánh

Một nhánh là một trình tự code được thực hiện theo điều kiện phụ thuộc vào luồng thực thi của chương trình. Khái niệm rẽ nhánh được dùng để mô tả luồng điều khiển qua các nhánh của chương trình.

Cách phổ biến nhất để rẽ nhánh là sử dụng các lệnh nhảy. Kiến trúc x86 cung cấp một tập lệnh nhảy đa dạng mà trong đó, jmp là lệnh đơn giản nhất. Cú pháp jmp location nhảy đến vị trí lệnh thực thi tiếp theo được chỉ định bằng toán hạng location. Lệnh này được coi là lệnh nhảy không điều kiện vì luồng thực thi luôn chuyển hướng đến location và cách nhảy này không thể thỏa mãn tất cả các yêu cầu rẽ nhánh. Ví dụ, logic tương đương với một lệnh if trong các ngôn ngữ bậc cao là không khả thi chỉ với một lệnh jmp. Hợp ngữ không có lệnh if nên ta cần các lệnh nhảy có điều kiện.

Các lệnh nhảy có điều kiện sử dụng các cờ để xác định sẽ nhảy hay thực thi lệnh tiếp theo. Có hơn 30 loại điều kiện nhảy khác nhau nhưng chỉ có một tập nhỏ trong số chúng là thường xuyên được sử dụng. Bảng sau thể hiện các lệnh nhảy có điều kiện phổ biến nhất và chi tiết về hoạt động của chúng.

Lệnh Mô tả
jz loc Nhảy đến vị trí loc nếu ZF = 1.
jnz loc Nhảy đến loc nếu ZF = 0.
je loc Giống jz, nhưng thường dùng sau một lệnh cmp. Thực hiện nhảy nếu toán hạng đích bằng với toán hạng nguồn.
jne loc Giống jnz, nhưng thường dùng sau một lệnh cmp. Thực hiện nhảy nếu toán hạng đích không bằng với toán hạng nguồn.
jg loc Nhảy sau một lệnh cmp so sánh có dấu nếu toán hạng đích lớn hơn toán hạng nguồn.
jge loc Nhảy sau một lệnh cmp so sánh có dấu nếu toán hạng đích lớn hơn hoặc bằng toán hạng nguồn.
ja loc Giống jg, nhưng so sánh không dùng dấu.
jae loc Giống jge, nhưng so sánh không dùng dấu.
jl loc Nhảy sau một lệnh so sánh có dấu nếu toán hạng đích nhỏ hơn toán hạng nguồn.
jle loc Nhảy sau một lệnh so sánh có dấu nếu toán hạng đích nhỏ hơn hoặc bằng toán hạng nguồn.
jb loc Giống jl, nhưng so sánh không dùng dấu.
jbe loc Giống jle, nhưng so sánh không dùng dấu.
jo loc Nhảy nếu lệnh trước đó set cờ Overflow (OF = 1).
js loc Nhảy nếu cờ dấu được set (SF = 1).
jecxz loc Nhảy tới vị trí loc nếu ECX = 0.

Các lệnh Rep

Các lệnh Rep là tập các lệnh dùng để quản lý các bộ đệm dữ liệu. Bộ đệm dữ liệu thường ở dạng một mảng các byte nhưng cũng có thể ở dạng word hoặc double word. Ta sẽ tập trung vào các mảng. (Intel gọi các lệnh này là lệnh chuỗi – string instructions, nhưng ta không dùng định nghĩa này để tránh xung đột với các chuỗi đã đề cập trong chương Phân tích tĩnh cơ bản.)

Các lệnh quản lý bộ đệm dữ liệu phổ biến nhất là movsx, cmpsx, stosx scasx, với x = b, w hoặc d lần lượt tương ứng với byte, word hoặc double word. Các lệnh này thao tác với bất kì kiểu dữ liệu nào nhưng ta sẽ tập trung vào kiểu byte, tức là nghiên cứu các lệnh movsb, cmpsb, stosb scasb.

Các thanh ghi ESI và EDI được dùng trong các lệnh này. ESI là thanh ghi chỉ số nguồn (source index register) và EDI là thanh ghi chỉ số đích (destination index register). ECX được dùng như một biến đếm.

Các lệnh này yêu cầu một tiền tố để thao tác trên dữ liệu có độ dài lớn hơn 1. Lệnh movsb chỉ chuyển một byte đơn và không dùng đến thanh ghi ECX.

Trong kiến trúc x86, các tiền tố lặp lại được dùng cho những thao tác multibyte. Lệnh rep tăng giá trị offset của ESI và EDI, đồng thời giảm giá trị của thanh ghi ECX. Tiền tố rep sẽ lặp cho đến khi ECX = 0. Các tiền tố repe/repz repne/repnz sẽ lặp cho tới khi ECX = 0 hoặc tới khi ZF = 1 hoặc 0, cụ thể được minh họa trong bảng phía dưới. Trong hầu hết các lệnh quản lý bộ đệm dữ liệu, ESI, EDI và ECX phải được chuẩn bị chính xác để lệnh rep thực thi đúng.

Lệnh Mô tả
rep Lặp tới khi ECX = 0
repe, repz Lặp tới khi ECX = 0 hoặc ZF = 0
repne, repnz Lặp tới khi ECX = 0 hoặc ZF = 1

Lệnh movsb được dùng để chuyển một chuỗi các byte từ một vị trí tới vị trí khác. Tiền tố rep thường được dùng với movsb để copy một chuỗi byte với độ dài của chuỗi được định nghĩa trong ECX. Lệnh rep movsb về mặt logic tương đương với hàm memcpy trong C. Lệnh movsb nhận giá trị một byte từ địa chỉ ESI và lưu nó lại trong địa chỉ EDI rồi tăng hoặc giảm giá trị thanh ghi ESI và EDI với 1 tùy theo cờ chỉ dẫn (direction flag – DF). Nếu DF = 0, hai thanh ghi được tăng 1; nếu không thì chúng phải giảm đi 1.

Trong code C được biên dịch thì ta hiếm thấy, nhưng trong shellcode, người ta thường đảo ngược giá trị cờ DF khiến ESI và EDI lưu trữ dữ liệu theo chỉ dẫn ngược. Nếu tồn tại tiền tố rep, ECX sẽ được kiểm tra để phát hiện nó có giá trị bằng 0 chưa. Nếu ECX khác 0, lệnh thực thi sẽ chuyển byte từ ESI sang EDI và giảm giá trị ECX. Tiến trình này được lặp lại cho tới khi ECX = 0.

Lệnh cmpsb được dùng để so sánh hai chuỗi byte nhằm phát hiện chúng có chứa cùng một giá trị dữ liệu hay không. Lệnh cmpsb trừ giá trị ở địa chỉ EDI với giá trị tại địa chỉ ESI rồi cập nhật các cờ. Nó thường dùng tiền tố repe và khi đó, cmpsb so sánh mỗi byte của hai chuỗi byte cho tới khi tìm ra sự khác nhau giữa hai chuỗi hoặc đạt tới điểm kết thúc so sánh. Lệnh cmpsb lấy giá trị của byte tại địa chỉ ESI, so sánh với giá trị của byte tại địa chỉ EDI để set các cờ, sau đó tăng giá trị ESI và EDI lên 1. Nếu có tiền tố repe, ECX và các cờ sẽ được kiểm tra, nhưng nếu ECX = 0 hoặc ZF = 0, lệnh sẽ ngừng lặp. Điều này tương đương với hàm memcmp trong C.

Lệnh scasb dùng để tìm kiếm một giá trị đơn trong một chuỗi các byte. Giá trị tìm kiếm được định nghĩa bằng thanh ghi AL. Lệnh này hoạt động tương tự lệnh cmpsb nhưng nó so sánh giá trị byte ở địa chỉ ESI với giá trị AL chứ không phải với giá trị byte tại địa chỉ EDI. Lặp repe sẽ tiếp tục cho tới khi tìm thấy byte đó hoặc ECX = 0. Nếu giá trị được tìm thấy trong chuỗi byte, ESI sẽ lưu địa chỉ của giá trị đó.

Lệnh stosb dùng để lưu giá trị tại một địa chỉ được quy định trong EDI. Nó tương tự như lệnh scasb nhưng thay vì tìm kiếm, byte cần dùng đã được quy định địa chỉ trong EDI. Tiền tố rep được dùng với scasb để khởi tạo một bộ đệm trong bộ nhớ, nơi mà tất cả các byte cùng chứa một giá trị. Lệnh này tương đương hàm memset trong C. Bảng dưới thể hiện một số lệnh rep phổ biến và mô tả hoạt động của chúng.

Lệnh Mô tả
repe cmpsb Dùng để so sánh hai bộ đệm dữ liệu. EDI và ESI phải được set là địa chỉ của hai bộ đệm và ECX phải được set bằng với kích thước bộ đệm. Phép so sánh lặp tới khi ECX = 0 hoặc hai bộ đệm không bằng nhau.
rep stosb Dùng để set tất cả các byte trong bộ đệm với một giá trị cụ thể. EDI sẽ chứa địa chỉ của bộ đệm, AL phải chứa giá trị để truyền vào các byte. Lệnh này thường dùng với xor eax, eax.
rep movsb Thường dùng để copy các byte từ một bộ đệm. ESI phải được set bằng địa chỉ bộ đệm nguồn, EDI phải có giá trị bằng địa chỉ bộ đệm đích và ECX phải chứa kích thước cần copy. Copy từng byte một tới khi ECX = 0.
repne scasb Dùng để tìm kiếm một bộ đệm dữ liệu hoặc một byte đơn. EDI phải chứa địa chỉ của bộ đệm, AL phải chứa giá trị byte mà ta cần tìm kiếm và ECX phải có giá trị bằng kích thước bộ đệm. Phép so sánh lặp lại cho tới khi ECX = 0 hoặc byte được tìm thấy.

Phương thức main trong C và các Offset

Vì mã độc thường được viết bằng C nên việc hiểu cách phương thức main của một chương trình C được biên dịch sang hợp ngữ là rất quan trọng. Kiến thức này cũng giúp ta hiểu được sự khác nhau của các offset trong code C và trong hợp ngữ.

Một chương trình C chuẩn có hai tham số cho phương thức main, thường ở dạng:

—————————————————————————————————————————-

int main (int argc, char ** argv)

—————————————————————————————————————————-

Tham số argc argv được xác định lúc chạy chương trình. Tham số argc là một số nguyên biểu diễn tổng số tham số dòng lệnh, bao gồm cả tên chương trình. argv là một con trỏ trỏ tới một mảng string chứa các tham số dòng lệnh. Ví dụ dưới đây biểu diễn một chương trình dòng lệnh cùng giá trị của argc argv khi chương trình được chạy.

—————————————————————————————————————————-

filetestprogram.exe -r filename.mvs

argc  =3

argv[0] = filetestprogram.exe

argv[1] = -r

argv[2] = filename.mvs

—————————————————————————————————————————-

Dưới đây là code C của chương trình.

—————————————————————————————————————————-

int main( int argc, char* argv[] )

{

if ( argc != 3 ) {

return 0;

}

if ( strncmp(argv[1], “-r”, 2) == 0 ) {

DeleteFileA( argv[2] );

}

return 0;

}

—————————————————————————————————————————-

Hình dưới là code hợp ngữ được biên dịch từ đoạn code C trên. Ví dụ này giúp ta hiểu cách các tham số  được truy cập trong code hợp ngữ. Tham số argc được so sánh với 3 trong lệnh (1) argv[1] được so sánh với -r trong lệnh (2) bằng cách dùng strncmp. Chú ý cách mà argc[1] được truy cập: Địa chỉ đầu tiên bắt đầu mảng được nạp vào EAX, sau đó EAX được thêm 4 (4 chính là offset) để lấy giá trị argv[1]. Sử dụng 4 để thêm vào EAX vì mỗi điểm vào (entry) trong mảng argv là một địa chỉ tới một chuỗi string, và mỗi địa chỉ này có kích thước 4 byte trong hệ thống 32-bit. Nếu -r được nhập vào từ giao diện dòng lệnh, đoạn mã bắt đầu tại (3) sẽ được thực thi. Khi đó ta sẽ thấy argv[2] được truy cập ở offset 8 của argv và được coi như là tham số đầu vào cho hàm DeleteFileA.

Xem thêm nhiều bài viết của Securitybox TẠI ĐÂY

0