Chương 19: Phân Tích Shellcode¤
1. Tổng Quan¤
Shellcode là một payload gồm mã thực thi thô (raw executable code). Tên gọi xuất phát từ việc kẻ tấn công thường dùng nó để chiếm quyền truy cập shell tương tác trên hệ thống bị xâm phạm. Hiện nay, thuật ngữ này mở rộng để chỉ bất kỳ đoạn mã thực thi độc lập nào.
Shellcode thường được dùng: - Kết hợp với exploit để chiếm quyền điều khiển tiến trình đang chạy - Bởi malware để thực hiện process injection
2. Đặc Điểm Khác Biệt Của Shellcode¤
Shellcode không thể dựa vào Windows Loader như chương trình bình thường. Cụ thể, nó phải tự xử lý các tác vụ sau mà loader thường làm:
- Đặt chương trình vào vị trí bộ nhớ ưa thích
- Áp dụng address relocation nếu không load được ở vị trí mong muốn
- Load các thư viện cần thiết và giải quyết các external dependencies
3. Position-Independent Code (PIC)¤
Định nghĩa¤
Position-Independent Code (PIC) là mã không sử dụng bất kỳ địa chỉ cứng (hard-coded address) nào cho code lẫn data. Shellcode bắt buộc phải là PIC vì khi runtime, các phiên bản khác nhau của chương trình dễ bị tấn công có thể load shellcode vào các vị trí bộ nhớ khác nhau.
So Sánh Các Loại Truy Cập x86¤
| Instruction | Bytes | Position-Independent? |
|---|---|---|
call sub_401000 |
E8 C1 FF FF FF |
✅ Có |
jnz short loc_401044 |
75 0E |
✅ Có |
mov edx, dword_407030 |
8B 15 30 70 40 00 |
❌ Không |
mov eax, [ebp-4] |
8B 45 FC |
✅ Có |
Giải thích
callvàjnz: Sử dụng offset tương đối so với EIP → PICmov edx, dword_407030: Chứa địa chỉ tuyệt đối0x00407030→ Không phải PIC, shellcode phải tránh dạng nàymov eax, [ebp-4]: Truy cập qua register base + offset tương đối → PIC
4. Xác Định Vị Trí Thực Thi (Identifying Execution Location)¤
Shellcode cần biết mình đang ở đâu trong bộ nhớ để truy cập data theo kiểu PIC. Trên x86, không thể đọc EIP trực tiếp (không có lệnh mov eax, eip). Có hai kỹ thuật phổ biến:
4.1 Kỹ Thuật call/pop¤
Nguyên lý: Khi lệnh call được thực thi, CPU push địa chỉ của lệnh ngay sau call lên stack. Shellcode lợi dụng điều này bằng cách đặt pop ngay đầu hàm được gọi để lấy địa chỉ đó vào register.
83 EC 20 sub esp, 20h
31 D2 xor edx, edx
E8 0D 00 00 00 call sub_17 ; push địa chỉ của chuỗi "Hello World!" lên stack
; Dữ liệu nhúng trong code (data embedded in code)
48 65 6C 6C 6F db 'Hello World!',0
20 57 6F 72 6C
64 21 00
sub_17:
5F pop edi ; edi = con trỏ trỏ tới chuỗi "Hello World!"
52 push edx ; uType: MB_OK
57 push edi ; lpCaption
57 push edi ; lpText
52 push edx ; hWnd: NULL
B8 EA 07 45 7E mov eax, 7E4507EAh ; MessageBoxA (hard-coded)
FF D0 call eax
52 push edx ; uExitCode
B8 FA CA 81 7C mov eax, 7C81CAFAh ; ExitProcess (hard-coded)
FF D0 call eax
Lưu ý quan trọng
Kỹ thuật trộn lẫn code và data (intermingling code and data) rất dễ làm disassembler bị nhầm lẫn, tự diễn giải vùng data sau call là code, dẫn đến kết quả disassembly vô nghĩa hoặc bị dừng hoàn toàn. Đây cũng là kỹ thuật anti-reverse-engineering.
4.2 Kỹ Thuật fnstenv¤
Nguyên lý: FPU (x87 Floating-Point Unit) lưu trữ con trỏ đến lệnh FPU cuối cùng đã thực thi trong cấu trúc FpuSaveState tại offset 12. Lệnh fnstenv lưu trạng thái FPU ra bộ nhớ, cho phép shellcode đọc giá trị fpu_instruction_pointer.
// Cấu trúc FpuSaveState (28 bytes)
struct FpuSaveState {
uint32_t control_word;
uint32_t status_word;
uint32_t tag_word;
uint32_t fpu_instruction_pointer; // offset 12 (0x0C) — đây là thứ cần lấy
uint16_t fpu_instruction_selector;
uint16_t fpu_opcode;
uint32_t fpu_operand_pointer;
uint16_t fpu_operand_selector;
uint16_t reserved;
};
D9 EE fldz ; push 0.0 lên FPU stack, cập nhật fpu_instruction_pointer
D9 74 24 F4 fnstenv byte ptr [esp-0Ch] ; lưu FpuSaveState vào stack
5B pop ebx ; ebx = địa chỉ của lệnh fldz
8D 7B F3 lea edi, [ebx-0Dh] ; edi = con trỏ tới chuỗi "Hello World!"
So sánh hai kỹ thuật
| Kỹ thuật | Ưu điểm | Nhược điểm |
|---|---|---|
| call/pop | Đơn giản, phổ biến | Dễ bị disassembler nhầm lẫn |
| fnstenv | Ít gây nhầm lẫn hơn | Phức tạp hơn một chút |
5. Manual Symbol Resolution (Tự Giải Quyết Symbol)¤
Vì shellcode không có Windows Loader hỗ trợ, nó phải tự tìm địa chỉ các hàm API. Hard-coded address là cách fragile (chỉ hoạt động trên đúng phiên bản OS/SP). Cách đúng đắn là dùng LoadLibraryA và GetProcAddress — cả hai đều export từ kernel32.dll.
flowchart TD
A[Shellcode cần gọi API] --> B[Tìm kernel32.dll trong bộ nhớ]
B --> C[Parse PE Export Table của kernel32.dll]
C --> D[Tìm LoadLibraryA và GetProcAddress]
D --> E[Dùng LoadLibraryA để load thư viện bất kỳ]
E --> F[Dùng GetProcAddress hoặc PE parsing để tìm hàm cần]
F --> G[Gọi hàm API]
5.1 Tìm kernel32.dll Trong Bộ Nhớ¤
Shellcode đi theo chuỗi các undocumented Windows structures:
flowchart LR
A["FS segment register"] --> B["TEB\n(offset 0x30 → PEB)"]
B --> C["PEB\n(offset 0x0C → PEB_LDR_DATA)"]
C --> D["PEB_LDR_DATA\n(offset 0x1C → InInitializationOrderLinks)"]
D --> E["ntdll.dll\nLDR_DATA_TABLE_ENTRY"]
E --> F["kernel32.dll\nLDR_DATA_TABLE_ENTRY\n(offset 0x18 → DllBase)"]
findKernel32Base:
push esi
xor eax, eax
mov eax, [fs:eax+0x30] ; eax = con trỏ tới PEB (từ TEB qua FS register)
test eax, eax
js .kernel32_9x ; nếu bit cao nhất set = Win9x (không hỗ trợ)
mov eax, [eax + 0x0c] ; eax = con trỏ tới PEB_LDR_DATA
mov esi, [eax + 0x1c] ; esi = Flink của InInitializationOrderLinks (entry đầu = ntdll)
lodsd ; eax = Flink tiếp theo (entry 2 = kernel32, trên Vista trở về trước)
mov eax, [eax + 8] ; eax = DllBase của kernel32.dll
jmp near .finished
.kernel32_9x:
jmp near .kernel32_9x ; Win9x: vòng lặp vô tận (không hỗ trợ)
.finished:
pop esi
ret
Lưu ý về Windows 7+
Từ Windows 7 trở đi, kernel32.dll không còn là module thứ hai được khởi tạo nữa. Shellcode portable cần kiểm tra trường UNICODE_STRING FullDllName để xác nhận đúng là kernel32.dll thay vì chỉ lấy entry thứ hai.
5.2 Parse PE Export Table¤
Sau khi có DllBase của kernel32.dll, shellcode parse cấu trúc PE để tìm hàm cần:
flowchart TD
A["DllBase"] --> B["PE Signature offset\n[DllBase + 0x3C]"]
B --> C["IMAGE_OPTIONAL_HEADER\n→ IMAGE_DATA_DIRECTORY"]
C --> D["IMAGE_EXPORT_DIRECTORY"]
D --> E["AddressOfNames\n(mảng RVA tới tên hàm)"]
D --> F["AddressOfNameOrdinals\n(mảng 16-bit ordinal)"]
D --> G["AddressOfFunctions\n(mảng RVA tới hàm)"]
E --> H["Tìm iName (index khớp với tên hàm)"]
H --> I["Dùng iName → iOrdinal\nqua AddressOfNameOrdinals"]
I --> J["Dùng iOrdinal → RVA hàm\nqua AddressOfFunctions"]
Thuật toán tìm địa chỉ export:
- Duyệt
AddressOfNames, so sánh từng chuỗi với tên hàm cần tìm → tìm được indexiName - Dùng
iNameđể index vàoAddressOfNameOrdinals→ lấyiOrdinal - Dùng
iOrdinalđể index vàoAddressOfFunctions→ lấy RVA của hàm - Cộng
DllBasevào RVA → địa chỉ thực tế
6. Hashed Exported Names (Tên Hàm Được Hash)¤
Vấn đề¤
Nếu shellcode lưu tên hàm dạng ASCII ("MessageBoxA", "LoadLibraryA"...) thì sẽ tốn không gian và dễ bị phân tích.
Giải pháp: Hash 32-bit xoay phải (ror-13-additive)¤
hashString:
push esi
push edi
mov esi, dword [esp+0x0c] ; load con trỏ chuỗi
.calc_hash:
xor edi, edi ; hash = 0
cld
.hash_iter:
xor eax, eax
lodsb ; load byte tiếp theo của chuỗi
cmp al, ah
je .hash_done ; nếu NULL byte → kết thúc
ror edi, 0x0d ; xoay phải hash 13 bit
add edi, eax ; cộng byte hiện tại vào hash
jmp near .hash_iter
.hash_done:
mov eax, edi ; trả về hash trong EAX
pop edi
pop esi
retn 4
Ví dụ hash values
| Hàm | Hash (ror-13) |
|---|---|
LoadLibraryA |
0xEC0E4E8E |
MessageBoxA |
0xBC4DA2A8 |
ExitProcess |
0x73E2D87E |
Lợi ích kép
- Tiết kiệm không gian (4 bytes thay vì cả chuỗi ASCII)
- Khó reverse-engineer hơn vì che giấu tên API khỏi việc kiểm tra thông thường
6.1 Triển Khai findSymbolByHash¤
findSymbolByHash:
pushad
mov ebp, [esp + 0x24] ; arg1: dllBase
mov eax, [ebp + 0x3c] ; offset tới PE signature
mov edx, [ebp + eax + 4+20+96] ; DataDirectories (giả định PE32)
add edx, ebp ; edx = địa chỉ IMAGE_EXPORT_DIRECTORY
mov ecx, [edx + 0x18] ; ecx = NumberOfNames
mov ebx, [edx + 0x20] ; ebx = RVA của AddressOfNames
add ebx, ebp ; RVA → VA
.search_loop:
jecxz .error_done ; hết mảng → lỗi
dec ecx
mov esi, [ebx+ecx*4] ; esi = RVA của tên tiếp theo
add esi, ebp ; RVA → VA
push esi
call hashString ; tính hash của tên hiện tại
cmp eax, [esp + 0x28] ; so sánh với arg2: symHash
jnz .search_loop
; Tìm được! Lấy ordinal
mov ebx, [edx+0x24]
add ebx, ebp
mov cx, [ebx+ecx*2] ; cx = ordinal
mov ebx, [edx+0x1c]
add ebx, ebp
mov eax, [ebx+ecx*4] ; eax = RVA của hàm
add eax, ebp ; RVA → VA
jmp near .done
.error_done:
xor eax, eax
.done:
mov [esp + 0x1c], eax ; ghi đè EAX đã lưu bởi pushad
popad
retn 8
7. Shellcode Encodings (Mã Hóa Shellcode)¤
Tại Sao Cần Mã Hóa?¤
Shellcode phải "qua mặt" bộ lọc của chương trình bị khai thác. Ví dụ:
- Hàm
strcpy/strcatdừng khi gặp byte0x00→ shellcode không được chứa NULL byte ở giữa - Một số chương trình chỉ chấp nhận ký tự printable ASCII (< 0x80) hoặc alphanumeric
Kỹ Thuật Phổ Biến¤
flowchart LR
A["Payload gốc\n(có thể chứa NULL bytes)"] --> B["Encoder\n(tại compile time)"]
B --> C["Encoded Payload\n(pass filter)"]
D["Decoder\n(viết cẩn thận để pass filter)"] --> E["Decode at runtime"]
E --> F["Payload gốc\n(thực thi được)"]
C --> E
Các kỹ thuật mã hóa thông dụng:
- XOR mask: XOR toàn bộ payload với một byte cố định. Vì
(a XOR b) XOR b == anên decoder chỉ cần XOR lại là ra payload gốc. - Alphabetic transform: Tách mỗi byte thành 2 nibble 4-bit, cộng với ký tự ASCII printable (như
Ahoặca).
Lợi ích phụ của encoding
- Che giấu các chuỗi đọc được (URL, địa chỉ IP...)
- Giúp né tránh các hệ thống phát hiện xâm nhập mạng (Network IDS)
8. NOP Sled¤
NOP Sled (hay NOP slide) là một chuỗi dài các lệnh NOP (0x90) đặt trước shellcode trong exploit.
Mục đích: Tăng xác suất exploit thành công. Miễn là execution được redirect vào đâu đó trong NOP sled, cuối cùng shellcode vẫn chạy.
Biến thể tránh phát hiện
Các opcode phổ biến thay thế NOP thường nằm trong range 0x40–0x4F (các lệnh increment/decrement register 1 byte). NOP sled phải pass cùng bộ lọc với phần còn lại của shellcode.
9. Tìm Shellcode Để Phân Tích¤
Các Nguồn Shellcode¤
- Network traffic
- Web pages (thường dùng JavaScript
unescape()) - Media files
- Malware (process injection)
Shellcode Trong JavaScript (Web Exploits)¤
Hàm unescape() decode chuỗi theo quy tắc:
%uXXYY→ byte sequenceYY XX(little-endian trên x86)%41%42%43→ bytes41 42 43
Ví dụ:
Input: %u1122%u3344%u5566%u7788%u99aa%ubbcc%uddee
Output: 22 11 44 33 66 55 88 77 aa 99 cc bb ee dd
Shellcode Trong Malware (Process Injection)¤
Nhận biết qua các API call đặc trưng:
Nếu malware launch remote thread mà không áp dụng relocation fix-ups hay resolve external dependencies → buffer được inject rất có thể là shellcode.
Tìm Shellcode Trong File Nhị Phân (IDA Pro)¤
Tìm kiếm các opcode đặc trưng thường xuất hiện ở đầu shellcode:
| Loại lệnh | Opcode phổ biến |
|---|---|
| Call | 0xE8 |
| Unconditional jumps | 0xEB, 0xE9 |
| Loops | 0xE0, 0xE1, 0xE2 |
| Short conditional jumps | 0x70 – 0x7F |
Lưu ý khi phân tích
Payload thường được mã hóa, nên chỉ có phần decoder hiển thị dưới dạng code có thể đọc được lúc đầu. Hãy tìm và phân tích decoder trước, sau đó giải mã payload.
10. Tổng Kết¤
mindmap
root((Shellcode))
PIC - Position Independent Code
call/pop để lấy EIP
fnstenv để lấy EIP
Chỉ dùng offset tương đối
Manual Symbol Resolution
Tìm kernel32 qua TEB→PEB→LDR
Parse PE Export Table
Dùng LoadLibraryA + GetProcAddress
Obfuscation
Hash tên hàm ror-13
Encoding XOR / Alphabetic
NOP Sled
Phân tích
IDA Pro static analysis
OllyDbg + shellcode_launcher.exe
Tìm opcode đặc trưng
Điểm mấu chốt: Shellcode phải tự xử lý mọi thứ mà OS loader thường làm. Hiểu được các kỹ thuật PIC, manual symbol resolution, và encoding là nền tảng để phân tích bất kỳ shellcode nào trong thực tế.