syscall的前世今生

本文首发于跳跳糖社区,原文链接:https://tttang.com/archive/1464/

前言

学免杀不可避免的学习syscall,虽然是几年前的技术,还是有必要学习。学习新技术之前先掌握旧技术。

PEB(Process Envirorment Block Structure)

进程环境信息块,是一个从内核中分配给每个进程的用户模式结构,每一个进程都会有从ring0分配给该进程的进程环境块,后续我们主要需要了解_PEB_LDR_DATA以及其他子结构

这张图是x86系统的结构体

img

FS段寄存器指向当前的TEB结构,可以看到PEB在TEB的0x30偏移处

微软并没有定义PEB结构,因此需要我们自定义

PEB的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
typedef struct _PEB {
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
BOOLEAN Spare;
HANDLE Mutant;
PVOID ImageBase;
PPEB_LDR_DATA LoaderData;
PVOID ProcessParameters;
PVOID SubSystemData;
PVOID ProcessHeap;
PVOID FastPebLock;
PVOID FastPebLockRoutine;
PVOID FastPebUnlockRoutine;
ULONG EnvironmentUpdateCount;
PVOID* KernelCallbackTable;
PVOID EventLogSection;
PVOID EventLog;
PVOID FreeList;
ULONG TlsExpansionCounter;
PVOID TlsBitmap;
ULONG TlsBitmapBits[0x2];
PVOID ReadOnlySharedMemoryBase;
PVOID ReadOnlySharedMemoryHeap;
PVOID* ReadOnlyStaticServerData;
PVOID AnsiCodePageData;
PVOID OemCodePageData;
PVOID UnicodeCaseTableData;
ULONG NumberOfProcessors;
ULONG NtGlobalFlag;
BYTE Spare2[0x4];
LARGE_INTEGER CriticalSectionTimeout;
ULONG HeapSegmentReserve;
ULONG HeapSegmentCommit;
ULONG HeapDeCommitTotalFreeThreshold;
ULONG HeapDeCommitFreeBlockThreshold;
ULONG NumberOfHeaps;
ULONG MaximumNumberOfHeaps;
PVOID** ProcessHeaps;
PVOID GdiSharedHandleTable;
PVOID ProcessStarterHelper;
PVOID GdiDCAttributeList;
PVOID LoaderLock;
ULONG OSMajorVersion;
ULONG OSMinorVersion;
ULONG OSBuildNumber;
ULONG OSPlatformId;
ULONG ImageSubSystem;
ULONG ImageSubSystemMajorVersion;
ULONG ImageSubSystemMinorVersion;
ULONG GdiHandleBuffer[0x22];
ULONG PostProcessInitRoutine;
ULONG TlsExpansionBitmap;
BYTE TlsExpansionBitmapBits[0x80];
ULONG SessionId;
} PEB, * PPEB;

在PEB中的0x0c处为一指针,指向PEB_LDR_DATA结构,该结构体包含有关为进程加载的模块的信息(存储着该进程所有模块数据的链表)。

PEB_LDR_DATA的0x0c,0x14,0x1c中为三个双向链表LIST_ENTRY,在struct _LDR_MODULE的0x00,0x08和0x10处是三个对应的同名称的LIST_ENTRY, PEB_LDR_DATAstruct _LDR_MODULE就是通过这三个LIST_ENTRY对应连接起来的。

三个双向链表分别代表模块加载顺序,模块在内存中的加载顺序以及模块初始化装载的顺序

1
2
3
4
5
6
7
8
typedef struct _PEB_LDR_DATA {
ULONG Length;
ULONG Initialized;
PVOID SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
} PEB_LDR_DATA, * PPEB_LDR_DATA;

双向链表 LIST_ENTRY的定义如下

1
2
3
4
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;

每个双向链表都是指向进程装载的模块,结构中的每个指针,指向了一个LDR_DATA_TABLE_ENTRY的结构:

这个结构很重要,提供了内存模块的基址和dll名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _LDR_MODULE {
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
PVOID BaseAddress;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
SHORT LoadCount;
SHORT TlsIndex;
LIST_ENTRY HashTableEntry;
ULONG TimeDateStamp;
} LDR_MODULE, * PLDR_MODULE;

