REVERSING WITH IDA FROM SCRATCH (P28)

Posted: February 15, 2020 in IDA Tutorials, REVERSING WITH IDA FROM SCRATCH (P28)
Tags: ,

Như thường lệ, xen kẽ với các bài tập thực hành là những nội dung lý thuyết để giúp củng cố kiến thức cũng như hoàn thiện và nâng cao kĩ năng, từ đó có thể tìm hiểu sâu và rộng hơn. Trong phần 28 này, tôi sẽ đề cập tới một số lý thuyết về các chủ đề mà trong quá trình RE ta cần phải biết.

Variable

Khi viết code của một chương trình, các bạn chắc không lạ gì với việc khai báo/ định nghĩa một biến. Ví dụ:

int temp = 4;

Với việc khai báo biến như trên, chương trình sẽ phải dành ra một không gian đủ cho việc lưu trữ, trong trường hợp này sẽ dành ra 4 bytes ở một vị trí bộ nhớ nào đó. Sau đó, khi giá trị 4 được gán vào biến này, ta sẽ có giá trị 4 của temp được lưu tại một địa chỉ bộ nhớ do chương trình chỉ định.

Khi compile code và cho vào IDA để phân tích, ta sẽ thấy cách mà chương trình sử dụng các lệnh asm để khởi tạo một biến temp có giá trị bằng 4 như trên hình. Để có thể hiểu rõ ràng hơn, ta có thể đặt một bp tại lệnh gán giá trị 4 cho temp và tiến hành debug chương trình:

Ở đây ta sử dụng debugger của IDA là Local Windows debugger. Nhấn F9 để thực thi chương trình, ta sẽ break tại bp đã đặt:

Khi dừng tại bp, nếu đặt chuột tại biến temp như trên hình, chúng ta sẽ thấy địa chỉ bộ nhớ trong đó có 4 bytes được dành riêng cho việc lưu trữ một số nguyên (địa chỉ trên máy tôi sẽ khác với máy của các bạn). Ban đầu, địa chỉ bộ nhớ này sẽ được khởi gán một giá trị bất kỳ. Nhấp đúp chuột vào biến temp ta sẽ tới địa chỉ của biến này trong bộ nhớ:

Vì nó là một số nguyên, ta sẽ nhấn D cho đến khi nó thay đổi thành dd hoặc dword. Kết quả có được như sau:

Bằng cách trên, ta đã chuyển thành dword và giá trị tại địa chỉ lúc này là ngẫu nhiên (trước khi thực hiện lệnh) và bằng 0x5EFC7DE5. Nhấn F8 để thực hiện câu lệnh mov và quan sát sự thay đổi:

Tóm lại, thông qua ví dụ trên, những gì chúng ta thấy là bất cứ khi nào ta khai báo một biến, biến này sẽ có một giá trị (kể cả khi không gán giá trị cho biến) và một địa chỉ tương ứng với biến đó. Trong trường hợp của tôi, sau khi gán, giá trị của biến temp là 4 và địa chỉ của biến temp là 0x19FF00.

Ở ví dụ tiếp theo, tôi sẽ thay đổi code một chút để in ra đồng thời cả giá trị và địa chỉ của biến temp.

Compile chương trình và load file vào IDA:

Theo code asm trên hình, ta thấy có hai lời gọi tới hàm printf(). Đầu tiên, chương trình sử dụng lệnh MOV để gán giá trị 4 vào biến temp và đọc giá trị của biến này vào EAX, sau đó in giá trị này ra màn hình. Tiếp theo, chương trình sử dụng lệnh LEA để lấy địa chỉ của biến temp và in địa chỉ đó ra màn hình.

Đặt một breakpoint tại lệnh gán 4 cho biến temp:

Nhấn F9 để chạy debugger, ta dừng lại tại bp đã đặt:

Tại máy của tôi lúc này, địa chỉ của biến temp là 0x19FEFC. Đi tới địa chỉ đó và nhấn D để đổi sang giá trị DWORD, sau đó nhấn F8 để trace qua lệnh. Tiếp theo, chương trình sẽ đọc ra giá trị của temp vào EAX và in ra màn hình:

Trace code tới lệnh LEA, nếu bạn đặt chuột tại biến temp sẽ vẫn thấy giá trị 4 được giữ nguyên, tuy nhiên khi thực thi LEA và quan sát giá trị tại thanh ghi EAX, kết quả có được tương tự như hình sau (giá trị này có thể khác trên máy bạn)

