EDR (Endpoint Detection and Response) là một sản phẩm với mục tiêu phát hiện và ngăn chặn các hành vi bất thường được thực thi trên máy tính cá nhân người dùng hoặc server. Những EDR hiện đại thường dựa vào nhiều các thành phần khác nhau: kernel callbacks, ETW, process protection, memory scanning, và nhiều kỹ thuật khác. Tuy nhiên, trước khi đi đến những cơ chế phức tạp đó, hầu hết chúng ta cần quan sát hoặc can thiệp vào hành vi của tiến trình và một trong những các trực tiếp nhất để làm điều này đó là API hooking. Trong phần 1 của chủ đề này, bài viết không nhằm xây dựng 1 mini EDR hay sử dụng kernel driver. Thay vào đó ta sẽ tập trung vào cơ chế hoạt động của API hooking trên Windows và cách chúng ta có thể sử dụng để xây dựng một hệ thống giám sát hành vi đơn giản.
Lưu ý: Các mô tả dưới đây chỉ nhằm phục vụ nghiên cứu và kiểm thử, phòng thủ hệ thống. Tác giả không chịu trách nhiệm với mọi hành vi sử dụng các tri thức này để thực hiện hoạt động tấn công phá hoại.
Khi bắt đầu tìm hiểu về EDR, mình từng nghĩ mọi thứ sẽ diễn ra dưới kernel: kernel callbacks, kernel driver, minifilter, ETW,... Nhưng đôi khi rất nhiều quyết định của EDR có thể được thực hiện ngay trước khi một system call xảy ra và không phải diễn ra trong kernel mà là trên user mode.
Nếu chỉ có một DLL được inject vào process thì liệu ta có thể xây dựng 1 mini EDR không? Câu trả lời là có nhưng với rất nhiều giới hạn. Ta sẽ thực hiện hook tại ntdll.dll. Mục tiêu rất đơn giản: mỗi lần 1 process yêu cầu cấp phát vùng nhớ thực thi (PAGE_EXECUTE_READWRITE), hook sẽ kiểm tra tham số trước khi lời gọi đi xuống phía kernel. Nếu phát hiện vùng nhớ RWX, process sẽ bị terminate.
Tại sao hook ở ntdll.dll?
Quay trở lại về trước, phần mềm antivirus phát hiện virus dựa trên các cách đơn giản như: kiểm tra signed, kiểm tra strings hoặc hash của binary. Tất nhiên các attacker cũng dễ dàng bypass bằng cách thay đổi các strings, thay đổi binary khiến cho việc phát hiện trở nên kém hiệu quả. Từ việc phân tích tĩnh là chưa đủ, các sản phẩm cần có khả năng phân tích động, chú ý hơn tới: các process được khởi tạo, các dll được load, các files bị thay đổi,... Điều này dễ dàng hơn nếu ta tận dụng kernel space, nơi chứa các thông tin cần thiết cho giám sát hệ thống được lưu trong các structures.

Trước đây, antivirus dùng kernel driver để patch SSDT (System Service Dispatch Table) — bảng ánh xạ syscall number sang địa chỉ hàm trong kernel. Các ứng dụng trên user mode phụ thuộc rất nhiều vào các WinAPI cung cấp qua nhiều dll như kernel32.dll, user.dll hoặc advapi.dll. Khi ta mở 1 file, hàm CreateFileA trong kernel32.dll sẽ được gọi rồi gọi tiếp tới hàm NtCreateFile trong NTDLL.dll.


Prototype của hàm NtCreateFile phức tạp hơn so với hàm CreateFileA vì NTDLL.dll là phiên bản ánh xạ của các hàm trên user mode so với các hàm dưới kernel. Một số tham số được truyền vào thêm cần thiết cho việc thực thi dưới kernel.
Ví dụ khi notepad.exe được load, nó sẽ load theo cả ntdll.dll. Dissassemble hàm NtCreateFile


Eax register được gán giá trị 55h chính là số của system service và lệnh syscall sẽ khiến processor chuyển sang chế độ kernel mode.