微软给出了如何检索PEB得到偏移地址 32位和64位

__readgsbyte, __readgsdword, __readgsqword, __readgsword | Microsoft Docs

__readfsbyte, __readfsdword, __readfsqword, __readfsword | Microsoft Docs

图片

syscall

基础概念

Windows下有两种处理器访问模式:用户模式(user mode)和内核模式(kernel mode)。用户模式下运行应用程序时,Windows 会为该程序创建一个新进程,提供一个私有虚拟地址空间和一个私有句柄表,因为私有,一个应用程序无法修改另一个应用程序的私有虚拟地址空间的数据;内核模式下,所有运行的代码都共享一个虚拟地址空间, 因此内核中驱动程序可能还会因为写入错误的地址空间导致其他驱动程序甚至系统出现错误。

内核中包含了大部分操作系统的内部数据结构,所以用户模式下的应用程序在访问这些数据结构或调用内部Windows例程以执行特权操作的时候,必须先从用户模式切换到内核模式,这里就涉及到系统调用。

x86 windows 使用 sysenter 实现系统调用。

x64 windows 使用 syscall 实现系统调用。

目前syscall已经成为了绕过AV/EDR所使用的主流方式,可以用它绕过一些敏感函数的调用监控(R3)。主流的AV/EDR都会对敏感函数进行HOOK,而syscall则可以用来绕过该类检测。

不同操作系统版本之间syscall number不同。可以参考https://j00ru.vexillium.org/syscalls/nt/64/

使用syscall的前提:

1
2
3
4
不使用GetModuleHandle找到ntdll的基址 
解析DLL的导出表
查找syscall number
执行syscall
ntdll

该网站解析了ntdll.dll的pe

http://undocumented.ntinternals.net/

在win10中,除了minimal和pico进程外,所有用户态的进程默认情况下都隐式链接到ntdll.dll

一般情况下,ntdll.dll在内存中的第二个模块,kernel32.dll是第三个模块。然而有些杀软会改变内存中的模块顺序列表,因此我们需要先确定指向的内存模块是ntdll.dll。如果访问了错误的模块,很显然不会加载任何功能。

可以简单的看几个ntdll.dll中的函数。注意到他们的调用.后面讲到地狱之门的时候会谈到这个调用

1
2
3
mov 	r10,rcx
mov eax,xxh
syscall

看几个ntdll.dll中的函数了解syscall的调用,如上文一样

ntCreateThread

image-20220302172656771

ntCreateProcess

image-20220302172726585

ntAllocateVirtualMemory

image-20220302172844674

可以看到NT函数的调用都差不多,差别就在传入 eax 寄存器的值不同,这里存储的是系统调用号,基于 eax 所存储的值的不同,syscall 进入内核调用的内核函数也不同。

代码分析

我们可以用代码来解析ntdll

首先获取内存模块的基地址

image-20220303162154007

我们的目标是获得模块导出地址表。步骤如下

1
2
3
4
1.找到模块基地址 
2.获取 IMAGE_DOS_HEADER 并检查 IMAGE_DOS_SIGNATURE 来验证dos头
3.遍历 IMAGE_NT_HEADER , IMAGE_FILE_HEADER , IMAGE_OPTIONAL_HEADER
4.在IMAGE_OPTIONAL_HEADER中找到导出地址表,它在 IMAGE_DATA_DIRECTORY 中,我们将他类型转换为IMAGE_EXPORT_DIRECTORY

代码实现,参考cripsr师傅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PBYTE ImageBase;
PIMAGE_DOS_HEADER Dos = NULL;
PIMAGE_NT_HEADERS Nt = NULL;
PIMAGE_FILE_HEADER File = NULL;
PIMAGE_OPTIONAL_HEADER Optional = NULL;
PIMAGE_EXPORT_DIRECTORY ExportTable = NULL;