Như vậy, sau khi thực hiện lệnh LEA, thanh ghi EAX sẽ chứa địa chỉ của biến temp, địa chỉ này sẽ được truyền làm tham số cho hàm printf() để in ra màn hình. Kết quả khi chạy chương trình sẽ như sau:

Qua ví dụ trên, các bạn có thể thấy lệnh mov thường được sử dụng để lấy giá trị được lưu tại một địa chỉ bộ nhớ (cụ thể là giá trị của biến temp), còn lệnh lea thường được sử dụng để lấy ra địa chỉ. Bên cạnh đó ta cũng thấy rằng trong mỗi trường hợp, cho dù nó là một số nguyên, là một buffer, một struct hay một kiểu dữ liệu nào khác thì chúng ta đều sẽ có một địa chỉ tương ứng (hoặc nơi bắt đầu (còn gọi là địa chỉ base) nếu nó là buffer hoặc struct) và một giá trị được lưu trữ tại địa chỉ đó.

Pointer

Trong lập trình C/C++ có rất nhiều kiểu dữ liệu khác nhau, trong đó có một kiểu dữ liệu được sử dụng để lưu trữ và quản lý các địa chỉ bộ nhớ, được gọi là con trỏ (pointer). Con trỏ chẳng qua cũng chỉ là một kiểu dữ liệu khác, ví dụ như kiểu int dùng để lưu số nguyên, kiểu char để lưu kí tự, kiểu float để lưu số dấu phẩy động, thì kiểu pointer dùng để lưu địa chỉ bộ nhớ.

Lấy ví dụ, trong trường hợp trước, điều gì sẽ xảy ra nếu thay vì in địa chỉ của biến temp ta lại muốn lưu nó. Vì đây là một địa chỉ trong bộ nhớ, nên để có thể lưu được thì ta phải sử dụng một biến có kiểu con trỏ. Cách thức khai báo một con trỏ có cú pháp như sau:

int *ptemp;

Nó sẽ khác với việc khai báo:

int ptemp;

Câu lệnh thứ hai sẽ khai báo một biến có kiểu số nguyên (int), trong khi câu lệnh đầu tiên sẽ khai báo một biến có kiểu con trỏ, được sử dụng để lưu trữ địa chỉ bộ nhớ hay nói cách khác ptemp là một con trỏ kiểu int. Như vậy, con trỏ là một biến có địa chỉ độc lập so với vùng nhớ mà nó trỏ đến, nhưng giá trị bên trong vùng nhớ của con trỏ chính là địa chỉ của biến (hoặc địa chỉ ảo) mà nó trỏ tới.

Sửa lại ví dụ ở trên như hình bên dưới, ta khai báo một biến ptemp là một con trỏ có kiểu int và gán địa chỉ bộ nhớ của biến temp cho nó:

Với khai báo như trên thì các bạn có thể hình dung một cách đơn giản như sau:

Như vậy, ta có thể nói rằng con trỏ ptemp đang nắm giữ địa chỉ của biến temp, cũng có thể nói con trỏ ptemp trỏ đến biến temp. Tiến hành compile chương trình và load file vào IDA để xem code:

Thông qua mã lệnh asm các bạn có thể nhận biết được biến ptemp là một pointer. Đầu tiên chương trình sử dụng lệnh LEA để lấy địa chỉ của biến temp và lưu tạm vào thanh ghi trung gian là EAX, sau đó gán giá trị tại EAX và biến ptemp. Như vậy, để một biến có thể lưu địa chỉ của một biến khác thì chắc chắn phải là kiểu pointer, và vì biến temp là một số nguyên nên biến ptemp phải là một con trỏ kiểu int (Phép gán của con trỏ chỉ thực hiện được khi kiểu dữ liệu của con trỏ phù hợp kiểu dữ liệu của biến mà nó sẽ trỏ tới). Kết quả khi chạy chương trình có được như sau:

Nếu ta nhấn F5 để decompile code ta có được kết quả như sau:

Ở mã giả C trên hình, ta thấy không xuất hiện biến ptemp mà thay vào đó nó sử dụng trực tiếp &temp để tối ưu. Tới đây, nhiều người sẽ đặt câu hỏi, có khác biệt gì khi tạo ra một kiểu dữ liệu đặc biệt chỉ để lưu trữ địa chỉ bộ nhớ hay không? Có thể truy xuất giá trị bên trong vùng nhớ mà con trỏ trỏ đến không? Xin trả lời là, con trỏ cũng cho phép ta đọc, thay đổi và làm việc với các giá trị mà chúng trỏ tới.

