Giới thiệu về Reverse Engineering.

MỞ ĐẦU

Để bắt đầu tiếp cận RCE, bạn cần có kiến thức, kinh nghiệm (ở mức độ tương đối trở lên) về ASM, C, càng nhiều ngôn ngữ càng tốt.
Nếu bạn vẫn chưa có kinh nghiệm về các mảng trên, tất nhiên bạn vẫn có thể tiếp cận được, ít nhất là với các bài lý thuyết mở đầu ở đây, nhưng trước khi lao vào thực hành, bạn nên quay lại, dành khoảng 0,5 - 1 tháng, code và debug, ngâm cứu tầm 10 chương trình (với C, ASM...).

Những loạt bài này, không đi lần lượt và bao quát hết về những cơ bản của RCE. Nói đơn giản là tôi chỉ đi qua những phần khó hiểu nhất, quan trọng nhất, diễn giải nó để các bạn hiểu thêm, và tôi cũng có cơ hội ghi chép để nhớ lâu hơn. Bạn hãy tìm các tài liệu để đọc về RCE, chỗ nào khó hiểu, hoặc quan trọng, nghía qua đây 1 chút và tôi sẽ thảo luận cùng bạn.

1. Một số tài liệu cơ bản:
https://www.ethicalhacker.net/columns/heffner/intro-to-assembly-and-reverse-engineering
- Nghệ thuật tận dụng lỗi phần mềm - Nguyễn Thành Nam

2. Những thuật ngữ căn bản:
- Compiler/ Decompiler, Asembly/Deasembly:

Đây là những thuật ngữ dễ nhầm. Chúng ta đều biết Ngôn ngữ máy (ML) là cấp thấp nhất (thế hệ 1), tiếp đến là Hợp ngữ (ASM - thế hệ 2), sau đấy là các ngôn ngữ cấp cao hơn, như C... Chúng ta phân biệt như thế này:




KIẾN TRÚC MÁY TÍNH CƠ BẢN

1. Các thanh ghi (Register):

Có 5 nhóm Thanh ghi:
- Nhóm tính toán: EAX, EBX, ECX, EDX - dùng cho công việc tính toán, đặt các biến tạm, lưu giữ các tham số.
- Nhóm xử lý chuỗi: EDI, ESI - dùng xử lý, sao chép chuỗi,...
- Nhóm thanh ghi ngăn xếp (stack): EBP, ESP - quản lý, định vị trong ngăn xếp
- Nhóm đặt biệt: EIP, EFLAGS - làm các nhiệm vụ đặt biệt
- Nhóm phân vùng: không còn sử dụng, thôi đừng quan tâm

Chữ "E" nghĩa là Extended - dùng cho hệ máy 32 bit.
Chứ "P" nghĩa là Pointer - con trỏ
ESP - con trỏ ngăn xếp, chỉ định đỉnh của ngăn xếp
EBP - con trỏ định vị, tham chiếu tới biến cục bộ và tham số của các hàm đang nạp vào
EIP - con trỏ lệnh, trỏ đến ô nhớ lệnh tiếp theo mà CPU sẽ thực hiện
EFLAGS - các cờ, như sign flag, carry flag, zero flag
Khá là khó hiểu, đề nghị đọc những những phần tiếp theo để hiểu.

Đôi khi, chúng ta chỉ cần dùng 16 bit trong số 32 bit của các thanh ghi, lúc đó ta bỏ chữ "E" đi. Ví dụ, ta chỉ định AX thay vì EAX. Thậm chí nếu chỉ dùng 8 bit đầu, ta chỉ định AL, nếu là 8 bit cuối là AH.

2. Bộ nhớ và định địa chỉ bộ nhớ:

Bộ nhớ chính của chúng ta là RAM, ngoài ra có thêm phần swap partition nữa.
Trong cấu trúc 32 bit, cách định địa chỉ là địa chỉ tuyến tính, nghĩa là có 1 con số hexa 32 bit đại diện cho 1 ô nhớ, đầu tiên là 00000000, cuối cùng là FFFFFF (thay vì cách cũ dùng 2 thanh ghi định vị chiều ngang, dọc).

Vì địa chỉ tối đa 32 bit (trên thanh ghi) nên Bộ nhớ khả dụng (RAM + swap) là khoảng 3 GB.

3. Ngăn xếp (stack):

Chúng ta xem mô hình cấu trúc vùng nhớ trên CPU như sau:

High Memory Addresses (0xFFFFFFFF)

———————- <—–Bottom of the stack

|                     |

|                     |   |

|         Stack       |   | Stack grows down

|                     |   v

|                     |

