Understanding Code
Author: _[kienmanowar]_
“Cuộc sống luôn đầy cám dỗ
Ngày ngày níu kéo con người
Làm sao thắng được chính mình…Làm sao…
Ngựa non lớn lên thường háu đá
Tuổi trẻ nào suy tính gì
Nên đi tới con đường bế tắc
Chìm sâu… trong bóng tối”
I. Intro :
Chào tất cả anh em REA, chúng ta lại gặp nhau trong bài viết này của tôi viết về “Understanding Code”. Đây là bài viết của tác giả Kwazy Webbit, đề
cập tới vấn đề đọc hiểu code của các chương trình được Disassembly bằng
W32Dasm, IDA v..v.. Tại sao tôi lại chọn nó là chủ đề cho bài viết thứ 3
này bởi vì một lý do hết sức đơn giản, có đọc hiểu code chúng ta mới
biết được chương trình đang làm gì, đoạn code mà chúng ta đang RE được
dùng vào mục đích gì, đề từ đó có những thay đổi chỉnh sửa sao cho hợp
lý để phục vụ mục đích của chúng ta. Hầu hết những anh em tôi quen biết
đến với RE, Cracking theo những cách thức khác nhau, có người muốn chỉ
trong một thời gian ngắn có thể Crack được một phần mềm mà không cần tìm
hiểu xem tại sao lại làm như thế, nhiều người chỉ thực hiện theo các
tut một cách dập khuôn, ăn sẵn để rồi cuối cùng vẫn luẩn quẩn với những
câu hỏi “Tại sao lại làm như thế ?” , “Tại sao tôi làm thế không được ?”
v…v… mà không hề có một chút đầu tư nghiên cứu tìm hiểu, nhưng ngược
lại có những người đi từ những viên gạch đầu tiên, từ từ từng bước một,
những viên gạch của quá trình lạo động tìm hiểu nghiêm túc, tốn nhiều mồ
hôi, nước mắt để rồi bù lại họ có được những kiến thức làm tôi kinh
ngạc.Có những người còn rất trẻ và những người lớn tuổi hơn tôi, có
người chuyên về IT và cũng có người không. Nhưng những người đó đã để
lại cho tôi lòng khâm phục bởi tinh thần làm việc nghiêm túc, lòng đam
mê, sự chia sẻ kiến thức tới cộng đồng không hề vụ lợi.
Việc
chúng ta tìm hiểu xem một đoạn binary biểu diễn ý nghĩa gì là một công
việc quan trọng.Các đoạn mã thực thi và dữ liệu được biểu diễn ở mức
thấp nhất đó là tập hợp của các bit 0 và 1.Bạn có thể cố gắng cho thực
thi một đoạn dữ liệu như một đoạn mã, nhưng hầu hết trong các trường hợp
việc làm này sẽ dẫn đến crash.Lấy ví dụ, việc cố gắng để sử dụng một
đoạn mã thực thi như là một Picture data cũng sẽ là không hợp lệ hoặc
ngược lại, nhưng đó chỉ là một sự ngẫu nhiên nào đó mà thôi. Đó là bởi
vì có một cấu trúc để biểu diễn chúng khiến cho chúng không chỉ đơn
thuần là binary mà còn bao hàm nhiều ý nghĩa khác. Để giúp ích cho bạn,
bạn cần phải tìm hiểu về cấu trúc này và từ đó sẽ diễn dịch được nó theo
đúng cách.
Để cụ thể hơn, tôi sẽ lấy một ví dụ, chẳng hạn tôi có 4 con số như sau :
112, 43, 149, 184
Như
các bạn thấy chúng có thể mang rất nhiều ý nghĩa. Nếu như tôi nói với
bạn rằng đây là ví dụ về một đường thẳng, và hãy tưởng tượng rằng đây là
một đường thẳng 2 chiều, bắt đầu tại tọa độ thứ nhất là (112, 43) và kết thúc tại tọa độ thứ hai là (149, 184).
Tuy nhiên nếu như có ai đó lại nói với bạn là đây chính là một hình
vuông, liệu bạn có tin không? Tôi thì tin liền khà khà bởi vì đơn giản
tôi nghĩ đây là một hình vuông với 4 các tọa độ của nó. Như các bạn
thấy, 4 con số trên có rất nhiều ý nghĩa đúng không, tất cả chúng đều
phụ thuộc vào sự suy diễn của bạn và tôi. Điều này sẽ dẫn đến nhiều vấn
đề, làm sao chúng ta có thể phán đoán hết được ý nghĩa của chúng, và làm
sao chúng ta chọn được một phương án đúng nhất trong đó? Máy tính sẽ
dùng cách thức nào để có thể hiểu được? Làm thế nào chúng ta biết được
điều gì đang thực sự xảy ra ? Trong bài viết này, tôi sẽ không đi vào
tìm hiểu ngữ nghĩa của data, bởi vì các cấu trúc dữ liệu là quá nhiều
(như các bạn đã thấy trong ví dụ ở trên). Mỗi một định dạng file sẽ có
một cấu trúc dữ liệu. Chương trình sử dụng các phần mở dụng của file
(.exe, .dll v..v..) như là một ám hiệu để biết cách cư xử với từng cấu
trúc.
Thay
vào đó, tôi và các bạn sẽ tập trung vào các đoạn code thực thi, đặc
biệt là các đoạn code cho x86 processor. Chúng ta sẽ bắt đầu từ binary
và kết thúc với ngôn ngữ C.
II. Binary to Hexadecimal
Như
tôi đã nói ở trên, mức biểu diễn thấp nhất của thông tin (trong một môi
trường máy tính) là binary. Các đoạn mã mà máy tính có thể hiểu được
được biểu diễn bằng những bit 0 và 1 dài vô tận.Điều này dẫn đến con
người khó có thể hiểu được những chuỗi 0 và 1 mà họ nhìn thấy thể hiện
cái gì, và điều gì sẽ xảy ra. Nếu như bạn có hứng thú trong việc tìm
hiểu nguyên lý hoạt động của các mạch trong CPU, tôi gợi ý bạn nên tìm
đọc các quyển sách điện tử.Còn đối với tôi, tôi không biết nhiều về
chúng để có thể giải thích cho bạn một cách chi tiết về nguyên lý hoạt
động (Mặc dù có một thời gian tôi đã từng làm việc với những bộ vi xử lý
đơn giản). Nhằm mục đích giảng giải, binary là một định dạng khó hiểu,
vì nếu số lượng các số binary là quá lớn sẽ khiến cho chúng ta khó khăn
trong việc quan sát. Đó chính là lý do tại sao các bạn thấy rằng thông
thường chúng ta không bao giờ chỉnh sửa bất cứ gì theo định dạng binary,
mà thay vào đó chúng được chuyển sang một định dạng dễ hiểu hơn mà tôi
và các bạn đều biết, đó chính là Hexadecimal.Nếu như các bạn thấy trong hệ binary chỉ có 2 số 0 và 1, hệ decimal thì có 10 số (0,1,2,3,4,5,6,7,8,9) còn hệ Hexa sẽ có 16 (0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F).
Bạn có thể sẽ rất ngạc nhiên là tại sao định dạng Hexa lại được chọn để
biểu diễn mà không phải là hệ Decimal đã quá thân thuộc ngay từ khi lọt
lòng. Câu trả lời hết sức đơn giản.Đó là bởi vì tất cả các số khi được
chuyển đổi vẫn nằm dưới định dang binary.Sử dụng 4bits thì tại một thời
điểm bạn có thể tạo ra chính xác 16 giá trị khác nhau từ 4 bits này, bắt
đầu từ 0 cho tới 15, theo hệ Hexa thì là từ 0 cho tới F. Điều này khiến
cho hệ thống xử lý một cách dễ dàng hơn đơn giản chỉ bằng thay thế 4
bits bằng một số trong hệ Hexa. Dưới đây là bảng minh họa quá trình
chuyển đổi giữa các hệ, giúp các bạn phần nào hiểu thêm về những gì mà
tôi đã nói :
Binary
|
Decimal
|
Hexadecimal
|
0000
|
0
|
0
|
0001
|
1
|
1
|
0010
|
2
|
2
|
0011
|
3
|
3
|
0100
|
4
|
4
|
0101
|
5
|
5
|
0110
|
6
|
6
|
0111
|
7
|
7
|
1000
|
8
|
8
|
1001
|
9
|
9
|
1010
|
10
|
A
|
1011
|
11
|
B
|
1100
|
12
|
C
|
1101
|
13
|
D
|
1110
|
14
|
E
|
1111
|
15
|
F
|
10000
|
16
|
10
|
Như
các bạn đã thấy trên bảng trên, tại giá trị Binary là 1000 thì giá trị ở
hệ Hexa là (10), đó là vì con số 1 đầu tiên có thể được biểu diễn thành
0001, trong khi số 0 thứ 2 được biêu diễn bằng (0000). Do đó chúng ta
có kết quả là 00010000, giá trị này rất phù hợp với hệ số Binary.
III. Hexadecimal to Assembly Code
Việc
sử dụng kí pháp hexadecimal giúp chúng ta viết các đoạn Binary code một
cách nhanh hơn, và cho phép chúng ta nhiều cái nhìn tổng quát hơn. Tuy
nhiên nó vẫn không có ý nghĩa nhiều lắm cho con người bởi vì thực chất
nó vẫn chỉ là những con số. Chúng ta hãy quan sát một ví dụ về đoạn code
Hexa dưới đây :
83EC20535657FF158C40400033DBA39881400053
Như
tôi đã nói, đây chỉ là một phương pháp biểu diễn nhanh, ngắn gọn cho
các số binary.Điều đó có nghĩa là nó không cho chúng ta thấy bất kì một ý
nghĩa gì cả, cũng như nhìn vào đó chúng ta chẳng hiểu nó định làm gì,
nhưng cái lợi của nó là ngắn gọn hơn so với việc biểu diễn dưới dạng
binary rất dài với toàn số 0 và 1. Đoạn Hexa ở trên bao gồm 40 kí tự,
trong khi đó nếu chúng ta biểu diễn ở dạng binary chúng ta sẽ có được
160 (40 * 4 bits)
Đoạn
mã ở trên không phải là một instruction lớn (thuật ngữ “instruction” ở
đây để cập tới đoạn bytes code thực sự). Trên một vài bộ vi xử lý thì
mỗi một instruction sẽ có một kích thước nhất định(ví dụ : 2 bytes) vì
vậy chúng ta có thể chia code thành các phần một cách dễ dàng theo kích
thước để có được các câu lệnh khác nhau (Giả sử rằng bạn sẽ có được một
điểm bắt đầu hợp lệ trong đoạn code). Bộ xử lý x86 ít phức tạp hơn nhiều
và có các kích thước instruction khác nhau. Bây giờ bạn có thể ngạc
nhiên làm thế nào chúng ta có thể luôn luôn tách được các instructions
theo cách này.Ý tưởng là như sau, chúng ta lấy byte đầu tiên, nhìn vào
giá trị của nó, và byte này sẽ cho bạn biết cách tiến hành như thế nào.
Một vài điều có thể xảy ra như sau :
_Nó có thể là một single byte instruction: ví dụ 90h là câu lệnh NOP (No Operation) và kích thước của nó chỉ là 1 byte.
_Có thể câu lệnh đó chưa được hoàn chỉnh: ví dụ Các lệnh (Instructions) mà được bắt đầu bằng 0Fh , chúng ta phải cần thêm các bytes vào sau để nó tạo thành một câu lệnh có nghĩa.
_Câu lệnh được định nghĩa bởi một byte độc lập, nhưng vẫn cần có tham số, ví dụ : 8Bh chuyển một thanh ghi vào trong một thanh ghi khác.Những byte mà theo sau 8Bh sẽ miêu tả nó được chuyển đến từ đâu và nó được chuyển đên đâu.
_Câu lệnh chưa hoàn chỉnh và cần thêm các tham số.
Bởi
vì chúng ta sẽ cần phải biết đó là câu lệnh nào để mà tách ra, chúng ta
sẽ kết hợp quá trình tách các câu lệnh khác nhau với việc chuyển chúng
sang một định dạng mà con người có thể đọc hiểu được một cách tương
đương. Ngôn ngữ mà con người có thể đọc hiểu được đó chính là “Assembly Language”,
thường được viết tắt là ASM. Quá trình chúng ta chuyển dịch một chương
trình từ Code thô (Raw code) sang ASM, được gọi là quá trình “Disassembling”. Việc làm này sẽ cho chúng ta khả năng để đọc hiểu ASM.Để có thể thực hiện được chúng ta cần phải có một số kinh nghiệm.
Vì
rằng rõ ràng không có hệ thống nào để hiều một đoạn hexadecimal code
thực hiện công việc gì , đó là một công việc cực kì chán ngắt. Tuy
nhiên, việc hiểu được nó làm việc thế nào là rất quan trọng.Tôi sẽ chứng
minh điều này thông qua ví dụ mà các bạn đã thấy ở trên.
Hãy quan sát lại đoạn Hexadecimal code :
83EC20535657FF158C40400033DBA39881400053
Chúng
ta sẽ giả sử rằng byte đầu tiên chính là điểm bắt đầu hợp lệ và chúng
ta sẽ bắt đầu phân tích từ đó.Đầu tiên tôi sẽ lấy byte này ra, nó là 83h , sau đó chúng ta thực hiện công việc tra cứu dựa trên một bảng và bảng này tôi để trong phần Phụ lục A1.
Khi xem trong bảng này chúng ta thấy nó cần phải có thêm byte khác để
mô tả nhiệm vụ của nó một cách đầy đủ nhất, và byte cần sẽ được hình
thành từ một “mod R/M” byte. Để có được những gì đầy đủ
về nhiệm vụ của câu lệnh chúng ta sử dụng thông tin từ byte này và tra
cứu thêm bảng phụ lục thứ 2 (Phụ lục A2) để tìm kiếm thông tin thông qua “group #1”.Trong trường hợp này, byte đó chính là ECh. Một mod R/M byte bao gồm trường 3 bits sau:
Bit :
|
7
|
6
|
5
|
4
|
3
|
2
|
1
|
0
|
Meaning :
|
mod
|
reg
|
R/M
|
Để phân tách các trường này, chúng ta quay trở lại với binary bằng cách biểu diễn lại ECh :
EC = 1110 1100 = 11 101 100
Sử dụng bảng phụ lục A2, chúng ta sẽ thấy rằng những gì chúng ta biểu diễn ở trên phù hợp với giá trị xx101xxx, và đây chính là cậu lệnh SUB. Hai bit khác sẽ dùng để miêu tả toán hạng đầu tiên của câu lệnh SUB. Chúng ta lại xem tiếp trong một bảng phụ lục thứ 3 (Phụ lục A3), chúng ta tìm thấy 11 có nghĩa là chúng ta sẽ sử dụng trực tiếp một thanh ghi, và giá trị 100 ở trên chính là biểu diễn cho thanh ghi ESP. Quay trở lại bảng phụ lục A1 chúng ta thấy rằng cần phải có một toán hạng nữa để điền vào, đó chính là ‘Ib’ (Input byte). Rất dễ dàng để chúng ta thấy rằng byte tiếp theo đó chính là 20h.
Ghép tất cả những gì chúng ta vừa phân tích ở trên lại với nhau ,chúng ta sẽ có được một câu lệnh ASM đầu tiên :
83EC20 SUB ESP, 20
Okie
như các bạn đã thấy, khá phức tạp phải không nào. Chúng ta phải tra đi
tra lại mới ra được còn máy tính thì thực hiện quá nhanh J. Tiếp theo chúng ta sẽ tiếp tục quá trình phân tích với câu lệnh kế tiếp, bắt đầu tại giá trị 53h . Tra cứu trên bảng A1 nó cho chúng ta biết đây là một byte độc lập mà không có tham số :
PUSH rBX (= PUSH EBX)
Vậy cuối cùng chúng ta có được kết quả với 4 bytes đầu tiên được biểu diễn bằng ASM :
83EC20 SUB ESP, 20
53 PUSH EBX
Như
các bạn đã thấy việc làm này đã tiêu tốn của chúng ta rất nhiều thời
gian phải không. Tuy nhiên chúng ta thật may mắn khi có những công cụ đã
thực hiện điều này cho chúng ta (ví dụ : HIEW) :
83EC20 sub esp,020
53 push ebx
56 push esi
57 push edi
FF158C404000 call d,[0040408C]
33DB xor ebx,ebx
A398814000 mov [00408198],eax
53 push ebx
Tuy
nhiên nhiều khi nhìn vào chúng ta không thể hiểu ngay được ý nghĩa của
chúng, vi dụ như địa chỉ ở trên tham chiếu đến hàm nào, có trỏ tới một
String nào không v..v.. Để giúp cho chúng ta các chương trình
Disassemblers như IDA và W32DASM đã hỗ trợ rất nhiều. Sử dụng IDA, chúng
ta sẽ có được nhiều thông tin hơn :
sub esp, 20h
push ebx
push esi
push edi
call ds:GetProcessHeap
xor ebx, ebx
mov hHeap, eax
push ebx ; lpModuleName
Như
bạn đã thấy, IDA đã thực hiện thật tuyệt.Nó đã nhận ra được hàm call sẽ
gọi tới API nào và nó cũng hiểu được giá trị trả về từ hàm đó, đó là
hàm (GetProcessHeap) và do đó nó sẽ đổi tên biến thành hHeap.
Đây chỉ là minh họa nhỏ cho thấy những gì IDA có thể làm được, nhưng
cũng đủ để thấy rằng nó cung cấp cho chúng ta rất nhiều thông tin hơn là
những gì chúng ta quan sát trong HIEW. Điều này thật là tuyệt vời và nó
giúp cho chúng ta tiết kiệm được rất nhiều thời gian hơn vào việc làm
bằng tay, và bên cạnh đó nó cũng giúp cho chúng ta có một điểm bắt đầu
tốt cho quá trình phân tích code về sau.
Nhưng
nhiệm vụ tiếp theo và rất quan trọng của chúng ta là làm cách nào để
biểu đạt đoạn code dưới dạng ASM đó thành đoạn code dưới dạng cú pháp
của một ngôn ngữ bậc cao (ví dụ như C).
IV. Assembly code to C
Bây
giờ giả sử rằng tôi và các bạn có một đoạn code ASM, và chúng ta có thể
đọc hiểu được nó để biết được chương trình đang làm gì.Tuy nhiên, vì
hầu hết các câu lệnh ASM chỉ thực hiện một nhiệm vụ thông thường, do đó
rất khó cho chúng ta biết được tổng quát nhiệm vụ của chương trình đang
thực hiện cái gì. Hãy xem một đoạn mã ASM dưới đây :
.004122F0: 55 push ebp
.004122F1: 8BEC mov ebp,esp
.004122F3: 83EC48 sub esp,048 ;”H”
.004122F6: 53 push ebx
.004122F7: 56 push esi
.004122F8: 57 push edi
.004122F9: C745F800000000 mov d,[ebp][-08],000000000 ;”
.00412300: EB09 jmps .00041230B —–¯ (1)
.00412302: 8B45F8 mov eax,[ebp][-08]
.00412305: 83C001 add eax,001 ;”J”
.00412308: 8945F8 mov [ebp][-08],eax
.0041230B: 8B4508 mov eax,[ebp][08]
.0041230E: 50 push eax
.0041230F: FF1584A34300 call lstrlenA ;KERNEL32.dll
.00412315: 3945F8 cmp [ebp][-08],eax
.00412318: 7D2E jge .000412348 —–¯ (2)
.0041231A: 8B4508 mov eax,[ebp][08]
.0041231D: 0345F8 add eax,[ebp][-08]
.00412320: 8A08 mov cl,[eax]
.00412322: 884DFF mov [ebp][-01],cl
.00412325: 0FB645FF movzx eax,b,[ebp][-01]
.00412329: 83F861 cmp eax,061 ;”a”
.0041232C: 7C18 jl .000412346 —–¯ (1)
.0041232E: 0FB645FF movzx eax,b,[ebp][-01]
.00412332: 83F87A cmp eax,07A ;”z”
.00412335: 7F0F jg .000412346 —–¯ (2)
.00412337: 0FB645FF movzx eax,b,[ebp][-01]
.0041233B: 83E820 sub eax,020 ;” ”
.0041233E: 8B4D08 mov ecx,[ebp][08]
.00412341: 034DF8 add ecx,[ebp][-08]
.00412344: 8801 mov [ecx],al
.00412346: EBBA jmps .000412302 —– (3)
.00412348: 5F pop edi
.00412349: 5E pop esi
.0041234A: 5B pop ebx
.0041234B: 8BE5 mov esp,ebp
.0041234D: 5D pop ebp
.0041234E: C3 retn
Bạn
thấy đấy, trên đây là một đoạn mã ASM sử dụng rất nhiều các câu lệnh
đơn giản kết hợp với nhau và cuối cùng là để thực hiện một nhiệm vụ nào
đó mà chính chúng ta cần phải tìm hiểu. Chúng ta sẽ bắt đầu làm việc từ
câu lệnh đầu tiên và cứ như thế cho đến hết, cố gắng để có một cái nhìn
tổng quan nhất về những gì sẽ xảy ra bằng việc sử dụng một “Pseudo-C” notation (kí pháp Giả ngôn ngữ C), và cuối cùng là để chuyển nó về chính xác ở C code.
Okie có vẻ vẫn hơi mơ hồ, tôi sẽ cùng các bạn giải quyết. Đầu tiên chúng ta sẽ bắt đầu với những dòng lệnh sau :
.004122F0: 55 push ebp
.004122F1: 8BEC mov ebp,esp
.004122F3: 83EC48 sub esp,048 ;”H”
.004122F6: 53 push ebx
.004122F7: 56 push esi
.004122F8: 57 push edi
Hai
dòng lệnh đầu tiên còn được biết đến với một cái tên là “stack
frame”.Về bản chất đây là một ‘local’ stack bên trong của hàm, nơi mà
chúng ta tưởng tượng như là một căn phòng đặc biệt dùng để chứa các biến
cục bộ (local variables). Việc tạo ra căn phòng này có thể được thực
hiện rất dễ dàng bằng cách đơn giản là giảm con trỏ stack đi một số bit
nào đó, cụ thể là bao nhiêu bytes cần thiết cho việc lưu trữ các biến
cục bộ.
Một trong những lợi thế chính của Stack frame chính là ở thanh ghi EBP, nó có thể được sử dụng như là một con trỏ cố định tới các biến tham chiếu (reference varibales) (Nằm ở trên thanh ghi EBP là các tham số, ở dưới nó thì là các biến cục bộ) (Đọc thêm các bài viết của anh Be)
Chú ý rằng các con trỏ Stack như (ESP và EBP) cần phải được phục hồi lại trước rời khỏi một hàm nào đó, để tránh cho việc lỗi Stack corruption.
.004122F0: 55 push ebp
.004122F1: 8BEC mov ebp,esp
.004122F3: 83EC48 sub esp,048 ;”H”
Trên đây là quá trình tạo Stack Frame và căn phòng được với không gian là 48 bytes dành cho việc lưu trữ các biến cục bộ.
Windows yêu cầu rằng một vài thanh ghi khác ngoài ESP và EBP cũng cần được bảo vệ trong suốt quá trình của một Callback function, đó là những thanh ghi EBX, ESI và EDI.
Chúng được lưu trữ một cách an toàn trên Stack, và sẵn sàng để khôi
phục lại đúng vị trí trước khi rời khỏi hàm.Điều này cho phép sự tự do
khi sử dụng những thanh ghi này bên trong một hàm.
Đã
có quá trình lưu giữ thanh ghi thì cũng phải có quá trình phục hồi
chúng, điều này được thực hiện nhờ vào các câu lệnh rất đơn giản. Và
nhìn vào đó ta biết ngay nó làm gì :
.00412348: 5F pop edi
.00412349: 5E pop esi
.0041234A: 5B pop ebx
.0041234B: 8BE5 mov esp,ebp
.0041234D: 5D pop ebp
.0041234E: C3 retn
Đầu
tiên 3 thanh ghi được khôi phục từ stack của chúng ta. Sau đó Stack
được phục hồi lại trạng thái của nó sau khi hàm đã được gọi và khi gặp
câu lệnh Return. Chú ý rằng chúng ta không thể khôi phục Stack trước khi
khôi phục 3 thanh ghi được, bởi vì các thanh ghi của chúng đã được lưu
trên Stack.Chuyển tất cả đoạn code trên sang C là rất dễ dàng. Bây giờ
chúng ta biết rằng đây có thể là một hàm, bởi vì dựa vào Stack Frame
cũng như việc lưu trữ và phục hồi các thanh ghi, v..v.. :
void SomeFunction()
{
//…code…
}
Bây giờ tôi giả sử rằng đây là một void function, bởi vì không hề có bất kì một sự thay đổi nào trong thanh ghi EAX trước khi Return. Điều đó không có nghĩa là EAX đã không bị thay đổi. Nhưng cho đến bây giờ, chúng ta sẽ giả sử giá trị trong thanh ghi EAX bị lờ đi.
Tiếp theo chúng ta sẽ tiếp tục với thân của hàm này :
.004122F9: C745F800000000 mov d,[ebp][-08],000000000 ;”
.00412300: EB09 jmps .00041230B —–¯ (1)
.00412302: 8B45F8 mov eax,[ebp][-08]
.00412305: 83C001 add eax,001 ;”J”
.00412308: 8945F8 mov [ebp][-08],eax
.0041230B: 8B4508 mov eax,[ebp][08]
.0041230E: 50 push eax
.0041230F: FF1584A34300 call lstrlenA ;KERNEL32.dll
.00412315: 3945F8 cmp [ebp][-08],eax
.00412318: 7D2E jge .000412348 —–¯ (2)
Chúng ta hãy để ý tới giá trị được tham chiếu đến :
d,[ebp][-08] == dword ptr[ebp-08] (in another notation)
Như tôi đã nói, bời vì nó nằm dưới thanh ghi EBP của chúng ta (thanh ghi EBP
đang được lưu trên Stack), vì vậy hàm đang lưu trữ một biến cục bộ ở
đó. Chúng ta biết được rằng nó có kích thước là DWORD và nó có thể là
một giá trị có dấu (signed value), bởi nó được đem đi so sánh với kết
quả của hàm lstrlenA,
mà kết quả của hàm này là một signed int). Trên nền tảng win32, thì giá
trị signed dword trong C là (signed) int. Chúng ta hãy đổi tên của nó
thành int_locall để cho việc đọc hiểu trở nên dễ dàng hơn :
.004122F9: mov int_local1, 000000000
.00412300: jmps .00041230B —–¯ (1)
.00412302: mov eax, int_local1
.00412305: add eax,001
.00412308: mov int_local1, eax
.0041230B: mov eax,[ebp][08]
.0041230E: push eax
.0041230F: call lstrlenA ;KERNEL32.dll
.00412315: cmp int_local1,eax
.00412318: jge .000412348 —–¯ (2)
Okie đã thấy sáng sủa hơn một chút, tuy nhiên các bạn hãy cẩn thận ở đây, đừng nhầm lần giữa [ebp][08] với [ebp][-08]
. Mặc dù là nhìn thoáng qua ta cũng thấy nó giống nhau đấy chứ, tuy
nhiên đây lại là những địa chỉ hoàn toàn khác nhau. Biến tại địa chỉ [ebp][08] thì
luôn luôn là một tham số đầu tiên được truyền vào hàm của chúng ta.
Chính vì lí do đó chúng ta sẽ đổi tên của giá trị này thành dw_param1.
Khà khà sau một hồi phân tích chúng ta đã xác định được biến cục bộ, và
làm sáng tỏ được một số vấn đề , bây giờ chúng ta sẽ thử chuyển nó sang
một đoạn mã giả C :
int_local1 = 0;
goto label_41230B;
eax = int_local1;
eax = eax + 1;
int_local1 = eax;
label_41230B:
eax = dw_param1;
eax = lstrlenA(eax); //lstrlenA returns its result in eax
if( int_local1 >= eax)
goto label_412348;
Vậy
là phần nào chúng ta đã có một cái nhìn dễ dàng hơn với đoạn code trên,
tuy nhiên đây mới chỉ là điểm khởi đầu.Việc tiếp theo chúng ta phải
dùng tư duy của mình để tối ưu hóa lại đoạn code này, hãy nhìn lại 3
dòng sau :
eax = int_local1;
eax = eax + 1;
int_local1 = eax;
Chúng ta sẽ thấy rằng đoạn code trên là hơi thừa nó có thể được đơn giản hóa lại như sau :
int_local1++;
Điểm khác biệt duy nhất giữa hai cách thể hiện này là thanh ghi EAX không xuất hiện trong cách biểu diễn thứ hai. Chúng ta cần phải cẩn thận quan sát, vì rất có thể giá trị của thanh ghi EAX sẽ lại được sử dụng ở phía bên dưới thì sao J.
Tiếp theo ta đến dòng kế tiếp :
eax = dw_param1;
Điều này có nghĩa là những gì chúng ta làm ở trên là đúng bởi vì thanh ghi EAX đã được thay đổi bằng cách được gán 1 giá trị mới. Phần tiếp theo :
eax = dw_param1;
eax = lstrlenA(eax); // lstrlenA returns its result in eax
if( int_local1 >= eax)
goto label_412348;
Với
đoạn code này chúng ta hoàn toàn có thể làm cho nó trở nên dễ dàng hơn,
chúng ta có thể kết hợp những câu lệnh trên lại như sau :
if( int_local1 >= lstrlenA(dw_param1) )
goto label_412348;
Một lần nữa chúng ta phải quan sát xem thành ghi EAX
có được sử dụng trong các đoạn code bên dưới không, để từ đó chúng ta
không bỏ sót vị trí nơi mà giá trị của thanh ghi này đang được sử dụng.
Trong các câu lệnh sau đó, giá trị của EAX bị thay đổi, do đó chúng ta không cần quan tâm về những thay đổi của chúng ta. Bởi vì chúng ta biết rằng hàm lstrlenA sẽ lấy đầu vào là một con trỏ trỏ tới một chuỗi, do đó chúng ta sẽ thay đổi tham số này thành pString , cuối cùng ta có được như sau :
int_local1 = 0;
goto label_41230B;
int_local1++;
label_41230B:
if( int_local1 >= lstrlenA(pString))
goto label_412348;
Quan sát toàn bộ đoạn code tiếp theo trong hàm này chúng ta thấy được dòng sau :
.00412346: EBBA jmps .000412302 —– (3)
Đây là một câu lệnh nhảy và nó nhảy trở về vị trí có câu lệnh int_local1++; , điều
này chứng tỏ đây là một vòng lặp. Nếu như bạn đã quen thuộc với lập
trình C, bạn có thể minh họa được cấu trúc này.Đây dường như là một vòng
lặp for. Chúng ta sẽ cố gắng để biểu diễn lại nó, bằng cách thay đổi biến int_local1 thành i. Chúng ta sẽ viết lại dưới ngôn ngữ C như sau :
for(i = 0; i < lstrlenA(pString); i++)
{
//…rest of code…
}
Mọi việc đang dần dần được rõ ràng J.
Giờ chúng ta đã biết hàm này có một vòng lặp, với số lần lặp bắt đầu từ
0 cho tới chiều dài của chuỗi có được thông qua tham số đầu tiên (lstrlenA(pString)). Tiếp theo chúng ta cần biết những gì đang diễn ra bên trong thân vòng lặp :
.0041231A: mov eax, pString
.0041231D: add eax,i
.00412320: mov cl,[eax]
.00412322: mov [ebp][-01],cl
.00412325: movzx eax,b,[ebp][-01]
.00412329: cmp eax,061 ;”a”
.0041232C: jl .000412346 —–¯ (1)
.0041232E: movzx eax,b,[ebp][-01]
.00412332: cmp eax,07A ;”z”
.00412335: jg .000412346 —–¯ (2)
.00412337: movzx eax,b,[ebp][-01]
.0041233B: sub eax,020 ;” ”
.0041233E: mov ecx, pString
.00412341: add ecx, i
.00412344: mov [ecx],al
Trong đoạn code này chúng ta lại thấy có một biến cục bộ khác được sử dụng. Nó xuất hiện dưới kiểu unsigned char , bởi vì nó có kích thước là byte ( byte ptr) và được sử dụng nhưng là unsigned (bởi câu lệnh movzx). Trong đoạn mã giả C, ta có thể viết lại như sau :
eax = pString;
eax = eax + i;
cl = *(eax);
ch_local2 = cl;
eax = (DWORD) ch_local2;
if(eax < 0×61) // “a”
goto label_412346;
eax = (DWORD) ch_local2;
if(eax > 0x7A) // “z”
goto label_412346;
eax = (DWORD) ch_local2;
eax = eax – 0×20;
ecx = pString;
ecx = ecx + i;
*(ecx) = al;
Bây giờ tiếp tục, chúng ta sẽ làm cho đoạn code của chương trình rõ ràng hơn, tôi đổi tên kí tự thành c cho nó ngắn gọn :
c = pString[i];
if((c < ‘a’) || (c > ‘z’))
goto label_412346;
pString[i] = c-0×20;
Để ý rằng địa chỉ tại 412346 chỉ đơn giản là vị trí kết thúc vòng lặp, vì vậy chúng ta có thể thay thế ‘goto label_412346’ bằng ‘continue;’, hoặc chúng ta có thể đảo conditional jumps.
Chúng ta nhận thấy rằng chương trình kết thúc vòng lặp nếu (c<‘a’)||(c>‘z’), vậy thì nó sẽ không kết thúc vòng lặp nếu ta đổi thành (c>=’a’)&&(c<=’z’), điều này cho phép chúng ta thay đổi lại cấu trúc như sau :
c = pString[i];
if((c >= ‘a’) && (c <= ‘z’))
pString[i] = c-0×20;
//…end of loop
Các
bạn thấy đó , mọi thứ đã sáng tỏ và dễ hiểu hơn rất nhiều. Bây giờ
chúng ta đã bắt đầu hiểu những gì đoạn code này đang làm. Chúng ta hãy
sắp xếp lại chúng lại thành đoạn code cuối cùng như sau :
void SomeFunction(char* pString)
{
int i; //Local variables have to be declared
unsigned char c; //at the start of the function.
for(i = 0; i < lstrlenA(pString); i++)
{
c = pString[i];
if((c >= ‘a’) && (c <= ‘z’))
pString[i] = c-0×20;
}
}
Cuối
cùng chúng ta đã có được một đoạn code ngắn gọn hơn nhiều so với những
gì chúng ta đã đọc với ASM code.Nó đã được chuyển đổi hoàn toàn sang
ngôn ngữ C, nhiệm vụ của nó là lấy từng kí tự từ String đầu vào, và nếu
kí tự đó nằm trong khoảng ‘a’ và ‘z’ (tức là các kí tự chữ cái thường) nó sẽ được trừ đi cho 0x20h.
Mà phép trừ này biểu diễn cho quá trình chúng ta chuyển đổi nó từ chữ
cái thường thành chữ cái hoa. Do đó chúng ta sẽ đặt tên cho hàm này một
cách gợi nhớ hơn là ToUpperCase .
V. Lời kết
Toàn bộ quá trình mà tôi và các bạn đã làm ở trên được gọi với cái tên : RE (Reverse Engineering).
Chúng ta thấy rằng nó không phải là một việc làm quá khó, nhưng nó đòi
hỏi một lòng kiên nhẫn và một kiến thức nền tảng vững chắc. Để chỉ khi
ta chỉ nhìn lướt qua đoạn code ta có thể hiểu ngay được nó làm gì. Có
một công cụ giúp chúng ta đơn giản hóa công việc đi rất nhiều, một trong
số đó chính là IDA mà tôi đã giới thiệu với anh em.Hi vọng bài viết này
của tôi sẽ phần nào giúp mọi người hiểu được quá trình phân tích một
đoạn code như thế nào, chỉ có một lời khuyên duy nhất đó là các bạn hãy
thực hành thật nhiều mới có thể đạt được những điều mình mong muốn. Qua
đây tôi cũng xin cảm ơn tác giả Webbit đã cho chúng ta một bài viết rất
hay và bổ ích.
Thời gian trôi đi rất nhanh
Vì sao ta cứ mãi đứng yên
Ngoài kia bóng tối đang dần buông
Lại một ngày nữa sắp qua mất rồi.
PS : Hi vọng anh TQN, Thug và light.phoenix có thời gian viết vài bài cho anh em mở rộng tầm mắt J
Best Regards
_[Kienmanowar]_
No comments:
Post a Comment