A very well known and documented way of bypassing userland hooks is to make use of direct and indirect system calls (blog posts will follow for these techniques). In order to perform either technique we need to identify the SSN(System Service Numbers) at runtime or hardcode them in our program.
Hardcoding the SSNs has its limitations since they can some times change between Windows updates. So in order to hardcode the SSNs we will need to know exact target system in advance.
A better approach is to use (I highly recommend to read this document first). Using this method we will identify the SSNs at runtime without any prior knowledge of the target system.
Flowchart of the approach:
With the above in mind, let's dive into the code
Get NTDLL.dll base address
Walking / Exploring the is a common method used by shellcoders to dynamically find the location of exported functions. A similar approach will be used to identify the base address of ntdll and subsequently the export functions.
To get a pointer to the PEB the following undocumented function was used.
[DllImport("ntdll.dll", SetLastError : true)]
def RtlGetCurrentPeb() as IntPtr:
pass
What it does it essentially returns a pointer to the beginning of the PEB. Thankfully for us this function is implemented in the windows package.
To view this struct we simply click on Ldr in windbg that generates the following command:
x -r1 ((ntdll!_PEB_LDR_DATA *)0x7ffb0e1143c0)
InMemoryOrderModuleList: The head of a doubly-linked list that contains the loaded modules for the process. Each item in the list is a pointer to an LDR_DATA_TABLE_ENTRY structure.
Once again to view InMemoryOrderMOduleList we just click on it in windbg. The command is generated for us:
Finally to get tge LDR_DATA_TABLE_ENTRY that holds the name and dllbase we run the following command in windbg
dt _LDR_DATA_TABLE_ENTRY (0x1ddbe804af0 -0x10)
where 0x1ddbe804af0 is the Flink address from the previous step. _LDR_DATA_TABLE_ENTRY is at an offset of -0x10
On the screenshot we can see both the module name and base address. To now print the dll address and dll name we run the following commands based on the offsets shown in windbg
Now we can find the dll base address and dll name we can loop through using the forward links until we reach an empty dll name.
Here is the code in go:
// adds all loaded modules and their base addresses in a slice
func ListDllFromPEB() []dllstruct {
peb := windows.RtlGetCurrentPeb()
moduleList := peb.Ldr.InMemoryOrderModuleList
a := moduleList.Flink
loadedModules := []dllstruct{}
for {
listentry := uintptr(unsafe.Pointer(a))
// -0x10 beginning of the _LDR_DATA_TABLE_ENTRY_ structure
// +0x30 Dllbase address
// +0x58 +0x8 address holding the address pointing to base dllname
// offsets different for 32-bit processes
DllBase := uintptr(listentry) - 0x10 + 0x30
BaseDllName := uintptr(listentry) - 0x10 + 0x58 + 0x8
v := *((*uintptr)(unsafe.Pointer(BaseDllName)))
//fmt.Printf("%p\n", (unsafe.Pointer(v))) // prints the address that holds the dll name
s := ((*uint16)(unsafe.Pointer(v))) // turn uintptr to *uint16
dllNameStr := windows.UTF16PtrToString(s)
if dllNameStr == "" {
break
}
dllbaseaddr := *((*uintptr)(unsafe.Pointer(DllBase)))
//fmt.Printf("%p\n", (unsafe.Pointer(dllbaseaddr))) // prints the dll base addr
loadedModules = append(loadedModules, dllstruct{
name: dllNameStr,
address: dllbaseaddr,
exportDirectoryAddress: 0,
exportDirectory: IMAGE_EXPORT_DIRECTORY{Characteristics: 0, TimeDateStamp: 0, MajorVersion: 0, MinorVersion: 0, Name: 0, Base: 0, NumberOfFunctions: 0, NumberOfNames: 0, AddressOfFunctions: 0, AddressOfNames: 0, AddressOfNameOrdinals: 0},
exportedNtFunctions: []Exportfunc{},
exportedZwFunctions: []Exportfunc{},
})
a = a.Flink
}
return loadedModules
}
We essentially save the name and address of each module into a dllstruct structure and return them all in a slice called loadedModules. This function was created with future applications in mind. In this case since we only care about ntdll.dll we could return only that dllstruct.
The following function was created to return the dllstruct needed. It receives the dll name as an argument and returns the struct.
func GetStructOfLoadedDll(name string) (dllstruct, error) {
modules := ListDllFromPEB()
for _, module := range modules {
if module.name == name {
return module, nil
}
}
return dllstruct{}, fmt.Errorf("dll not Found")
}
For debugging purposes we can print the table to console just to ensure that the code generates the expected output.
func PrintModules() {
t := table.NewWriter()
fmt.Printf("---------------------------------------------\nLoaded modules in current process\n")
t.AppendHeader(table.Row{"#", "DLL Name", "Address"})
for i, module := range ListDllFromPEB() {
t.AppendRow(table.Row{i, module.name, fmt.Sprintf("0x%x", module.address)})
}
fmt.Println(t.Render())
}
We can now cross check using windbg using the "lm" command or using ProcessHacker
We can see that the addresses and names match.
t, err := windows.LoadDLL(`C:\Windows\System32\ntdll.dll`)
if err != nil {
return err
}
h := t.Handle
dllBase := uintptr(h)
Identify location of IMAGE_EXPORT_DIRECTORY
Once the base address of the ntdll.dll is identified the next step is to find the location of the image_export_directory. Let's have a look at the contents of the struct.
What this code does is to take the pointers to the memory locations and cast them to Golang types before placing them into the struct
Extract the exported function names, addresses, SSNs, syscall function address and whether there are hooks installed
The next and final step is to get the export function information. A struct was defined with all the required information
type Exportfunc struct {
funcRVA uint32 // relative address to the base address of the dll
functionAddress uintptr // absolute address
name string // name of the exported function
syscallno uint16 // SSN
tramboline uintptr // syscall ;ret; address location
isHooked bool // Is the function hooked?
}
A for loop was used loop through the exported functions
The above code is used to extract the function relative address and then read then function name bytes from memory and turn it to string
Syscall Tramboline
This value will be useful when we write the code for the indirect system calls.
Let's have another look in windbg to identify the byte sequence of a syscall;ret;
From the screenshot above we can see that the byte sequence for a syscall;ret is \x0f\x05\xc3. So when we identify the function address we start a loop to identify the location of the syscall return. I am pretty sure that any syscall from the ntdll could be used without raising any flags by the EDR when we implement indirect syscalls but I decided to find the address of the corresponding syscall instruction for the exported function since it was simple enough.
A kernel-mode driver calls the Zw version of a native system services routine to inform the routine that the parameters come from a trusted, kernel-mode source. In this case, the routine assumes that it can safely use the parameters without first validating them. However, if the parameters might be from either a user-mode source or a kernel-mode source, the driver instead calls the Nt version of the routine, which determines, based on the history of the calling thread, whether the parameters originated in user mode or kernel mode. For more information about how the routine distinguishes user-mode parameters from kernel-mode parameters.
func (dll *dllstruct) PrintExports() {
noPrint := []string{"NtQuerySystemTime", "ZwQuerySystemTime"}
tNt := table.NewWriter()
tNt.AppendHeader(table.Row{"#", "Function Address", "Function Name", "SysCallNo (SSN)", "Tramboline", "Hooked?"})
for i, fun := range dll.exportedNtFunctions {
if slices.Contains(noPrint, fun.name) {
continue
}
tNt.AppendRow(table.Row{i, fmt.Sprintf("0x%x", fun.functionAddress), fun.name, fmt.Sprintf("0x%x", fun.syscallno), fmt.Sprintf("0x%x", fun.tramboline), fun.isHooked})
}
tZw := table.NewWriter()
tZw.AppendHeader(table.Row{"#", "Function Address", "Function Name", "SysCallNo (SSN)", "Tramboline", "Hooked?"})
for i, fun := range dll.exportedZwFunctions {
if slices.Contains(noPrint, fun.name) {
continue
}
tZw.AppendRow(table.Row{i, fmt.Sprintf("0x%x", fun.functionAddress), fun.name, fmt.Sprintf("0x%x", fun.syscallno), fmt.Sprintf("0x%x", fun.tramboline), fun.isHooked})
}
fmt.Println(tNt.Render())
fmt.Println(tZw.Render())
}
Results & Testing against the EDR
We will first run the code against windows defender that doesn't use API hooks to check if the addresses, names, SSNs and trampoline addresses match the functions
The values from the script and the script and windbg are a complete match. Great.
Let's run the script against OpenEDR:
To get only the hooked functions we use the following command
.\unhk.exe | findstr true
- or -
go run . | findstr true
A few APIs were found to be hooked. Let's check in the Debugger if that's the case
Complete Code
package main
import (
"fmt"
"log"
"slices"
"sort"
"strings"
"unsafe"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/sys/windows"
)
type IMAGE_EXPORT_DIRECTORY struct { //offsets
Characteristics uint32 // 0x0
TimeDateStamp uint32 // 0x4
MajorVersion uint16 // 0x8
MinorVersion uint16 // 0xa
Name uint32 // 0xc
Base uint32 // 0x10
NumberOfFunctions uint32 // 0x14
NumberOfNames uint32 // 0x18
AddressOfFunctions uint32 // 0x1c
AddressOfNames uint32 // 0x20
AddressOfNameOrdinals uint32 // 0x24
}
type Exportfunc struct {
funcRVA uint32 // relative address to the base address of the dll
functionAddress uintptr // absolute address
name string // name of the exported function
syscallno uint16 // SSN
trampoline uintptr // syscall ;ret; address location
isHooked bool // Is the function hooked?
}
type dllstruct struct {
name string
address uintptr
exportDirectoryAddress uintptr
exportDirectory IMAGE_EXPORT_DIRECTORY
exportedNtFunctions []Exportfunc
exportedZwFunctions []Exportfunc
}
func main() {
PrintModules()
dll, err := GetStructOfLoadedDll("ntdll.dll")
if err != nil {
log.Fatalln(err)
}
fmt.Printf("\n[+] Base Address of dll %s is 0x%x\n\n", dll.name, dll.address)
fmt.Printf("[+] Export Table Address 0x%x\n\n", dll.getExportTableAddress())
dll.GetImageExportDirectory()
dll.getExportTableAddress()
dll.GetModuleExports()
dll.PrintExports()
}
func (dll *dllstruct) PrintExports() {
noPrint := []string{"NtQuerySystemTime", "ZwQuerySystemTime"}
tNt := table.NewWriter()
tNt.AppendHeader(table.Row{"#", "Function Address", "Function Name", "SysCallNo (SSN)", "Trampoline", "Hooked?"})
for i, fun := range dll.exportedNtFunctions {
if slices.Contains(noPrint, fun.name) {
continue
}
tNt.AppendRow(table.Row{i, fmt.Sprintf("0x%x", fun.functionAddress), fun.name, fmt.Sprintf("0x%x", fun.syscallno), fmt.Sprintf("0x%x", fun.trampoline), fun.isHooked})
}
tZw := table.NewWriter()
tZw.AppendHeader(table.Row{"#", "Function Address", "Function Name", "SysCallNo (SSN)", "Trampoline", "Hooked?"})
for i, fun := range dll.exportedZwFunctions {
if slices.Contains(noPrint, fun.name) {
continue
}
tZw.AppendRow(table.Row{i, fmt.Sprintf("0x%x", fun.functionAddress), fun.name, fmt.Sprintf("0x%x", fun.syscallno), fmt.Sprintf("0x%x", fun.trampoline), fun.isHooked})
}
fmt.Println(tNt.Render())
fmt.Println(tZw.Render())
}
func (fun *Exportfunc) GetSyscallNumbers(address uintptr) {
funcbytes := (*[5]byte)(unsafe.Pointer(fun.functionAddress))[:]
if funcbytes[0] == 0x4c && funcbytes[1] == 0x8b && funcbytes[2] == 0xd1 && funcbytes[3] == 0xb8 { // Check if the function is hooked.
fun.syscallno = *(*uint16)(unsafe.Pointer(&funcbytes[4])) // Get Syscall Number
fun.isHooked = false
} else {
fun.syscallno = 0xffff // when hooked set the syscall number 0xff
fun.isHooked = true
}
//fmt.Printf("Func RVA: %x , nameRVA: %x , name: %s, syscallno : %x\n", exFunc.funcRVA, exFunc.nameRVA, exFunc.name, exFunc.syscallno)
}
func (dll *dllstruct) GetModuleExports() {
exclusions := []string{"NtdllDefWindowProc_A", "NtdllDefWindowProc_W", "NtdllDialogWndProc_A", "NtdllDialogWndProc_W", "NtGetTickCount"}
var absAddress uintptr
for i := 0; i < int(dll.exportDirectory.NumberOfNames); i++ {
funcRVA := *((*uint32)(unsafe.Pointer(dll.address + (uintptr(dll.exportDirectory.AddressOfFunctions) + uintptr((i+1)*0x4)))))
nameRVA := *((*uint32)(unsafe.Pointer(dll.address + (uintptr(dll.exportDirectory.AddressOfNames) + uintptr(i*0x4)))))
nameAddr := dll.address + uintptr(nameRVA)
nameRVAbyte := (*[4]byte)(unsafe.Pointer(nameAddr))[:]
name := windows.BytePtrToString(&nameRVAbyte[0])
absAddress = dll.address + uintptr(funcRVA)
for j := 0; j < 100; j++ {
if *(*byte)(unsafe.Pointer(absAddress)) == 0x0f {
if *(*byte)(unsafe.Pointer(absAddress + 1)) == 0x05 {
if *(*byte)(unsafe.Pointer(absAddress + 2)) == 0xc3 {
break
}
}
}
absAddress += 1
}
if strings.HasPrefix(name, "Nt") && !slices.Contains(exclusions, name) {
funcExp := Exportfunc{
funcRVA: funcRVA,
functionAddress: dll.address + uintptr(funcRVA),
name: name,
trampoline: absAddress,
}
funcExp.GetSyscallNumbers(dll.address)
dll.exportedNtFunctions = append(dll.exportedNtFunctions, funcExp)
}
if strings.HasPrefix(name, "Zw") {
funcExp := Exportfunc{
funcRVA: funcRVA,
functionAddress: dll.address + uintptr(funcRVA),
name: name,
trampoline: absAddress,
}
funcExp.GetSyscallNumbers(dll.address)
dll.exportedZwFunctions = append(dll.exportedZwFunctions, funcExp)
}
}
sort.SliceStable(dll.exportedNtFunctions, func(i, j int) bool {
return (dll.exportedNtFunctions)[i].funcRVA < (dll.exportedNtFunctions)[j].funcRVA
})
sort.SliceStable(dll.exportedZwFunctions, func(i, j int) bool {
return (dll.exportedZwFunctions)[i].funcRVA < (dll.exportedZwFunctions)[j].funcRVA
})
}
// Get Image Export directory. We are interested in
// - AddressofFunctions
// - AddressOfNames
// - AddressOFNameOrdinals (maybe in the future)
// - Number of functions
func (dll *dllstruct) GetImageExportDirectory() {
dll.exportDirectory.Characteristics = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress)))
dll.exportDirectory.TimeDateStamp = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0x4)))
dll.exportDirectory.MajorVersion = *((*uint16)(unsafe.Pointer(dll.exportDirectoryAddress + 0x8)))
dll.exportDirectory.MinorVersion = *((*uint16)(unsafe.Pointer(dll.exportDirectoryAddress + 0xa)))
dll.exportDirectory.Name = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0xc)))
dll.exportDirectory.Base = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0x10)))
dll.exportDirectory.NumberOfFunctions = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0x14)))
dll.exportDirectory.NumberOfNames = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0x18)))
dll.exportDirectory.AddressOfFunctions = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0x1c)))
dll.exportDirectory.AddressOfNames = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0x20)))
dll.exportDirectory.AddressOfNameOrdinals = *((*uint32)(unsafe.Pointer(dll.exportDirectoryAddress + 0x24)))
}
func (dll *dllstruct) getExportTableAddress() uintptr {
e_lfanew := *((*uint32)(unsafe.Pointer(dll.address + 0x3c)))
ntHeader := dll.address + uintptr(e_lfanew)
fileHeader := ntHeader + 0x4
// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_file_header
optionalHeader := fileHeader + 0x14 // 0x14 is the size of the image_file_header struct
exportDir := optionalHeader + 0x70 // offset to export table
exportDirOffset := *((*uint32)(unsafe.Pointer(exportDir)))
dll.exportDirectoryAddress = dll.address + uintptr(exportDirOffset)
return dll.exportDirectoryAddress
}
func GetStructOfLoadedDll(name string) (dllstruct, error) {
modules := ListDllFromPEB()
for _, module := range modules {
if module.name == name {
return module, nil
}
}
return dllstruct{}, fmt.Errorf("dll not Found")
}
func PrintModules() {
t := table.NewWriter()
fmt.Printf("---------------------------------------------\nLoaded modules in current process\n")
t.AppendHeader(table.Row{"#", "DLL Name", "Address"})
for i, module := range ListDllFromPEB() {
t.AppendRow(table.Row{i, module.name, fmt.Sprintf("0x%x", module.address)})
}
fmt.Println(t.Render())
}
// adds all loaded modules and their base addresses in a slice
func ListDllFromPEB() []dllstruct {
peb := windows.RtlGetCurrentPeb()
moduleList := peb.Ldr.InMemoryOrderModuleList
a := moduleList.Flink
loadedModules := []dllstruct{}
for {
listentry := uintptr(unsafe.Pointer(a))
// -0x10 beginning of the _LDR_DATA_TABLE_ENTRY_ structure
// +0x30 Dllbase address
// +0x58 +0x8 address holding the address pointing to base dllname
// offsets different for 32-bit processes
DllBase := uintptr(listentry) - 0x10 + 0x30
BaseDllName := uintptr(listentry) - 0x10 + 0x58 + 0x8
v := *((*uintptr)(unsafe.Pointer(BaseDllName)))
//fmt.Printf("%p\n", (unsafe.Pointer(v))) // prints the address that holds the dll name
s := ((*uint16)(unsafe.Pointer(v))) // turn uintptr to *uint16
dllNameStr := windows.UTF16PtrToString(s)
if dllNameStr == "" {
break
}
dllbaseaddr := *((*uintptr)(unsafe.Pointer(DllBase)))
//fmt.Printf("%p\n", (unsafe.Pointer(dllbaseaddr))) // prints the dll base addr
loadedModules = append(loadedModules, dllstruct{
name: dllNameStr,
address: dllbaseaddr,
exportDirectoryAddress: 0,
exportDirectory: IMAGE_EXPORT_DIRECTORY{Characteristics: 0, TimeDateStamp: 0, MajorVersion: 0, MinorVersion: 0, Name: 0, Base: 0, NumberOfFunctions: 0, NumberOfNames: 0, AddressOfFunctions: 0, AddressOfNames: 0, AddressOfNameOrdinals: 0},
exportedNtFunctions: []Exportfunc{},
exportedZwFunctions: []Exportfunc{},
})
a = a.Flink
}
return loadedModules
}
We are particularly interested in the
The structure is defined as follows:
All the above could be replaced by the following code ! What if the LoadDLL function is hooked though ?
Similarly to the techniques used in we can get the export directory from the Optional Header.
: At location 0x3c, the stub has the file offset to the PE signature.
: 4-bytes (PE00)
: (2+2+4+4+4+2+2) 20-bytes (0x14)
: Offset to Export Directory 0x70
, each native system services routine has two slightly different versions that have similar names but different prefixes. For example, calls to NtCreateFile and ZwCreateFile perform similar operations and are, in fact, serviced by the same kernel-mode system routine. For system calls from user mode, the Nt and Zw versions of a routine behave identically. For calls from a kernel-mode driver, the Nt and Zw versions of a routine differ in how they handle the parameter values that the caller passes to the routine.
We will split these functions into two slices. It will later allow us to sort these functions and unhook them using the .