Vì vậy, thông qua bảng SSDT, ta có thể chỉnh sửa địa chỉ của kernel function, chuyển hướng tới bất kì địa chỉ nào ta muốn. Bằng cách chỉnh sửa trỏ tới drivers của ta, ta có thể kiểm soát các hành vi của máy. Tuy nhiên điều này rất dễ gây ra BSOD hoặc attaker cũng lợi dụng chính điều đó.
Để bảo vệ nhân hệ điều hành, Microsoft chặn luôn bằng PatchGuard (KPP) — cơ chế kiểm tra định kỳ các cấu trúc kernel quan trọng. Bất cứ ai sửa SSDT sẽ gây ra BSOD trên máy người dùng và. Antivirus vendor cũng không ngoại lệ. Vì vậy, các AV chuyển dần sang sử dụng callback kèm với usermode hook.
Trong phần một của chuỗi bài viết, mình sẽ nói tới cơ chế hook trên usermode trước khi mô tả tới cơ chế sử dụng các callback ở kernel mode trong các bài viết sau.
Như đã thấy ở trước đó, bởi vì cơ chế bảo vệ của PatchGuard, sửa đổi SSDT hoặc các thành phần quan trong dưới kernel là bất khả thi. Vậy thì các AV đã thực hiện như nào để sửa đổi NTDLL.dll? Vì NTDLL.dll là lớp cuối cùng trước kernel — mọi Win32 API (VirtualAlloc, OpenProcess, CreateFile...) đều gọi xuống hàm Nt* tương ứng trong ntdll.dll trước khi syscall, nên ta có thể thực hiện hook ở đây với mong muốn là intercept được gần như mọi thứ trên usermode có thể làm, mà không cần xuống tới kernel.

Câu hỏi là, làm thế nào để có thể sửa đổi luồng code trong NTDLL.dll? Khi mà process được khởi tạo, nó sẽ thực hiện load dll, trong đó có NTDLL.dll. Ở đây, sử dụng các kĩ thuật, ta sẽ sửa đổi luồng code tại đây, thay bằng functions mà ta mong muốn sau khi thực hiện xong sẽ jump back lại luồng code gốc. Nghe thì rất khó nhưng may mắn là ta đã có 1 library MinHook của TsudaKageyu. Từ thư viện MinHook, ta phát triển 1 dll thực hiện inject vào target process. Ở bài viết này, NtAllocateVirtualMemory là function được hook. Tại sao lại là NtAllocateVirtualMemory? Function này được sử dụng để khởi tạo cũng như là bảo vệ không gian memory. VirtualAlloc, VirtualAllocEx, HeapAlloc — tất cả đều đi qua đây và mục tiêu của chúng ta là phát hiện việc inject shellcodes.
Kiến trúc
Gồm 3 phần: MonitorInjector inject DLL vào target process, MonitorDLL chứa hook logic, TestProgram để test.

Injection
Classic DLL injection — OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread gọi LoadLibraryA:
HANDLE hProcess = OpenProcess(
PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE, targetPID);
FARPROC pLoadLibraryA = GetProcAddress(
GetModuleHandle(TEXT("kernel32.dll")), "LoadLibraryA");
LPVOID pDLLPath = VirtualAllocEx(hProcess, NULL,
strlen(dllPath) + 1, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, pDLLPath, dllPath,
strlen(dllPath) + 1, NULL);
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)pLoadLibraryA, pDLLPath, 0, NULL);
WaitForSingleObject(hThread, 5000);

Cũng chính là pattern mà malware dùng để inject shellcode — khác biệt duy nhất là payload.
Hook: MinHook patch gì?
Khi DLL được nạp vào tiến trình, DllMain chỉ tạo một thread mới để chạy InitHooksThread thay vì khởi tạo hook ngay bên trong DllMain. Lý do là trong thời điểm này Windows vẫn đang giữ loader lock; Microsoft cũng khuyến cáo không thực hiện các tác vụ phức tạp hoặc gọi nhiều API trong DllMain vì rất dễ dẫn đến deadlock.
Sau khi thread riêng được tạo, MinHook sẽ ghi đè một vài byte đầu tiên (prologue) của hàm NtAllocateVirtualMemory bằng một lệnh JMP trỏ đến detour function của chúng ta. Những instruction gốc bị ghi đè không bị mất mà được sao chép sang một vùng nhớ riêng gọi là trampoline. Cuối trampoline sẽ có một lệnh JMP quay trở lại phần còn lại của hàm gốc, ngay sau đoạn prologue đã bị thay thế.
Nhờ cơ chế này, mỗi lần NtAllocateVirtualMemory được gọi, luồng thực thi sẽ đi qua detour function trước để ta có thể theo dõi hoặc can thiệp. Sau đó, nếu cần, chương trình vẫn có thể tiếp tục thực thi hàm gốc thông qua trampoline, đảm bảo hành vi ban đầu của API gần như không thay đổi.