|———————| <—-Top of the stack (ESP points here)

|                     |

|                     |

|                     |

|                     |

|                     |

|———————|  <—-Top of the heap

|                     |

|                     |    ^

|       Heap          |    | Heap grows up

|                     |    |

|                     |

|———————| <—–Bottom of the heap

|                     |

|    Instructions     |

|                     |

|                     |

———————–

Low Memory Addresses (0×00000000)

Chúng ta thấy có 3 vùng nhớ:
1. Stack: Lưu trữ đối số và biến cục bộ của hàm
2. Heap: Lưu trữ biến static và dynamic
3. Instructions: Lưu trữ các chỉ lệnh chương trình

Chúng ta quan tâm và chỉ nói tới Stack. Rất độc đáo, nó bắt đầu ở phía trên và đi xuống phía dưới. Đỉnh stack có địa chỉ thấp hơn đáy.

TẬP LỆNH, MÃ MÁY, HỢP NGỮ

Tập lệnh (instruction set) là tập hợp các lệnh đọc trực tiếp để CPU thực hiện. Là những viên gạch nhỏ mà người thợ xây sẽ lắp ghép để ra từng ngôi nhà là các chương trình riêng.

Mã máy là các mã đại diện cho các lệnh, ví dụ 90 - Không làm gì cả.
Đôi khi có những tập lệnh dài tới 9 byte, tối đa là 15 byte, nhưng thông thường chỉ 1,2 byte.

Hợp ngữ: Để dễ nhớ, cứ mỗi một (hoặc đôi khi 1 vài) mã máy ta tạo ra 1 chuỗi ký tự tương ứng để dễ nhớ. Ví dụ, thay vì mã 90, ta có NOP (NO Operation), hay 31 C0 ta có XOR EAX, EAX.

1. Một số nhóm lệnh:

Nhóm lệnh gán: LEA, MOV, SETZ
Nhóm lệnh tính toán: INC, DEC, ADD, SUB, MUL, DIV
Nhóm lệnh logic: AND, OR, XOR, NEG
Nhóm lệnh so sánh: TEST, CMP
Nhóm lệnh nhảy: JNZ, JZ, JA, JB
Nhóm lệnh ngăn xếp: PUSH, POP, PUSHA, POPA
Nhóm lệnh hàm: CALL, RET

2. Ngăn xếp:

Một nguyên tắc rất nổi tiếng của Stack là Vào sau Ra trước.
Khi PUSH dữ liệu vào Stack:
- ESP sẽ giảm đi 4
- 4 byte dữ liệu chuyển vào stack tại địa chỉ ESP
Khi POP dữ liệu ra Stack:
- 4 byte tại ESP của stack được chuyển ra
- ESP tăng lên 4

TRÌNH TỰ XỬ LÝ HÀM TRÊN STACK VÀ THANH GHI

Chúng ta xem xét hàm sau:

int FunctionA (int agA, agB, agC)
{
    int vA;
    int FunctionB (int ag1, int ag2, int ag3)
    {
        char v1[5];
        int v2;
        short v3;
        return 0;
    }
    vB = 0;
    return 0;
}

Chúng ta xét Quá trình nạp vào FunctionB (FunctionA gọi FunctionB):

Bước 1: Các đối số của hàm được đưa vào Stack theo thứ tự ngược.

PUSH ag3
PUSH ag2
PUSH ag1

Bước 2: Giá trị Return value được đưa vào Stack.

Return Address có vai trò lưu giữ giúp thanh ghi EIP vị trí lệnh mà CPU sẽ thực hiện sau khi xử lý xong Function đang được gọi. Trong trường hợp trên, vị trí lệnh tại "vB = 0" sẽ được lưu tại Return Value để sau khi xử lý xong FunctionB, sẽ POP ra và gắn vào EIP để chỉ định CPU thực hiện tiếp công việc.

Sở dĩ cần Return Address lưu giữ vị trí lệnh kế tiếp cho EIP vì EIP luôn chỉ tới địa chỉ lệnh kế tiếp chỉ dẫn cho CPU (tức là trường hợp này, EIP sẽ nhảy loanh quanh trong FunctionB để chỉ cho CPU xử lý hàm này), tuy nhiên, khi xong 1 hàm, EIP không có cách nào để trở về vị trí trước đó (trước FunctionB) và vị trí kế tiếp để thực hiện.

(Chúng ta chú ý địa chỉ bộ nhớ FunctionA và FunctionB rất khác nhau chứ không phải liên tiếp như trên mã nguồn).

Bước 3: Gọi hàm

CALL FunctionB