Trong ví dụ trên, ptemp lưu địa chỉ bộ nhớ của biến temp và giá trị 4 gán cho biến temp sẽ liên quan đến cả hai biến, hay nói cách khác ptemp có thể truy xuất giá trị này. Nếu tôi thực hiện câu lênh như sau trong chương trình:

*ptemp = 8;

Dấu “*” được sử dụng trong tình huống này không chỉ để xác định một con trỏ, mà còn để truy cập giá trị mà nó trỏ tới (trong trường hợp này nó được gọi là gián tiếp). Khi đó, giá trị 4 ban đầu của biến temp sẽ được thay thế bằng 8, còn địa chỉ của biến vẫn giữ nguyên không thay đổi. Ta sửa code lại như sau:

Như đã giải thích, vì giá trị của ptemp là địa chỉ bộ nhớ của temp, ptemp là một con trỏ trỏ tới giá trị của temp (ở đây là 4), nếu tôi thay đổi nó thành 8, tôi sẽ thay đổi giá trị của temp.

Do vậy có thể thấy rằng việc thay đổi nội dung của ptemp cũng sẽ làm ảnh hưởng đến giá trị của temp, đây là một điều hoàn toàn logic, bởi vì ptemp chứa địa chỉ bộ nhớ của temp.

Load file vào trong IDA để quan sát cách thực hiện của các lệnh asm:

Chúng ta thấy rằng giá trị của ptemp được đọc ra và lưu vào eax, lúc này ptemp đang chứa địa chỉ của temp. Sau đó thực hiện gán giá trị 8 vào giá trị đang lưu tại địa chỉ này. Ta đặt bp tại lệnh gán biến temp bằng 4 để debug từ đầu, nhấn F9 để chạy debugger và trace qua lệnh gán sẽ dừng tại đây:

Kết quả trên máy tôi lúc này, địa chỉ của biến temp là 0x19FEFC và giá trị của temp đang bằng 4. Tiếp tục trace code cho tới đây:

Đoạn code trên thực hiện lưu địa chỉ của temp vào ptemp, ta thấy rằng địa chỉ của ptemp lúc này là 0x19FEF8 và giá trị của nó đang lưu chính là địa chỉ bộ nhớ của temp (0x19FEFC). Cách mà IDA hiển thị thông tin cho chúng ta là: offset dword_0x19FEFC. Tiền tố offset trong IDA có nghĩa là một địa chỉ bộ nhớ và tiền tố dword bên cạnh địa chỉ có nghĩa là trỏ đến một giá trị dword (int). Tiếp tục trace code tới đây:

Lệnh trên sẽ đọc giá trị đang lưu tại ptemp, đó chính là địa chỉ của temp, và lưu vào eax. Giá trị tại thanh ghi eax sau khi thực hiện lệnh:

Tiếp theo thực hiện gán giá trị 8 vào nội dung của vùng nhớ được trỏ bởi thanh ghi eax. Như vậy, đồng nghĩa với việc thay đổi giá trị của temp thành 8:

Thử nhấn F5 để decompile, ta thấy rằng mã giả sinh ra khác với mã nguồn gốc, nó luôn sử dụng và gán giá trị trực tiếp vào biến temp thay vì dùng tới con trỏ ptemp:

Rõ ràng bạn sẽ thấy rằng khi gán temp = 8 thì cũng tương đương với *ptemp = 8;

Tiếp tục xem xét một ví dụ khác để thấy được lợi ích khi ta sử dụng con trỏ trong việc truyền tham số cho hàm.

Trong đoạn code trên, ta định nghĩa một biến n có kiểu int và gán giá trị là 4, sau đó truyền giá trị này vào hàm sum. Hàm sum sẽ gán 4 cho biến cục bộ của hàm này là x, sau đó tăng x lên 1. Như vậy, câu lệnh printf trong hàm sum sẽ in ra giá trị của x là 5, nhưng sau khi thoát khỏi sum thì biến x sẽ không còn giá trị nữa nên biến n giữ nguyên bằng 4. Do đó, câu lệnh printf tại hàm main sẽ in ra giá trị của n là 4:

Bây giờ, làm cách nào để có thể sửa đổi giá trị của n? Nếu truyền giá trị như chúng ta đã làm trong ví dụ trước thì sẽ không giải quyết được bài toán đưa ra, nhưng nếu chúng ta sử dụng con trỏ thì sẽ hữu ích cho những trường hợp như thế này. Con trỏ sẽ phép chúng ta lưu địa chỉ bộ nhớ của một biến. Đoạn code được sửa lại như sau:

Hàm sum được định nghĩa với một tham số x là một con trỏ kiểu int. Hàm được gọi bằng cách truyền vào địa chỉ của biến n: sum(&n); Vì con trỏ cho phép lưu địa chỉ của bộ nhớ và trong trường hợp này chúng ta truyền vào địa chỉ bộ nhớ của n trỏ đến một số nguyên, vì vậy lời gọi hàm là chuẩn xác.

void sum(int * x) {
	*x += 1;
	printf("Value of *x = %x \n", *x);
}

Trong thân hàm sum ta thực hiện tăng nội dung của x, vì x có địa chỉ bộ nhớ của n, việc tăng nội dung của x sẽ tác động đến giá trị n. Như vậy, khi sử dụng con trỏ theo cách trên thì khi thoát khỏi hàm sum cũng đồng nghĩa giá trị n sẽ thay đổi theo.

Như các bạn thấy, giá trị của n đã thay đổi mà không cần có bất kì thay đổi nào tại hàm main. Thử phân tích chương trình trong IDA:

Biến n được khởi tạo và được gán giá trị là 4, sau đó địa chỉ của n được truyền như một tham số cho hàm sum. Đi vào hàm sum để phân tích:

Do hàm nhận vào địa chỉ của biến n nên tham số x của hàm phải là một pointer có kiểu int. Ta thấy code tại hàm thực hiện đọc x (lúc này là địa chỉ bộ nhớ của n) và gán vào EAX. Tiếp theo, thực hiện đọc giá trị tại bộ nhớ vào ECX và tăng ECX lên 1, sau đó lưu giá trị tại ECX vào trong nội dung EDX (đang chứa địa chỉ của n). Với một lệnh đơn giản ở mã nguồn gốc ta phải mất khá nhiều lệnh asm để đạt được cùng một mục đích.

Thử nhấn F5 để decompile hàm sum:

Có một cách khác để giải quyết vấn đề trên mà không cần sử dụng con trỏ, bằng cách sử dụng kĩ thuật được gọi là tham chiếu (Reference). Mục đích của tham chiếu trong C++ là tạo ra một biến khác có cùng kiểu dữ liệu nhưng sử dụng chung vùng nhớ với biến được tham chiếu đến. Nôm na nó giống như một dạng alias (bí danh) hoặc tag (nhãn) của một biến. Ví dụ:

int n = 4;
int &ref_n = n; //reference to n, not means address of n

Khi đặt & trước một biến trong khi khai báo, thì có nghĩa là ta tạo alias của cùng một biến thậm chí chia sẻ cùng một địa chỉ bộ nhớ. Tôi có ví dụ như sau:

Thực thi đoạn code trên:

Bằng cách này, tôi sẽ có hai biến chia sẻ cùng một vị trí bộ nhớ. Nếu tôi thay một biến thì sẽ thế nào:

Kết quả khi chạy chương trình sẽ như sau:

Như vậy, mọi hành vi thay đổi giá trị của ref_n đều tác động trực tiếp đến n. Do vậy, ta có thể áp dụng phương pháp này để viết lại hàm sum ở trên mà không cần sử dụng tới con trỏ.

Như vậy, ở đây tôi vẫn truyền giá trị của n cho hàm, nhưng vì hàm tạo ra một bí danh của n là x và chia sẻ cùng một địa chỉ bộ nhớ, do đó, khi sửa đổi x cũng giống như sửa đổi n. Ta thấy đơn giản hơn so với việc sử dụng con trỏ:

void sum(int &x) {
	x += 1;
}

Kết quả khi chạy chương trình sẽ như sau:

Xem thử code của chương trình trong IDA. Các bạn thấy gì nào???

Chúng ta thấy rằng code tại mã nguồn của chương trình đã được thay đổi để tiện lợi hơn, nhưng ở mức low level thì vẫn tiếp tục sử dụng con trỏ. Cụ thể là giá trị của n không được truyền trực tiếp cho hàm mà thay vào đó vẫn là địa chỉ như đã thấy trong ví dụ ở phần con trỏ.

Và code bên trong của hàm sum cũng giống hệt như code tại ví dụ con trỏ. Vì vậy, giải pháp alias hay tham chiếu chỉ là để tiện lợi hơn trong quá trình viết code ở ngôn ngữ bậc cao mà thôi, còn ở mức low level, chúng ta vẫn phải làm việc với con trỏ nhé các bạn!! 😦

Hẹn gặp các bạn ở phần 29!

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

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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