Khởi tạo hook:
MH_STATUS status = MH_Initialize();
status = MH_CreateHookApi(
L"ntdll.dll", // DLL chứa hàm target
"NtAllocateVirtualMemory", // hàm cần hook
&DetourNtAllocateVirtualMemory, // detour function
(LPVOID*)&fpNtAllocateVirtualMemory // nhận pointer tới trampoline
);
status = MH_EnableHook(MH_ALL_HOOKS);
Detour function
Nhận cùng parameter với hàm gốc. Logic chỉ quan tâm một thứ — parameter Protect:
typedef NTSTATUS(NTAPI* pNtAllocateVirtualMemory)(
HANDLE ProcessHandle, PVOID* BaseAddress,
ULONG_PTR ZeroBits, PSIZE_T RegionSize,
ULONG AllocationType, ULONG Protect);
pNtAllocateVirtualMemory fpNtAllocateVirtualMemory = NULL;
NTSTATUS NTAPI DetourNtAllocateVirtualMemory(
HANDLE ProcessHandle, PVOID* BaseAddress,
ULONG_PTR ZeroBits, PSIZE_T RegionSize,
ULONG AllocationType, ULONG Protect)
{
// Log mọi allocation
LogToFile("[HOOK] Size: 0x%zX | Protect: 0x%02X",
RegionSize ? *RegionSize : 0, Protect);
// RWX = red flag
if (Protect == PAGE_EXECUTE_READWRITE) {
LogToFile("[BLOCKED] RWX detected - TERMINATING");
TerminateProcess(GetCurrentProcess(), 0xdeadb33f);
return STATUS_ACCESS_DENIED;
}
// Không phải RWX → gọi hàm gốc qua trampoline
return fpNtAllocateVirtualMemory(
ProcessHandle, BaseAddress, ZeroBits,
RegionSize, AllocationType, Protect);
}
Trong ví dụ này, logic chỉ quan tâm đến tham số Protect – cờ xác định quyền truy cập của vùng nhớ sẽ được cấp phát. Mỗi lần NtAllocateVirtualMemory được gọi, hook sẽ ghi lại kích thước vùng nhớ (RegionSize) và giá trị Protect để phục vụ việc theo dõi.
Nếu Protect có giá trị PAGE_EXECUTE_READWRITE (0x40), tức vùng nhớ vừa ghi được vừa thực thi được (RWX), hook xem đây là một dấu hiệu đáng ngờ vì kiểu phân quyền này thường xuất hiện trong nhiều kỹ thuật nạp shellcode hoặc code injection. Thay vì cho phép cấp phát, hook ghi log, kết thúc tiến trình bằng TerminateProcess() và trả về STATUS_ACCESS_DENIED.
Ngược lại, nếu Protect không phải PAGE_EXECUTE_READWRITE, hook sẽ không can thiệp vào luồng thực thi. Thay vào đó, lời gọi được forward sang trampoline (fpNtAllocateVirtualMemory), nơi chứa các instruction gốc đã bị MinHook ghi đè. Từ đó, hàm NtAllocateVirtualMemory tiếp tục chạy như bình thường và trả kết quả về cho chương trình gọi.
Tóm lại, detour function đóng vai trò như một bộ lọc: mọi yêu cầu cấp phát bộ nhớ đều đi qua hook trước; các allocation thông thường được chuyển tiếp đến hàm gốc, còn những allocation yêu cầu quyền RWX (Protect == 0x40) sẽ bị chặn và tiến trình bị kết thúc ngay lập tức.
Kết quả
TestProgram chạy 3 lần VirtualAlloc với protection khác nhau: RW, RX, RWX. Sau khi inject MonitorDLL:
[+] NtAllocateVirtualMemory hooked
[+] All hooks enabled - MONITORING ACTIVE!
[HOOK #1] Size: 0x1000 | Protect: RW (0x04)
-> Result: 0x00000000 | Address: 0x... ← OK
[HOOK #2] Size: 0x2000 | Protect: RX (0x20)
-> Result: 0x00000000 | Address: 0x... ← OK
[HOOK #3] Size: 0x4000 | Protect: RWX (0x40) [MALICIOUS]
[BLOCKED] RWX allocation detected - TERMINATING PROCESS
Kết quả cho thấy các hàm được gọi với protection là RWX sẽ bị chặn thực thi.
Kết
Hook vào NtAllocateVirtualMemory chắc chắn chưa thể gọi là một EDR hoàn chỉnh. Tuy nhiên, ví dụ này giúp minh họa rõ tư duy cốt lõi của các giải pháp bảo mật hiện đại: chặn tại đúng điểm quan trọng, quan sát hành vi và đưa ra quyết định trước khi hệ điều hành thực thi yêu cầu. Nếu bài viết này đạt được 1000 view, tôi sẽ sớm viết tiếp bài tiếp theo trong chuỗi bài giới thiệu các kĩ thuật tôi tìm hiểu được trong EDR. RiverShen.