———————– <—–Bottom of the stack (top of memory)

|    ag3             |
|
|--------------------|
|
|    ag2             |
||--------------------|
|
|    ag1             |
||--------------------|
|
|    Return Address  |
||--------------------|
|
|    Saved EBP Value |
||--------------------| <—-EBP Points here
|
|    v1              |
||--------------------|
|
|    v2              |
||--------------------|
|
|    v3              |
||--------------------| <—-ESP (top of the stack,      low memory addresses)

Đầu tiên chúng ta tìm hiểu 1 chút về EBP.
Đây là thanh ghi có vai trò làm mốc để tham chiểu địa chỉ tới các đối số và biến trên stack. Ví dụ, để gọi đối số ag2, ta có: EBP + 5 chẳng hạn. Chúng ta không dùng ESP vì ESP luôn thay đổi qua các thao tác PUSH, POP.

Nhưng EBP, cũng giống như EIP, vẫn cần 1 vùng nhớ để lưu giá trị cũ để sau khi thực hiện xong hàm, sẽ lấy lại giá trị đó để chạy tiếp. Lý giải cho việc này như sau:

Khi 1 hàm mới được gọi (FunctionB) thì các đối số của nó sẽ nạp vào stack, do kích cỡ các đối số này khác so với của hàm mẹ (FunctionA), nên vị trí EBP trên stack (ta gọi Saved EBP) lúc này thay đổi, dẫn tới giá trị trên thanh ghi EBP thay đổi (lát nữa chúng ta sẽ nói tới việc chuyển giá trị từ Saved EBP sang thanh ghi EBP), nó chỉ có giá trị tạm thời với hàm FunctionB.

Khi chuyển về hàm mẹ (FunctionA) với các đối số, biến số cũ, thanh ghi EBP cần lấy lại giá trị cũ để tham chiếu và chạy tiếp. (Cần chú ý mỗi lần xử lý 1 hàm thì sẽ đưa các đối số, biến số của hàm đó lên stack, như vậy Bức tranh về Stack của FunctionA hoàn toàn khác FunctionB).

Vậy thao tác lưu giữ EBP như thế nào?

Khi gọi 1 hàm, sẽ xảy ra 3 bước:

Đầu tiên là Prologue: với 3 dòng lệnh:

PUSH EBP
MOV EBP, ESP
SUB ESP, 0x16

Tới đây chúng ta sẽ đề cập tới chuyện làm sao lưu giữ giá trị cũ ở thanh ghi EBP, chuyển từ Saved EBP lên thanh ghi...

Đầu tiên, giá trị trên thanh ghi EBP sẽ được đưa vào Stack qua lệnh PUSH, người ta gọi EBP trên stack này là Saved EBP, và đúng như tên gọi, đây chính là giá trị cũ của thanh ghi EBP (của FunctionA).

Lúc này Saved EBP và đỉnh stack (ESP) có cùng 1 địa chỉ (data mới nhất được đưa vào stack). Chú ý điều này: giá trị tại Saved EBP là giá trị cũ của thanh ghi EBP (FunctionA), nhưng địa chỉ của Saved EBP phải là giá trị mới của thanh ghi EBP để phục vụ thực thi FunctionB lúc này.

(Tới đây chúng ta phải cần thận giữa GIÁ TRỊ và ĐỊA CHỈ/ VỊ TRÍ của 1 vùng nhớ).

Hàm MOV chuyển giá trị ESP, và cũng là địa chỉ Saved EBP lên thanh ghi EBP.

Sau đó, sẽ là màn đưa các biến cục bộ lên Stack theo đúng thứ tự: v1, v2, v3

Và do đó ESP sẽ giảm xuống tương ứng với tổng kích thước các biến này (hoặc nhiều hơn).

Với FunctionB, char v1[5] chiếm 8 byte (làm tròn bội số 4), int v2 chiếm 4 byte, short v3 chiếm 4 byte, tổng cộng 16 byte.

Như vậy là xong giai đoạn Prologue. Thực ra, hình ảnh stack mà bạn thấy trên chỉ là 1 phần so với thực tế. Mỗi 1 hàm đều có cho mình 1 hình ảnh tương tự (phần từ đối số, cho đến hết biến số trên stack) gọi là frame. Bình thường các hàm sẽ tự xóa frame của mình trước khi trả về, nhưng nếu hàm này nằm trong hàm kia thì tất cả cùng tồn tại song song, kiểu như:

        ———————– <—–Bottom of the stack (top of memory)
        | functiona() Frame   |
        |———————|
        | functionb() Frame   |
        |———————|
        | functionc() Frame   |
        ———————– <—-Top of the stack (low memory addresses)