PPEB Peb = (PPEB)__readgsqword(0x60);
PLDR_MODULE pLoadModule;
pLoadModule = (PLDR_MODULE)((PBYTE)Peb->LoaderData->InMemoryOrderModuleList.Flink - 0x10);
ImageBase = (PBYTE)pLoadModule->BaseAddress;
Dos = (PIMAGE_DOS_HEADER)ImageBase;
if (Dos->e_magic != IMAGE_DOS_SIGNATURE)
return 1;
Nt = (PIMAGE_NT_HEADERS)((PBYTE)Dos + Dos->e_lfanew);
File = (PIMAGE_FILE_HEADER)(ImageBase + (Dos->e_lfanew + sizeof(DWORD)));
Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File + sizeof(IMAGE_FILE_HEADER));
ExportTable = (PIMAGE_EXPORT_DIRECTORY)(ImageBase + Optional->DataDirectory[0].VirtualAddress);

当我们得到NT_Header时,由于PE头文件的数据结构已经给出,其前四个字节为一个DWORD类型的Signature,因此加上这四个字节就会得到FileHeader,然后在此基础上加上FileHeader数据结构所占大小最终得到Optional,只需要将其类型转为_IMAGE_EXPORT_DIRECTORY就得到了我们的导出地址表,再通过:

1
2
3
1.FunctionNameAddressArray 一个包含函数名称的数组
2.FunctionOrdinalAddressArray 充当函数寻址数组的索引
3.FunctionAddressArray 一个包含函数地址的数组

得到ntdll的导出函数及地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int GetPeHeader()
{
PBYTE ImageBase;
PIMAGE_DOS_HEADER Dos = NULL;
PIMAGE_NT_HEADERS Nt = NULL;
PIMAGE_FILE_HEADER File = NULL;
PIMAGE_OPTIONAL_HEADER Optional = NULL;
PIMAGE_EXPORT_DIRECTORY ExportTable = NULL;

PPEB Peb = (PPEB)__readgsqword(0x60);
PLDR_MODULE pLoadModule;
// NTDLL
pLoadModule = (PLDR_MODULE)((PBYTE)Peb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
ImageBase = (PBYTE)pLoadModule->BaseAddress;

Dos = (PIMAGE_DOS_HEADER)ImageBase;
if (Dos->e_magic != IMAGE_DOS_SIGNATURE)
return 1;
Nt = (PIMAGE_NT_HEADERS)((PBYTE)Dos + Dos->e_lfanew);
File = (PIMAGE_FILE_HEADER)(ImageBase + (Dos->e_lfanew + sizeof(DWORD)));
Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File + sizeof(IMAGE_FILE_HEADER));
ExportTable = (PIMAGE_EXPORT_DIRECTORY)(ImageBase + Optional->DataDirectory[0].VirtualAddress);

PDWORD pdwAddressOfFunctions = (PDWORD)((PBYTE)(ImageBase + ExportTable->AddressOfFunctions));
PDWORD pdwAddressOfNames = (PDWORD)((PBYTE)ImageBase + ExportTable->AddressOfNames);
PWORD pwAddressOfNameOrdinales = (PWORD)((PBYTE)ImageBase + ExportTable-> AddressOfNameOrdinals);
for (WORD cx = 0; cx < ExportTable->NumberOfNames; cx++)
{
PCHAR pczFunctionName = (PCHAR)((PBYTE)ImageBase + pdwAddressOfNames[cx]);
PVOID pFunctionAddress = (PBYTE)ImageBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]];
printf("Function Name:%s\tFunction Address:%p\n", pczFunctionName, pFunctionAddress);
}
}

image-20220303165921255

syscall项目

为什么有地狱之门

在创建R3进程的时候,EDR会hook用户层的相关windows API调用,从而完成对进程动态行为的监控。比如,hook VirtualAlloc,监控内存分配。hook CreateProcess,监控进程创建。可以在用户层完成hook,也可以在内核层hook。用户层hook的好处是对性能的影响较小,相对于内核层hook更稳定,不容易导致系统蓝屏,所以很多EDR会选择在用户层hook,同时在内核层使用回调监控重要的内核api调用。一个进程分配了RWX属性的内存,或者修改了内存属性,将RW的内存修改为了RWX,由于RWX内存属性是shellcode或反射型DLL加载所用的内存属性,因此EDR会对申请的内存进行扫描,匹配到恶意软件的yara规则后,将会杀死恶意进程,并向控制中心发送告警。

因此地狱之门出现了,为了避免在用户层被EDR hook的敏感函数检测到敏感行为,利用从ntdll中读取到的系统调用号进行系统直接调用来绕过敏感API函数的hook。主要来应对 EDR 对 Ring3 API 的 HOOK,不同版本的 Windows Ntxxx 函数的系统调用号不同,且调用时需要逆向各 API 的结构方便调用。

