Giải mã Hell’s Gate: Dynamic SSN và kỹ thuật Syscall Direct/Indirect

Published By: BLUE TEAM

Published On: 03/09/2025

Published in:

Mục lục

  1. giới thiệu
  2. Lấy thông tin từ EAT
  3. Sắp xếp và phân chia các hàm
  4. Unhooking
  5. Hàm wrapper
  6. InDirectSyscall và DirectSyscall
  7. Demo

1.Giới thiệu

Trong phần trước, chúng ta đã cùng tìm hiểu về Direct và Indirect Syscall – hai phương pháp phổ biến giúp né tránh sự giám sát của EDR/AV bằng cách bỏ qua các API tầng cao và giao tiếp trực tiếp với kernel thông qua syscall. Tuy nhiên, cả hai kỹ thuật này vẫn bộc lộ một hạn chế quan trọng: Syscall Number (SSN) có thể thay đổi theo từng phiên bản Windows. Điều này khiến việc hardcode hoặc tái sử dụng shellcode trở nên thiếu ổn định, thậm chí dễ bị EDR phát hiện. Chính vì vậy, cần một hướng tiếp cận mới – đó chính là Hell’s Gate.

Trong Part 2 này, chúng ta không chỉ dừng lại ở việc giải thích cơ chế hoạt động của Hell’s Gate, mà sẽ đi xa hơn với một ví dụ thực tế: viết đoạn code shellcode injection vào tiến trình khác. Ở kịch bản thông thường, EDR sẽ chủ động hook vào các API nhạy cảm như NtAllocateVirtualMemory, NtWriteVirtualMemory hay NtCreateThreadEx để theo dõi và phát hiện hành vi nguy hiểm. Đây là lý do mà hầu hết các kỹ thuật injection truyền thống dễ dàng bị phát hiện và ngăn chặn. Nhưng với Hell’s Gate, chúng ta có thể trích xuất SSN ngay tại runtime và gọi trực tiếp syscall, từ đó bypass cơ chế hook của EDR và thực hiện injection thành công.

Điểm nổi bật của Hell’s Gate là khả năng trích xuất SSN động thay vì phụ thuộc vào API đã bị hook hoặc hardcode ID cố định. Nhờ đó, shellcode có thể tự động thích ứng với phiên bản Windows hiện tại, đồng thời giảm thiểu rủi ro bị phát hiện. Nói cách khác, Hell’s Gate cho phép chúng ta lấy được SSN trực tiếp từ stub gốc của ntdll.dll khi chương trình chạy. Đây là cách tiếp cận vượt trội so với việc hardcode truyền thống.

Có nhiều cách để trích xuất SSN từ ntdll.dll trong runtime, ví dụ:

  • Sử dụng GetModuleHandleA và GetProcAddress
  • Sử dụng PEB để parse EAT
  • Xây dựng các hàm GetModuleHandleA và GetProcAddress của riêng bạn

Bài viết này sẽ sử dụng cách duyệt qua Process Environment Block (PEB) để xác định module ntdll.dll, sau đó phân tích Export Address Table (EAT) nhằm tìm địa chỉ của các hàm native cần thiết. Cụ thể ta sẽ có các bước sau:

  1. Truy cập và đi qua Thread Environment Block (TEB) để truy cập PEB và lấy base address từ ntdll.dll
  2. Truy cập EAT từ ntdll.dll và trích xuất các thông tin cần thiết như tên, địa chỉ tuyệt đối, SSN, tình trạng có bị hook và địa chỉ trả về của syscall gốc (phục vụ cho indirect syscall) của hàm native
  3. Sắp xếp và phân loại lại các hàm native
  4. Thực hiện unhook
  5. Sử dụng các hàm wrapper để chuẩn bị cho việc sử dụng các kỹ thuật
  6. Sử dụng các hàm InDirectSyscall và DirectSyscall để thực hiện hai kỹ thuật syscall và indirect syscall

2. Lấy thông tin từ EAT

Chi tiết các bước lấy image base của các dll (trong trường hợp này là ntdll.dll) thông qua TEB và PEB đã từng được đề cập ở một bài block trước đây, link bài viết: https://sec.vnpt.vn/2023/07/tim-dia-chi-kernel32-dll-va-cac-ham-api

Sau khi lấy được image, ta có thể parse ra các header của ntdll.dll và lấy được export directory:

Bên trong directory này ta sẽ cần sử dụng 4 trường đó là:

  • AddressOfNameOrdinals: sử dụng để kiểm tra xem hàm tương ứng có tên không (không phải hàm nào xuất hiện trong DLL cũng có tên).
  • AddressOfNames: địa chỉ đến tên của hàm.
  • AddressOfFunctions: địa chỉ đến địa chỉ tuyệt đối của hàm.
  • NumberOfFunctions: số lượng hàm.

Ta sẽ chuẩn bị một cấu trúc Export_funciton để lưu thông tin của từng hàm

Ta sẽ sử dụng vòng lặp để đi qua toàn bộ export directory, ở đoạn đầy ta sẽ kiểm tra xem hàm đang được đi qua có tên không , tránh làm lệch địa chỉ và tên (do một số hàm tuy có tên nhưng vẫn có địa chỉ):

Nếu có tên, ta sẽ kiểm tra xem chúng có tiền tố Nt hoặc Zw không (do chúng ta chỉ lấy các hàm native). Cả tiền tố Nt và Zw đều dùng để chỉ các hàm native trong Windows. Mình sẽ không đi sâu vào chi tiết sự khác nhau giữa hai nhóm hàm này, bạn chỉ cần hiểu đơn giản rằng chúng đều là cầu nối để gọi trực tiếp xuống kernel thông qua cơ chế system call. Về cơ bản, chúng thực hiện cùng một chức năng, khác biệt chủ yếu nằm ở ngữ cảnh được gọi và một vài chi tiết kiểm tra quyền hạn.

Quay trở lại vấn đề, nếu kiểm tra đúng là 1 hàm native, ta sẽ lấy tên và địa chỉ tuyệt đối của hàm đó:

Tiếp theo, ta sẽ kiểm tra hàm có bị hook không thông qua hàm Is_hooked().

Hàm này sẽ kiểm tra xem 4 byte đầu có bị thay đổi không, mặc định 4 byte đầu sẽ là 0x4C, 0x8B, 0xD1, 0xB8, nếu bị thay đổi sẽ trả về True:

Tiếp theo là hàm Find_Trampoline để xác định địa chỉ mà lệnh syscall trong stub code gốc trả về say khi chạy lệnh. Các hex 0x0F ,0x005, 0xC3 tương ứng với:

Nghĩa là ta sẽ tăng dần giá trị địa chỉ mà ta chuyền vào hàm đến khi nào tăng lên thành địa chỉ tại offset 0xC3 (như trong ảnh là là địa chỉ 0x00007FFA9D762D52) – địa chỉ ngay sau lệnh syscall.

Cuối cùng là ta sẽ lấy giá trị SSN của từng hàm, giá trị SSN luôn nằm cố định sau 4 byte tính từ đầu stub code syscall, còn nếu hàm đấy đã bị hook và ta ko lấy được giá trị, sẽ gán nó với 0xFFFF. Rồi đẩy Export_funciton mà ta vừa lấy đầy đủ thông tin vào 1 hàm global Export_funciton_list.

 

3.Sắp xếp và phân chia các hàm

Như đã nói ở trên, các hàm native có 2 loại tiền tố khác nhau. Khi trích xuất thông tin, chúng ta đã để trộn lẫn cả hai loại. Bước tiếp theo là cần phân chia thành 2 mảng chứa 2 loại tiền tố, đồng thời sắp xếp các hàm theo thứ tự từ nhỏ đến lớn tính theo địa chỉ. Có nhiều cách để phân chia cũng như sắp xếp, các bạn có thể chọn cách mình thích. Dưới đây là code mình sử dụng:

Lý do việc chia hàm lại bắt đầu từ index [i+4] là do trong lúc lọc các native API, bị lọt thêm 4 hàm sau:

4 hàm đấy không phải native api, chúng chỉ tình cờ có cùng tiền tố Nt nên bị lọt vào danh sách:

Bạn có thể chọn lọc nó từ bước parse EAT hoặc là có thể làm như mình.

Vậy là ta đã hoàn thành việc trích xuất Syscall Number của các native API thông qua kỹ thuật Hell's Gate. Tuy nhiên, không phải hàm nào cũng có thể lấy được SSN do một số đã bị EDR hook. Để giải quyết vấn đề này, ta cần áp dụng thêm một kỹ thuật khác – đó là Halo's Gate.

4.Unhooking

Halo's Gate chỉ là một bản vá cho Hell's Gate. Nó dựa vào việc không phải là hàm nào cũng bị hook. Lấy ví dụ:

Có thể thấy rõ hàm NtAllocateVirtualMemory đã bị hook bởi EDR (mov địa chỉ DLL vào rax và jmp đến đó). Nhưng 2 hàm xung quanh nó (NtQueryValueKey và NtQueryInformationProcess) không bị hook và có 2 SSN lần lượt là 17 và 19.

Dựa vào điều này, ta có thể tìm được SSN của các hàm đã bị hook bằng cách tính toán dựa vào các hàm xung quanh nó. Ta sẽ dò ngược về trước hàm bị hook cho đến khi tìm thấy hàm “sạch”, lấy SSN của nó rồi tính ra SSN mà ta cần:

5.Hàm wrapper

Sau khi đã trích xuất được Syscall Number (SSN) và địa chỉ trampoline stub từ ntdll.dll, ta sẽ bắt đầu triển khai 2 kỹ thuật direct syscall và indirect syscall. Mình sẽ sử dụng 3 hàm native là:

  • NtAllocateVirtualMemory
  • NtProtectVirtualMemory
  • NtCreateThreadEx

Đây sẽ là 3 hàm thường bị EDR hook và cần direct hoặc indirect syscall để bypass.

Để thuận tiện cho việc gọi các hàm native này, mình tạo ra các wrapper function giúp chuẩn hóa cách gọi syscall dưới dạng direct và indirect syscall, thay vì viết đi viết lại logic cho từng hàm. Đây là ví dụ wrapper cho NtAllocateVirtualMemory:

6. InDirectSyscall và DirectSyscall

  • Tiếp theo là cần các hàm assembly để thực hiện việc direct và indirect syscall. Các hàm này sẽ thực hiện nhiệm vụ:
  • Xử lý việc chuẩn bị stack frame cho syscall.
  • Nếu hàm có nhiều hơn 4 tham số, nó tự động copy các tham số còn lại từ stack của caller sang stack mới (theo calling convention của Windows x64).
  • Nhờ vậy, syscall luôn nhận đúng số lượng và thứ tự tham số, tránh lỗi khi truyền trực tiếp.
  • Di chuyển Syscall Number (SSN) từ thanh ghi rcx sang rax, đúng theo chuẩn syscall của Windows.
  • Chuẩn bị các thanh ghi rcx, rdx, r8, r9 chứa 4 tham số đầu tiên, theo convention mặc định.
  • Nếu là thực hiện direct syscall, thì có thể thực hiện luôn lệnh syscall, còn nếu là indirect syscall, thì thay vì gọi syscall trực tiếp, hàm sẽ jmp đến địa chỉ stub của hàm gốc trong ntdll.dll (trampoline) theo địa chỉ mà ta đã chuẩn bị trước đó.

Code InDirectSyscall

Code DirectSyscall

7.Demo

Ở đây mình sẽ thưc hiện việc sử dụng indirect syscall để bypass EDR, Ví dụ bên dưới minh họa việc EDR đã hook trực tiếp vào hàm NtAllocateVirtualMemory. Khi chương trình gọi hàm này theo cách thông thường, EDR ngay lập tức phát hiện và đưa ra cảnh báo, đồng thời chặn tiến trình lại:

Tại bước này, mình sẽ tiến hành cấp phát một vùng nhớ trong tiến trình mục tiêu (Notepad++), sau đó ghi shellcode vào và thực thi trực tiếp trong tiến trình đó:

Nếu mà không dùng kỹ thuật mà sử dụng các hàm native thì chắc chắn bị detect ra:

Nhưng khi đổi qua dùng 2 kỹ thuật thì không hề phát hiện ra việc sử dụng:

Reference

https://blog.sektor7.net/#!res/2021/halosgate.md

https://d01a.github.io/syscalls/

https://www.scriptchildie.com/evasion/edr-bypass/2.-userland-hooks/1.-what-are-userland-hooks

https://redops.at/en/blog/direct-syscalls-vs-indirect-syscalls

https://medium.com/@0xcc00/bypassing-bitdefender-antivirus-using-api-unhooking-4fa61d8e0145

https://www.ired.team/offensive-security/defense-evasion/bypassing-cylance-and-other-avs-edrs-by-unhooking-windows-apis

https://www.ired.team/offensive-security/defense-evasion/detecting-hooked-syscall-functions
https://redops.at/en/blog/exploring-hells-gate

By,

Blue_Team.


 

 

 

5 lượt xem