Bước thứ 2 là Thân hàm với các lệnh phục vụ việc xử lý trong hàm.

Các câu lệnh để thực thi hàm, trong đó EAX là thanh ghi nhận giá trị trả về của hàm. Đặt biệt với main(), ngay sau Prologue là việc clear sạch ESP (xem ví dụ).

Cuối cùng là kết thúc

MOV ESP, EBP
POP EBP
RET

Khá dễ hiểu, đầu tiên là đưa giá trị trên EBP (là địa chỉ Saved EBP hiện tại) vào ESP.
Sau đấy lấy ra giá trị tại Saved EBP đưa vào EBP, như vậy, EBP đã lấy lại giá trị cũ. Cuối cùng là RET (về Return Address đã nói tại Bước 2


VÍ DỤ

Giả sử ta có chương trình đầu tiên:

int main(int argc, char *argv[])
{
printf("Hello World!n");
return 0;
}

Tiến hành dịch ngược, ta được:

0×08048384 <main+0>: push %ebp   ; Prologue

0×08048385 <main+1>: mov 
%ebp,%esp   ; Prologue

0×08048387 <main+3>: sub %esp,
$0×8   ; Prologue

0x0804838a <main+6>: and %esp,
$0xfffffff0   ; Clear bit cuối của ESP

0x0804838d <main+9>: mov 
%eax,$0×0   ; Gán 0 vào EAX

0×08048392 <main+14>: sub %esp,
%eax   ; Subtract EAX - ESP

0×08048394 <main+16>: 
mov DWORD PTR [esp],0x80484c4  ; Đưa chuỗi tại ô nhớ 0x80484c4 vào stack

0x0804839b <main+23>: call 0x80482b0 <_init+56>  ; Gọi hàm printf tại 
0x80482b0

0x080483a0 <main+28>: mov %eax,
$0×0  ; Return 0

0x080483a5 <main+33>: leave  ; Xóa biến cục bộ và phục hổi EBP

0x080483a6 <main+34>: ret  ; Phục hồi EIP từ Return Address

Khi bạn dịch ngược, sẽ có những lệnh khác xen vào do từng compiler quy định, tuy nhiên về cơ bản là vậy.

Một ví dụ khác, ngược lại, lần này ta sẽ mò từ Asembly dịch thành source code:

0×08048384 <myprint+0>: push %ebp
0×08048385 <myprint+1>: mov %esp,%ebp
0×08048387 <myprint+3>: sub $0×8,%esp
0x0804838a <myprint+6>: cmpl $0×1,0x804961c
0×08048391 <myprint+13>:jne 0x80483a1 <myprint+29>
0×08048393 <myprint+15>:movl $0x80484f4,(%esp)
0x0804839a <myprint+22>:call 0x80482b0 <_init+56>
0x0804839f <myprint+27>:jmp 0x80483ad <myprint+41>
0x080483a1 <myprint+29>:movl $0×8048502,(%esp)
0x080483a8 <myprint+36>:call 0x80482b0 <_init+56>
0x080483ad <myprint+41>:leave
0x080483ae <myprint+42>:ret

(Lưu ý, các mã lệnh này tôi copy từ máy AT&T nên cấu trúc lệnh ngược lại Lệnh Nguồn, Đích so với Lệnh Đích, nguồn của Intel, và đây chỉ là 1 hàm trong chương trình).

- 3 lệnh đầu là prologue
<myprint+6>: so sánh giá trị/ biến tại ô nhớ 0x804961c và 1 (kiểm tra tại 0x804961c thì đây là biến var1)
<myprint+13>: Jump nếu non-equal (không bằng nhau) nghĩa là var1 khác 1, tới 0x80483a1 <myprint+29>
- Nếu var1 = 1, bỏ qua <myprint+13> thực hiện tiếp <myprint+15>, đưa giá trị tại 0x80484f4 vào stack (đây là chuỗi "Equal")
<myprint+22>: Gọi hàm tại 0x80482b0 (hàm printf()) (chứng tỏ hàm này chỉ có 1 đối số tại 0x80484f4)
<myprint+27>: Jump đến <myprint+41> - phần kết thúc
<myprint+29>: đưa giá trị tại 0×8048502 vào stack (đây là chuỗi "Not equal")
<myprint+36>: Gọi hàm tại 0x80482b0
<myprint+41> <myprint+42>: Kết thúc

Vậy, ta có cấu trúc hàm này như sau:

void myprint()
{
    if(var1 == 1){
        printf("Equal");
    } else {
        printf("Not equal");
    }
}

Nhận xét