网上也出了几个项目

Hell’s Gate:地狱之门

https://github.com/am0nsec/HellsGate/

原理:通过直接读取进程第二个导入模块即NtDLL,解析结构然后遍历导出表,根据函数名Hash找到函数地址,将这个函数读取出来通过0xb8这个操作码来动态获取对应的系统调用号,从而绕过内存监控,在自己程序中执行了NTDLL的导出函数而不是直接通过LoadLibrary然后GetProcAddress

其中生成的宏汇编代码(MASM)来定义执行通过系统调用号调用NT函数的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.data
wSystemCall DWORD 000h

.code
HellsGate PROC
mov wSystemCall, 000h
mov wSystemCall, ecx
ret
HellsGate ENDP

HellDescent PROC
mov r10, rcx
mov eax, wSystemCall

syscall
ret
HellDescent ENDP
end

这里执行payload

image-20220303172723877

SysWhisoers2

https://github.com/jthuraisamy/SysWhispers2

image-20220302180209698

SysWhisoers2很方便,用python脚本生成需要的文件,通过包含头文件就可以syscall。

Syswhispers2 相比于一代版本,不再需要指定 Windows 版本,也不依赖于以往的系统调用表,没有从磁盘读取 Ntdll.ll 无惧 Hook,这里仅仅获取 Syscall Number,进三环部分自己实现.

SW2_PopulateSyscallList函数其具体含义是先解析 Ntdll.dll 的 导出地址表 EAT,定位所有以 “Zw” 开头的函数,最后按地址从小到大进行排序。代码太长这里就不贴了,项目里有

另一个函数是 SW2_GetSyscallNumber,这个函数循环遍历 SW2_PopulateSyscallList 的数组,如果 Hash 相等就返回 循环的值 作为 SyscallNumber。

image-20220304010508030

Halo’s Gate:光环之门

https://github.com/boku7/AsmHalosGate

地狱之门实现了动态的系统调用,amazing,但也有一点缺点,如果访问的那块内存不是ntdll.dll呢,即ntdll.dll被修改了,Nt*函数被hook。这个时候地狱之门就会失效,这个时候出现了光环之门

原理:EDR不可能HOOK全部的Nt*函数,总有一些不敏感的函数没有被HOOK,程序当中以STUB_SIZE(这个长度为32)上下循环遍历,找到没有被HOOK的STUB后获取其系统调用号再减去移动的步数,就是所要搜索的系统调用号。这种方式的优点是可以避免所要搜索的函数被hook之后,程序直接返回。

TartarusGate:

https://github.com/trickster0/TartarusGate

这个项目的作者声称是对光环之门的加强,只是检测第一个字节和第四个字节是否是0xe9,来判断函数是否被hook,个人感觉意义并不是很大,在光环之门中已经实现了 遍历相邻的Nt*函数来搜索系统调用号,多出来的check字节,有点锦上添花。

image-20220303214153418

另外在asm中做了一些nop混淆,方便syscall

image-20220303213710095

image-20220303213729361

Spoofing-Gate:欺骗之门

https://github.com/timwhitez/Spoofing-Gate

主要原理是当使用halos gate/hells gate获取到sysid后 从ntdll中随机选择未用到的Nt api, 替换其sysid为获取到的sysid即可直接call。 在设计上面调用时加入了一个排除项可以输入[]string类型以避免api调用冲突, 内置的Nt api list已经排除了EDRs项目中被hook的api ,内置的Nt api list排除了部分可能影响到正常执行的api 返回的结构体实现了Revover()函数可以直接恢复原sysid 。缺点是必须修改ntdll,有两种方式:(NtProtect+memcpy/WriteProcessMemory) 作者选用的是后者,在写入前后会自动修改protect值

ParallelSyscalls

https://github.com/mdsecactivebreach/ParallelSyscalls

https://www.mdsec.co.uk/2022/01/edr-parallel-asis-through-analysis/

