Cài DLL của mình vào chương trình của người khác
Để khiến người dùng thực thi một đoạn mã nào đó, ta không nhất thiết phải biên dịch chúng thành file .EXE và tìm cách khiến cho họ thực thi file này, cũng không nhất thiết phải đưa nó vào danh sách Autorun hay vào khóa Registry của Windows. Có một cách đơn giản hơn để bắt các chương trình hợp lệ thực thi bất kỳ đoạn mã nào mà ta mong muốn mỗi khi các chương trình đó được gọi đến. Cách thức đó sẽ được mô tả sau đây.
Thư viện liên kết động trong Windows
Các file có thể thực thi được (executable files) trong Windows có nhiều định dạng khác nhau, trong đó hai định dạng chính là EXE và DLL. Các file EXE là những chương trình độc lập mà người dùng có thể thực thi. Còn file DLL là thư viện liên kết động, được thiết kế ban đầu nhằm mục đích tiết kiệm bộ nhớ (do chỉ có một bản sao duy nhất của file DLL nằm trong bộ nhớ mà nhiều chương trình có thể đồng thời sử dụng). Mọi chương trình (EXE) trong Windows đều sử dụng thư viện liên kết động, mà chính xác hơn là sử dụng các hàm được export bởi các thư viện. Chúng có thể sử dụng thư viện của hệ thống như kernel32.dll, user32.dll và thư viện của riêng mình. Khi xây dựng chương trình, lập trình viên cần phải import những hàm cần sử dụng từ các thư viện tương ứng.
Để import các hàm từ thư viện, người ta chủ yếu sử dụng cách liên kết ở thời điểm biên dịch. Trong trường hợp đó, trình nạp file PE (PE loader) của Windows sẽ xác định những DLL cần thiết và ánh xạ chúng vào trong không gian địa chỉ của tiến trình, cũng như xác định địa chỉ của các hàm được import. Để thực hiện được điều này thì trong mỗi file PE cần chứa một cấu trúc dữ liệu đặc biệt, gọi là “Import Table”. Cấu trúc dữ liệu này cho biết những hàm nào, thuộc DLL nào được sử dụng trong chương trình. Để DLL của ta được nạp mỗi khi người dùng thực thi một chương trình nào đó thì chỉ cần chỉnh sửa Import Table của chương trình đó là được.
Cấu trúc file PE và Import Table
Mọi file có thể thực thi được trong Windows đều có DOS Header. Header này nằm ở ngay đầu file và trường đầu tiên của nó (tên là e_magic, offset = 00h) luôn có giá trị là 5A4Dh (IMAGE_DOS_SIGNATURE), tức là hai chữ cái latin “MZ”. Trong header này có một trường tên là e_lfanew, chứa offset của PE Header. Cấu trúc của PE Header được mô tả trong file winnt.h dưới tên gọi IMAGE_NT_HEADERS. Trường đầu tiên của nó có tên là Signature, kiểu DWORD và luôn có giá trị là 4550h (tức là “PE”).
Để xác định vị trí của Import Table, ta sử dụng cấu trúc IMAGE_OPTIONAL_HEADER, là một thành phần của cấu trúc IMAGE_NT_HEADERS. IMAGE _OPTIONAL_HEADER lại chứa một mảng có tên là PIMAGE_DATA_DIRECTORY. Mỗi phần tử của mảng này có kiểu IMAGE_DATA_DIRECTORY và chứa thông tin về một trong số các bảng (table) cần cho hoạt động của chương trình. Thông tin về Import Table nằm trong phần tử có chỉ số là IMAGE_DIRECTORY_ENTRY_IMPORT (tức là 1).
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY,
*PIMAGE_DATA_DIRECTORY;
Trường VirtualAddress chứa RVA (relative virtual address), tức là địa chỉ ảo tương đối so với ImageBase. Ví dụ, nếu như RVA của Import Table là 1000h, còn ImageBase có giá trị là 00400000h thì địa chỉ ảo tuyệt đối sẽ là 00401000h.
Đến đây sẽ gặp trở ngại đầu tiên: ở trên chỉ mô tả việc đọc file PE vào bộ đệm, chứ không tính toán và đặt các thành phần của nó vào đúng vị trí trong không gian địa chỉ ảo. Do vậy, cần phải tính toán được offset vật lý của Import Table trong file PE này (có thể gọi là RAW offset). Để làm được điều đó, trước hết cần xác định được Import Table nằm trong section nào. Sau đó, lấy RVA của Import Table trừ đi RVA của section, rồi cộng với giá trị PointerToRawData của section thì ta được giá trị cần tìm.
Trình tự của việc xử lý các cấu trúc dữ liệu để tìm ra vị trí Import Table trong file PE được mô tả trong hình 1.
Hình 1: Sơ đồ quá trình tìm Import Table
Tính RAW offset của Import Table
//Trước đó, ta đã đọc file PE vào bộ đệm
PIMAGE_NT_HEADERS nth = (PIMAGE_NT_HEADERS)((DWORD)
((PIMAGE_DOS_HEADER)buff)->e_lfanew) + (DWORD)buff);
// Số lượng section trong file
WORD nos = nth->FileHeader.NumberOfSections;
// RVA của Import Table
DWORD impRVA = nth->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
// Section đầu tiên
PIMAGE_SECTION_HEADER inFileSec =
IMAGE_FIRST_SECTION(nth);
// Tìm chỉ số của section có chứa Import Table
WORD impSecIndex = -1;
for (size_t i = 0; i < nos-1; i++)
{
if (impRVA >= inFileSec[i].VirtualAddress &&
impRVA < inFileSec[i+1].VirtualAddress)
{
impSecIndex = i;
break;
}
}
// Offset vật lý của ImportTable trong file
DWORD impRawOffset = inFileSec[impSecIndex].
PointerToRawData + impRVA;
Chỉnh sửa Import Table
Sau khi đã xác định được vị trí của Import Table, ta có thể tiến hành cài DLL của mình. Trên cơ sở biết cấu trúc của Import Table. Thực chất của Import Table là một mảng, và mỗi phần tử của nó có kiểu là cấu trúc IMAGE_IMPORT_DESCRIPTOR. Mỗi một thư viện được import tương ứng với ít nhất một phần tử trong mảng.
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
};
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
Có ba trường cần quan tâm là: Name, OriginalFirstThunk và FirstThunk. Trong đó, Name là RVA của tên của DLL được import. Tên của DLL là một xâu ký tự kết thúc bởi 0x00 và có độ dài là bội của 2. Ví dụ, nếu kernel32.dll được import thì tên của nó cộng với ký tự kết thúc xâu nữa có độ dài là 13, không chẵn; khi đó, cần thêm một ký tự 0x00 vào cuối.
OriginalFirstThunk chứa RVA của một mảng có kiểu IMAGE_THUNK_DATA. Mảng này kết thúc bởi phần tử có giá trị 0. Bản thân cấu trúc IMAGE_THUNK_DATA đơn giản là một số kiểu DWORD, và trong phần lớn trường hợp nó có ý nghĩa là RVA của cấu trúc IMAGE_IMPORT_BY_NAME. Trong cấu trúc này chứa trường Hint kích thước 2 byte, có tác dụng tăng tốc tìm kiếm các tên gọi trong file DLL được import.
FirstThunk là một bản sao của OriginalFirstThunk, tức là hai trường này cũng chứa một giá trị RVA như nhau. Tuy nhiên, trình nạp file PE của Windows sẽ thay đổi giá trị của FirstThunk khi thực hiện liên kết với thư viện. Khi đó, nó sẽ chứa địa chỉ của các hàm được import.
Như vậy, để có thể cài DLL của mình vào một file PE nào đó, người ta cần bổ sung vào Import Table một cấu trúc IMAGE_IMPORT_DESCRIPTOR hợp lý.
Bổ sung một IMAGE_IMPORT_DESCRIPTOR
// Phần tử IMAGE_IMPORT_DESCRIPTOR trong Import Table
PIMAGE_IMPORT_DESCRIPTOR iid = (PIMAGE_IMPORT_DESCRIPTOR)
(impRawOffset + (DWORD)buff);
// Tìm phần tử cuối cùng của Import Table
while (iid->Name != 0) iid++;
// Bổ sung một phần tử
IMAGE_IMPORT_DESCRIPTOR
fillIID(iid);
// Thêm phần tử kết thúc bảng
iid++;
ZeroMemory(iid, sizeof(IMAGE_IMPORT_DESCRIPTOR));
Sẽ nảy sinh một số vấn đề: Thứ nhất, khi thêm một phần tử thì kích thước của Import Table sẽ tăng thêm một lượng là sizeof (IMAGE_IMPORT_DESCRIPTOR). Do vậy, nhiều khả năng là sẽ ghi đè lên một phần thông tin về OriginalFirstThunk. Thứ hai, bên cạnh IMAGE_IMPORT_DESCRIPTOR, còn phải tạo các mảng thích hợp mà OriginalFirstThunk và FirstThunk trỏ đến, cũng như xác định RVA trong trường Name. Và khi thực hiện tất cả các việc trên, cần phải tìm được không gian trống trong file PE để không đè lên các dữ liệu quan trọng.
Giải pháp đơn giản nhất là di chuyển Import Table vào cuối một section nào đó. Thông thường, section trong file PE phải có kích thước là bội số của một giá trị nhất định. Như vậy, ở cuối section có thể có đủ chỗ trống để đặt các dữ liệu khi cần. Các bảng mà OriginalFirstThunk và FirstThunk trỏ đến, tên của DLL muốn cài được trỏ bởi Name và cả mảng các phần tử IMAGE_IMPORT_BY NAME cũng cần phải được đặt vị trí xác định trong file PE. Khi thực hiện việc dịch chuyển này, cần thay đổi các giá trị DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT] và Data Directory[IMAGE _DIRECTORY_ ENTRY _IAT] một cách thích hợp.
Để thực hiện được các việc trên đây thì cần phải có hiểu biết cặn kẽ về cấu trúc file PE và dành nhiều thời gian để thử nghiệm với không chỉ một file PE.
Hướng ứng dụng
Những kiến thức được trình bày trên đây trước hết có thể được sử dụng để viết phần mềm virus. Ví dụ, có thể cài một DLL vào một chương trình mà sẽ được thực thi khi Windows khởi động. Khi đó, ngay người dùng nhiều kinh nghiệm nhất cũng khó mà đoán được nguyên nhân của các biểu hiện bất thường (nếu các biểu hiện bất thường đó là kết quả của các đoạn mã trong DllMain). Ngoài ra, còn có thể cài cắm DLL để thực hiện chặn bắt các lời gọi API