该项目的亮点是使用syscall从磁盘读取ntdll.dll,最后一步利用 LdrpThunkSignature 恢复系统调用。还涉及到dll的并行加载,并行加载允许进程执行递归映射通过进程模块导入表导入的 DLL 的过程,而不是在单个线程上同步 , 从而在初始应用程序启动期间提高性能。

LdrpThunkSignature 恢复系统调用实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
BOOL InitSyscallsFromLdrpThunkSignature()
{
PPEB Peb = (PPEB)__readgsqword(0x60);
PPEB_LDR_DATA Ldr = Peb->Ldr;
PLDR_DATA_TABLE_ENTRY NtdllLdrEntry = NULL;

for (PLDR_DATA_TABLE_ENTRY LdrEntry = (PLDR_DATA_TABLE_ENTRY)Ldr->InLoadOrderModuleList.Flink;
LdrEntry->DllBase != NULL;
LdrEntry = (PLDR_DATA_TABLE_ENTRY)LdrEntry->InLoadOrderLinks.Flink)
{
if (_wcsnicmp(LdrEntry->BaseDllName.Buffer, L"ntdll.dll", 9) == 0)
{
// got ntdll
NtdllLdrEntry = LdrEntry;
break;
}
}

if (NtdllLdrEntry == NULL)
{
return FALSE;
}

PIMAGE_NT_HEADERS ImageNtHeaders = (PIMAGE_NT_HEADERS)((ULONG_PTR)NtdllLdrEntry->DllBase + ((PIMAGE_DOS_HEADER)NtdllLdrEntry->DllBase)->e_lfanew);
PIMAGE_SECTION_HEADER SectionHeader = (PIMAGE_SECTION_HEADER)((ULONG_PTR)&ImageNtHeaders->OptionalHeader + ImageNtHeaders->FileHeader.SizeOfOptionalHeader);

ULONG_PTR DataSectionAddress = NULL;
DWORD DataSectionSize;

for (WORD i = 0; i < ImageNtHeaders->FileHeader.NumberOfSections; i++)
{
if (!strcmp((char*)SectionHeader[i].Name, ".data"))
{
DataSectionAddress = (ULONG_PTR)NtdllLdrEntry->DllBase + SectionHeader[i].VirtualAddress;
DataSectionSize = SectionHeader[i].Misc.VirtualSize;
break;
}
}

DWORD dwSyscallNo_NtOpenFile = 0, dwSyscallNo_NtCreateSection = 0, dwSyscallNo_NtMapViewOfSection = 0;

if (!DataSectionAddress || DataSectionSize < 16 * 5)
{
return FALSE;
}

for (UINT uiOffset = 0; uiOffset < DataSectionSize - (16 * 5); uiOffset++)
{
if (*(DWORD*)(DataSectionAddress + uiOffset) == 0xb8d18b4c &&
*(DWORD*)(DataSectionAddress + uiOffset + 16) == 0xb8d18b4c &&
*(DWORD*)(DataSectionAddress + uiOffset + 32) == 0xb8d18b4c &&
*(DWORD*)(DataSectionAddress + uiOffset + 48) == 0xb8d18b4c &&
*(DWORD*)(DataSectionAddress + uiOffset + 64) == 0xb8d18b4c)
{
dwSyscallNo_NtOpenFile = *(DWORD*)(DataSectionAddress + uiOffset + 4);
dwSyscallNo_NtCreateSection = *(DWORD*)(DataSectionAddress + uiOffset + 16 + 4);
dwSyscallNo_NtMapViewOfSection = *(DWORD*)(DataSectionAddress + uiOffset + 64 + 4);
break;
}
}

if (!dwSyscallNo_NtOpenFile)
{
return FALSE;
}

ULONG_PTR SyscallRegion = (ULONG_PTR)VirtualAlloc(NULL, 3 * MAX_SYSCALL_STUB_SIZE, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);

if (!SyscallRegion)
{
return FALSE;
}

NtOpenFile = (FUNC_NTOPENFILE)BuildSyscallStub(SyscallRegion, dwSyscallNo_NtOpenFile);
NtCreateSection = (FUNC_NTCREATESECTION)BuildSyscallStub(SyscallRegion + MAX_SYSCALL_STUB_SIZE, dwSyscallNo_NtCreateSection);
NtMapViewOfSection = (FUNC_NTMAPVIEWOFSECTION)BuildSyscallStub(SyscallRegion + (2* MAX_SYSCALL_STUB_SIZE), dwSyscallNo_NtMapViewOfSection);

return TRUE;
}
GetSSN

这里还介绍一种更加方便简单和迅速的方法来发现SSN(syscall number),这种方法不需要unhook,不需要手动从代码存根中读取,也不需要加载NTDLL新副本,可以将它理解成为光环之门的延伸,试想当上下的邻函数都被Hook时,光环之门的做法是继续递归,在不断的寻找没有被Hook的邻函数,而在这里假设一种最坏的情况是所有的邻函数(指Nt*函数)都被Hook时,那最后将会向上递归到SSN=0的Nt函数。

其实可以理解为系统调用的存根重新实现 + 动态 SSN 解析

首先我们需要知道:

1
2
1.实际上所有的Zw函数和Nt同名函数实际上是等价的
2.系统调用号实际上是和Zw函数按照地址顺序的排列是一样的

因此我们就只需要遍历所有Zw函数,记录其函数名和函数地址,最后将其按照函数地址升序排列后,每个函数的SSN就是其对应的排列顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
int GetSSN()
{
std::map<int, string> Nt_Table;
PBYTE ImageBase;
PIMAGE_DOS_HEADER Dos = NULL;
PIMAGE_NT_HEADERS Nt = NULL;
PIMAGE_FILE_HEADER File = NULL;
PIMAGE_OPTIONAL_HEADER Optional = NULL;
PIMAGE_EXPORT_DIRECTORY ExportTable = NULL;

PPEB Peb = (PPEB)__readgsqword(0x60);
PLDR_MODULE pLoadModule;
// NTDLL
pLoadModule = (PLDR_MODULE)((PBYTE)Peb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
ImageBase = (PBYTE)pLoadModule->BaseAddress;

Dos = (PIMAGE_DOS_HEADER)ImageBase;
if (Dos->e_magic != IMAGE_DOS_SIGNATURE)
return 1;
Nt = (PIMAGE_NT_HEADERS)((PBYTE)Dos + Dos->e_lfanew);
File = (PIMAGE_FILE_HEADER)(ImageBase + (Dos->e_lfanew + sizeof(DWORD)));
Optional = (PIMAGE_OPTIONAL_HEADER)((PBYTE)File + sizeof(IMAGE_FILE_HEADER));
ExportTable = (PIMAGE_EXPORT_DIRECTORY)(ImageBase + Optional->DataDirectory[0].VirtualAddress);

PDWORD pdwAddressOfFunctions = (PDWORD)((PBYTE)(ImageBase + ExportTable->AddressOfFunctions));
PDWORD pdwAddressOfNames = (PDWORD)((PBYTE)ImageBase + ExportTable->AddressOfNames);
PWORD pwAddressOfNameOrdinales = (PWORD)((PBYTE)ImageBase + ExportTable->AddressOfNameOrdinals);
for (WORD cx = 0; cx < ExportTable->NumberOfNames; cx++)
{
PCHAR pczFunctionName = (PCHAR)((PBYTE)ImageBase + pdwAddressOfNames[cx]);
PVOID pFunctionAddress = (PBYTE)ImageBase + pdwAddressOfFunctions[pwAddressOfNameOrdinales[cx]];
if (strncmp((char*)pczFunctionName, "Zw",2) == 0) {
printf("Function Name:%s\tFunction Address:%p\n", pczFunctionName, pFunctionAddress);
Nt_Table[(int)pFunctionAddress] = (string)pczFunctionName;
}
}
int index = 0;
for (std::map<int, string>::iterator iter = Nt_Table.begin(); iter != Nt_Table.end(); ++iter) {
cout << "index:" << index << ' ' << iter->second << endl;
index += 1;
}
}
SysWhispers3

klezVirus/SysWhispers3: AV/EDR通过直接系统调用规避。 (github.com)

在做完之前的笔记不久,又出现了新的 SysWhisper3 ,重点解决了 SysWhisper2中syscall指令被查杀,syscall不是从ntdll发出这两个问题。

这里可以与之前的项目比较,其中有个叫EGG的手段,先用垃圾指令替代syscall,在运行的时候从内存中找出来替换syscall。

前面也提到了,可以用int2e来替换syscall指令,但AV也不是傻子,加一条规则就可以检测到。

这里的egg hunt,使用“DB”来定义一个字节的汇编指令,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
NtAllocateVirtualMemory PROC
mov [rsp +8], rcx ; Save registers.
mov [rsp+16], rdx
mov [rsp+24], r8
mov [rsp+32], r9
sub rsp, 28h
mov ecx, 003970B07h ; Load function hash into ECX.
call SW2_GetSyscallNumber ; Resolve function hash into syscall number.
add rsp, 28h
mov rcx, [rsp +8] ; Restore registers.
mov rdx, [rsp+16]
mov r8, [rsp+24]
mov r9, [rsp+32]
mov r10, rcx
DB 77h ; "w"
DB 0h ; "0"
DB 0h ; "0"
DB 74h ; "t"
DB 77h ; "w"
DB 0h ; "0"
DB 0h ; "0"
DB 74h ; "t"
ret
NtAllocateVirtualMemory ENDP

但是在实际使用中这种方式程序会出错,因为该函数只为了syscall的调用和返回提供了堆栈,没有释放,

因此作者用下面的函数来进行转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
void FindAndReplace(unsigned char egg[], unsigned char replace[])
{

ULONG64 startAddress = 0;
ULONG64 size = 0;

GetMainModuleInformation(&startAddress, &size);

if (size <= 0) {
printf("[-] Error detecting main module size");
exit(1);
}

ULONG64 currentOffset = 0;

unsigned char* current = (unsigned char*)malloc(8*sizeof(unsigned char*));
size_t nBytesRead;

printf("Starting search from: 0x%llu\n", (ULONG64)startAddress + currentOffset);

while (currentOffset < size - 8)
{
currentOffset++;
LPVOID currentAddress = (LPVOID)(startAddress + currentOffset);
if(DEBUG > 0){
printf("Searching at 0x%llu\n", (ULONG64)currentAddress);
}
if (!ReadProcessMemory((HANDLE)((int)-1), currentAddress, current, 8, &nBytesRead)) {
printf("[-] Error reading from memory\n");
exit(1);
}
if (nBytesRead != 8) {
printf("[-] Error reading from memory\n");
continue;
}

if(DEBUG > 0){
for (int i = 0; i < nBytesRead; i++){
printf("%02x ", current[i]);
}
printf("\n");
}

if (memcmp(egg, current, 8) == 0)
{
printf("Found at %llu\n", (ULONG64)currentAddress);
WriteProcessMemory((HANDLE)((int)-1), currentAddress, replace, 8, &nBytesRead);
}

}
printf("Ended search at: 0x%llu\n", (ULONG64)startAddress + currentOffset);
free(current);
}

使用demo

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(int argc, char** argv) {

unsigned char egg[] = { 0x77, 0x00, 0x00, 0x74, 0x77, 0x00, 0x00, 0x74 };
// w00tw00t
unsigned char replace[] = { 0x0f, 0x05, 0x90, 0x90, 0xC3, 0x90, 0xCC, 0xCC };
// syscall; nop; nop; ret; nop; int3; int3

//####SELF_TAMPERING####
(egg, replace);

Inject();
return 0;
}

当然,这样还是被检测到。这是因为edr不仅检测syscall的字符,还检测syscall执行特定指令的位置。即如果合理的话,syscall本应在ntdll中调用。

下图显示了当api调用syscall的正常流程。

image-20220310014525396

而当 SysWhispers 调用函数时,syscall指令直接在程序的主模块中执行,如图

image-20220310014618135

这个区别也是EDR检测的特征,即RIP指针指向的不同

作者也提供了绕过方式,即在运行的时候从内存中动态找出替换syscall

实现逻辑:

添加一个ULONG64字段来存储syscall指令绝对地址。有了这个集合,当_SW2_SYSCALL_LIST被填充时,计算 syscall指令的地址。在这种情况下,我们已经有了ntdll.dll基地址,SysWhispers 还从 DLL EAT(导出地址表)计算函数 RVA。最后**jmp syscall

**因此,唯一需要计算的是syscall指令的相对位置并进行一些数学运算。

伪代码实现:

1
2
3
4
5
6
7
8
9
10
function findOffset(HANDLE current_process, int64 start_address, int64 dllSize) -> int64:
int64 offset = 0
bytes signature = "\x0f\x05\x03"
bytes currentbytes = ""
while currentbytes != signature:
offset++
if offset + 3 > dllSize:
return INFINITE
ReadProcessMemory(current_process, start_address + offset, &currentbytes, 3, nullptr)
return start_address + offset

检测syscall

利用syscall的文章、项目很多,但检测syscall的却很少见,这里浅谈一下如何检测

ntdll.dll中的系统调用都遵循代码结构

1
2
3
4
mov r10, rcx
mov eax, *syscall number*
syscall
ret

如果仅仅根据特征码来检测,比如检测 mov r10, rcx,很显然不可行,很容易就被bypass,比如

1
2
mov r11,rcx
mov r10,r11
磁盘检测
1
objdump --disassemble -M intel Outflank-Dumpert.exe | grep "syscall"

输出:

1
2
3
4
5
6
7
140013438:   0f 05                   syscall
140013443: 0f 05 syscall
14001344e: 0f 05 syscall
[truncated]
14001356c: 0f 05 syscall
140013577: 0f 05 syscall
140013582: 0f 05 syscall
使用 Frida 检测直接系统调用

作用:

1
2
3
1.解决 NTDLL 的界限
2.在 NtAcceptConnectPort 上放置一个钩子。这是第一个 Nt 函数,它将帮助我们验证即使在函数上放置了一个钩子,syscall也应该是完整的。
3.向每个线程添加一个 Stalker,它将检查每条指令以查看它是否是syscall。如果是,那么我们将检查它是否在 NTDLL 的范围内。如果它在 NTDLL 之外,那么我们将对其附加一个标注,它将查看 EAX 寄存器并告诉我们系统调用号。然后我们将告警恶意syscall。

Stalker 是 Frida 的代码跟踪引擎,它允许我们跟踪给定线程的所有指令

脚本源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
var modules = Process.enumerateModules()
var ntdll = modules[1]

var ntdllBase = ntdll.base
send("[*] Ntdll base: " + ntdllBase)
var ntdllOffset = ntdllBase.add(ntdll.size)
send("[*] Ntdll end: " + ntdllOffset)

var pNtAcceptConnectPort = Module.findExportByName('ntdll.dll', 'NtAcceptConnectPort');
Interceptor.attach(pNtAcceptConnectPort, {
onEnter: function (args){}
})
const mainThread = Process.enumerateThreads()[0];
Process.enumerateThreads().map(t => {
Stalker.follow(t.id, {
events: {
call: false, // CALL instructions: yes please
// Other events:
ret: false, // RET instructions
exec: false, // all instructions: not recommended as it's
// a lot of data
block: false, // block executed: coarse execution trace
compile: false // block compiled: useful for coverage
},
onReceive(events) {
},

transform(iterator){
let instruction = iterator.next()
do{
if(instruction.mnemonic == "syscall"){
var addrInt = instruction.address.toInt32()
//If the syscall is coming from somewhere outside the bounds of NTDLL
//then it may be malicious
if(addrInt < ntdllBase.toInt32() || addrInt > ntdllOffset.toInt32()){
send("[+] Found a potentially malicious syscall")
iterator.putCallout(onMatch)
}
}
iterator.keep()
} while ((instruction = iterator.next()) !== null)
}
})
})

function onMatch(context){
send("[+] Syscall number: " + context.rax)
}

总结

syscall技术研究出来已经几年了,但也没有停滞不前,随着AV/EDR的增强,攻击技术自然也要进步,产出了很多地狱之门的变体,但万变不离其宗,深入理解原理才能更好的Bypass。

参考链接:

1
2
3
4
5
6
7
8
https://www.crisprx.top/archives/540
https://www.anquanke.com/post/id/261582
https://github.com/timwhitez/Spoofing-Gate
https://github.com/am0nsec/HellsGate/
https://github.com/jthuraisamy/SysWhispers2
https://www.mdsec.co.uk/2022/01/edr-parallel-asis-through-analysis/
https://passthehashbrowns.github.io/detecting-direct-syscalls-with-frida
https://klezvirus.github.io/RedTeaming/AV_Evasion/NoSysWhisper/

Peace.