Contents
  1. 1. 执行流程
  2. 2. 详细分析
    1. 2.1. 一阶段shellcode
    2. 2.2. 二阶段shellcode
      1. 2.2.1. syscall_hook
        1. 2.2.1.1. 系统调用初始化
        2. 2.2.1.2. 限制APC排队数量
        3. 2.2.1.3. 恢复原有系统调用
      2. 2.2.2. r3_to_r0_start
        1. 2.2.2.1. 遍历内核首地址
        2. 2.2.2.2. 遍历指定进程
        3. 2.2.2.3. 遍历可用于注入的线程
        4. 2.2.2.4. APC注入
    3. 2.3. 三阶段shellcode
      1. 2.3.1. 调整堆栈
      2. 2.3.2. 分配内存拷贝Ring3 shellcode
      3. 2.3.3. 获取CreateThread API地址
    4. 2.4. 四阶段shellcode
  3. 3. 参考资料

在现有公开的永恒之蓝RCE的exp中并没有直接使用doublepulsar后门,而是采用了Ring0 shellcode和Ring3 shellcode结合的方式,通过Ring0 shellcode来执行用户自定义的Ring3 shellcode。

在公开的永恒之蓝RCE的exp中几乎都使用了同一个Ring0 shellcode,包括Metasploit中的ms17_010_eternalblue.rb

https://github.com/worawit/MS17-010/blob/master/shellcode/eternalblue_kshellcode_x86.asm

同时公开的永恒之蓝exp中,除了zzz_exploit.py这种需要有权限的命名管道或者低权限的smb用户以外的远程RCE利用的exp都是不稳定的,会导致概率性的重启问题。

经过测试发现只保留exp中的部分Ring0 shellcode进行攻击,该exp是可以稳定执行的。

保留部分如下:

1
2
3
4
5
shellcode =\
b"\x60\xE8\x00\x00\x00\x00\x5B\xE8\x23\x00\x00\x00\xB9\x76\x01\x00"\
b"\x00\x0F\x32\x8D\x7B\x39\x39\xF8\x74\x11\x39\x45\x00\x74\x06\x89"\
b"\x45\x00\x89\x55\x08\x89\xF8\x31\xD2\x61\xC2\x24\x00\x8D\xAB\x00" \
b"\x10\x00\x00\xC1\xED\x0C\xC1\xE5\x0C\x83\xED\x50\xC3"

可以判定导致不稳定重启的原因存在于shellcode中,针对shellcode进行优化处理,有可能可以解决该问题。

可以搜集到的关于exp中Ring0 shellcode的分析资料很少,想解决永恒之蓝exp的概率性重启问题,要针对shellcode进行优化,就必须了解exp中的Ring0 shellcode中的执行细节,所以就有了这篇文章的产生。

执行流程

根据执行过程中所作事情的不同,将整个执行流程分为了四个阶段

  1. 一阶段shellcode:操作MSR模式寄存器将二阶段的shellcode作为钩子函数来对sysenter或syscall下钩子。
    • X86 覆盖IA32_SYSENTER_EIP来hook SYSENTER。
    • X64 覆盖IA32_LSTAR MSR 来hook SYSCALL。
  2. 二阶段shellcode:触发系统调用后还原syscall(sysenter),遍历进程寻找lsass.exe或者spoolsv.exe进程,使用异步过程调用 (APC)将用户级 shellcode 注入进程中。

  3. 三阶段shellcode:执行KernelApcRoutine(该回调函数为KeInitializeApc的KernelRoutiune参数,原本用于销毁KAPC的函数地址,而现在用于执行自定义功能)。

    • KernelApcRoutine:分配内存将存在于内核态的shellcode拷贝到用户态,遍历kernel32.dll中的CreateThread中的api地址用于存储。
  4. 四阶段shellcode:四阶段shellcode为三阶段中被从内核态拷贝到用户态的shellcode,在三阶段shellcode执行后会紧接着执行,用于执行之前存储的CreateThread创建线程,执行用户自定义的Ring3 shellcode。

    Ring0 shellcode相当于一个中转,通过APC注入的方式从漏洞利用成功后的内核态来执行用户态的shellcode。

这里存在一个问题,在漏洞利用成功后,明明已经可以控制eip了,为什么还要进行syscall的hook呢?

原因在于,shellcode 代码在Ring0下执行时,IRQL是DISPATCH_LEVEL,在该级别下无法使用分页内存。

内核态下执行ZwAllocateVirtualMemory函数可以在指定进程中保留一系列应用程序可以访问的虚拟地址,用于将存在于内核态下的shellcode copy到用户态中,该函数需要使用到PASSIVE_LEVEL中断请求级别,通过劫持syscall获取到的IRQL是PASSIVE_LEVEL。

详细分析

一阶段shellcode

一阶段shellcode用于将二阶段的shellcode安装为sysenter或者syscall的钩子。

在x86下是sysenter,在x64下是syscall。

MSR

MSR是模式指定寄存器,用于设置CPU 的工作环境和标示CPU 的工作状态,包括温度控制,性能监控等。

rdmsr:读取ECX中的MSR寄存器地址的值,低32位存储到EAX中,高32位存储到EDX中。

1644456731132

wrmsr:向ECX中存储的MSR寄存器地址写入EAX:ECX的值,其中低32位存储在EAX中,高32位存储在EDX中。

1644456930667

从inter CPU手册中可以看到,X86架构下 MSR寄存器 在0x176的地址存储了 我们关注的SYSENTER_EIP_MSR值,通过对其修改可以达到hook sysenter的目的,在X64下其值为0xC0000082。

1644456184254

Ring0 shellcode通过这种方式将X86下的sysenter的地址替换为了自定义的函数地址,实现了钩子的安装。

1644634732915

其中的set_ebp_data_address_fn函数,用于调整ebp栈底空间,在HAL heap中寻找了一块可用的空间来存储临时变量和作用后续的KAPC结构体的空间使用。

1644464548444

X86 sysenter hook:

1644458796043

x64 syscall hook:

1644463104830

二阶段shellcode

在发生系统调用时,syscall_hook 函数的代码会被执行。

所以对syscall_hook函数下断点,等待一阶段shellcode执行完成后,发生系统调用时该函数就会被执行到。

1644910049384

syscall_hook

syscall_hook函数用于执行原有sysenter/syscall中的初始化代码,限制APC排队数量为1,恢复原有系统调用,函数内部调用了r3_to_r0_start进行了后续的APC注入的操作。

系统调用初始化

在二阶段shellcode开头的部分使用了sysenter/syscall中的前几行指令,用于执行原始系统调用。

X86架构下:

1644474741005

1644475250722

X64架构下:

1644474872533

1644474317223

限制APC排队数量

1644634472286

cmpxchg为比较交换指令,操作数1与eax做比较:

1
2
xor eax,eax
cmpxchg 操作数1,操作数2

如果相等,则 操作数1 = 操作数2,ZF = 1。

如果不相等,则 eax = 操作数1,ZF = 0。

1644479255926

shellcode中的cmpxchg运算完成后,将ebp + 8,即ffdfffb8的值置1

恢复原有系统调用

1644480043793

去掉hook,还原回原地址,原有的sysenter跳转到的eip为nt!KiFastCallEntry

1644480270173

在执行完成syscall_hook函数后会自动跳转到KiFastCallEntry/KiSystemCall64 还未执行代码的位置。

1644475883516

r3_to_r0_start

遍历内核首地址

KiFastCallEnter函数属于内核中的导出函数,通过其可以定位到内核的首地址。

1644480917769

shellcode中从KiFastCallEnter所属的页为起始位置,按页为单位向前遍历,直到遍历到页开头为MZ标记为止。

1644481410753

遍历指定进程

在遍历到内核首地址后,执行PsGetCurrentProcess获取进程的EPROCESS结构体,通过使用PsGetProcessImageFileName获取ImageFileName在EPROCESS中的偏移地址,来判断操作系统版本从而推算出EPROCESS.ThreadListHead的位置。

1644484690301

win_api_direct函数用于根据eax寄存器转入的API hash来寻找api的地址并执行。

1644485579352

动态分析执行流程

执行PsGetCurrentProcess获取进程的EPROCESS结构体。

1644486028292

执行PsGetProcessImageFileName获取EPROCESS.ImageFilename的地址,用它减去EPROCESS的首地址,得到偏移。

1644486658257

通过ImageFilename在EPROCESS中的偏移判断操作系统版本,从而推算出EPROCESS.ThreadListHead的偏移地址。

1644543829518

验证一下,win7系统下ThreadListHead在EPROCESS中的偏移确实是0x188

1644544085100

通过遍历EPROCESS.ThreadListHead的成员减去KPRC.KPRCB.CurrentThread(当前线程首地址)的方式来获取ThreadListEntry相对于ETHREAD结构体的偏移。

1644546234613

最后得到ThreadListEntry相对于ETHREAD结构体在Win7系统下的偏移是0x268。

1644546959762

从windbg中验证确为上述值。

1644547055457

在Windows内核中有一个活动进程链表AcvtivePeorecssList。它是一个双向链表,保存着系统中所有进程的EPROCESS结构,记录了当前进程正在哪些处理器上运行,shellcode通过其来进行进程遍历。

获取lsass.exe进程或者spoolsv.exe

经过测试发现如果注入lsass.exe进程,在断开bind_tcp的shellcode连接时会导致系统重启,而在msf的ms17_010_eternalblue中只定向注入spoolsv.exe进程退出后不会导致该问题。

1644551620111

在EPROCESS结构体中,UniqueProcessId与ActiveProcessLinks字段是相邻的,shellcode使用PsGetProcessId获取UniqueProcessId字段,在其字段后0xA的位置存储着它的偏移,获取UniqueProcessId的偏移后就能够得到ActiveProcessLinks字段的偏移。

1644550070105

进行动态调试,观察执行过程。

1644550786213

获取到EPROCESS.ActiveProcessLinks的偏移值是0xb8。

1644550798022

根据

获取EPROCESS.ActiveProcessLinks的偏移,通过其来遍历进程匹配进程名称来找到lsass.exe或者spoolsv.exe,因为注入lsass.exe进程存在断开bindtcp shellcode连接后操作系统重启的问题,所以在调试过程中将lsass.exe的hash改为了spoolsv.exe的,不再遍历lsass.exe进程。

1644560467682

1644560533824

最终遍历到进程spoolsv.exe

1644560560080

遍历可用于注入的线程

通过注释可以看到,作者描述说

寻找可以用于APC注入的线程,通常判断线程中是否可以被APC注入的方法是检查ETHREAD中的alertable字段。

因为每个Windows版本的ETHREAD中的alertable偏移量不同,所以这种方式不太可靠。

尝试逐个线程插入APC队列然后检查KAPC成员的方式更加可靠。

1644567631775

继续阅读shellcode中的注释,

在Ring0 shellcode中要用到CreateThread来启动Ring0的用户自定义的shellcode。

CreateThread函数需要使用非空的TEB.ActivationContextStackPointer,

如果TEB.ActivationContextStackPointer是NULL,被注入的进程将因为访问违规而崩溃。

(APC程序中不需要非空的TEB.ActivationContextStackPointer)

当TEB.ActivationContextStackPointer为NULL时,KTRHEAD.Queue总是为NULL。

精简掉注释后shellcode代码实现如下:

1644569666448

Win7下在KTHREAD结构体中Queue和Teb的位置。

1644568718181

查看执行结果:

1644570560440

可以看到其执行过后,遍历得到的线程的KTHREAD.queue不为0

1644570709410

1644570740436

APC注入

在遍历得到可以被注入的线程后,使用KeInitializeApc初始化KAPC结构体,使用KeInsertQueueApc注入到目标线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
KeInitializeApc(PKAPC,//KAPC指针
PKTHREAD,//目标线程
KAPC_ENVIRONMENT = OriginalApcEnvironment (0),//0 1 2 3 四种状态
PKKERNEL_ROUTINE = kernel_apc_routine,//销毁KAPC的函数地址
PKRUNDOWN_ROUTINE = NULL,
PKNORMAL_ROUTINE = userland_shellcode,//用户APC总入口或者内核APC函数
KPROCESSOR_MODE = UserMode (1),//要查看的用户apc队列还是内核APC队列
PVOID Context);//内核APC队列;NULL 用户APC:真正的APC函数

BOOLEAN KeInsertQueueApc(PKAPC, SystemArgument1, SystemArgument2, 0);
//SystemArgument1 is second argument in usermode code
//SystemArgument1是usermode代码中的第二个参数
//SystemArgument2 是用户模式代码的第三个参数

1644572900451

调试查看KeInitializeApc

1644632102731

这里context在通常的apc注入中这里是要执行的函数,而在这个shellcode里面作者使用了kernel_apc_routine这个参数放入了要执行的函数进行空间的分配和copy Ring3 shellcode,该函数一般用于销毁KAPC

可以看到context只是使用了栈底ebp的值,是无效的函数地址。

1644573650863

KernelApcRoutine的地址为ffdff3d2。

1644632561116

调用前的函数参数。

1644827351349

查看执行初始化前后KAPC值的变化。

初始化前:

1644632773006

经过初始化后:

1644632811001

调试查看KeInitializeApc

查看要注入的线程执行前的APC队列,这里我们主要查看线程的_KTHREAD.ApcState,通过其KAPC_STATE结构体就可以看到APC队列执行的情况。

1644633646113

1644633322984

1644633344263

在注入前,正在等待执行的用户APC为0。

1644633440179

查看执行后的APC队列,可以看到,正在等待的用户APC数量为1,并且在用户APC队列中看到了我们插入的KAPC结构体的地址。

1644633541132

在shellcode中也对KAPC.UserApcPending进行了判断,如果其值不为1,则清除线程的KAPC队列重新选择线程进行插入。

1644807819959

感觉这里shellcode写的有问题,UserApcPending是KAPC_STATE的成员。

1644807929252

按照shellcode里写的逻辑,只能获取到KAPC.ApcListEntry中第一个成员 + 0xe偏移的值。

1644808240902

1644808298404

如果想要获取到当前线程中的KTHREAD.ApcState.UserApcPending的值,要做如下修改(只针对于windows 7 x86)。

1
2
3
4
lea eax,dword ptr ds:[ebp + 0x10];get KAPC
mov eax, [eax + 0x8];get KAPC.Thread
mov eax, [eax + 0x40];get KTHREAD.ApcState
mov eax, byte ptr [eax + 0x016];get KTHREAD.ApcState.UserApcPending

三阶段shellcode

在二阶段shellcode执行KeInitializeApc之前,对KeInitializeApc函数中的kernel_apc_routine参数下断点。

1644910877846

在执行完二阶段shellcode后,系统调用被执行,触发KiServiceExit函数,该函数执行了KiDeliveApc函数(负责执行APC函数),通过KiDeliveApc我们的三阶段shellcode 被执行。

1644825780608

1
2
3
4
5
6
; VOID KernelApcRoutine(
; IN PKAPC Apc,
; IN PKNORMAL_ROUTINE *NormalRoutine,
; IN PVOID *NormalContext,
; IN PVOID *SystemArgument1,
; IN PVOID *SystemArgument2)

调整堆栈

在函数开头,调整堆栈位置,保存当前eip到SystemArgument2函数中,去除掉栈中无用的PKAPC参数,保存寄存器环境,将取出的SystemArgument1和NormalRoutine参数压入栈中,其中SystemArgument1的位置用于存储寻找到的CreateThread API地址,最后调整ebp的位置。

1644823039132

存储调用前的返回地址到参数2的位置。

1644826261945

调整栈底指针ebp

1644827922806

分配内存拷贝Ring3 shellcode

调整完堆栈以后,调用ZwAllocateVirtualMemory分配一块Ring3的堆空间来将Ring0中存储的Ring3 用户自定义的shellcode拷贝到该空间中。

1
2
3
4
5
6
7
8
9
10
11
12
13
NTSTATUS ZwAllocateVirtualMemory(
_In_ HANDLE ProcessHandle,//当前进程的句柄
_Inout_ PVOID *BaseAddress,
//该变量将接收分配的页面区域的基地址。如果此参数的初始值为非NULL,则从指定的虚拟地址开始分配区域,
//向下舍入到下一个主机页面大小地址边界。如果此参数的初始值为NULL,操作系统将确定分配区域的位置。
_In_ ULONG_PTR ZeroBits,//
//此值必须小于 21,并且仅在操作系统确定分配区域的位置时使用,例如BaseAddress为NULL时。
_Inout_ PSIZE_T RegionSize,//
//指向变量的指针,该变量将接收分配的页面区域的实际大小(以字节为单位)。
//此参数的初始值指定区域的大小(以字节为单位),并向上舍入到下一个主机页面大小边界。
_In_ ULONG AllocationType,//指定要执行的分配类型的标志。
_In_ ULONG Protect//指定权限
);

1644828418022

由于ZwAllocateVirtualMemory要求IRQL级别为PASSIVE_LEVEL,所以在调用函数前要对IRQL进行调整。

1644828915196

https://docs.microsoft.com/en-us/previous-versions/ff566416(v=vs.85)

1644829212066

在内核模式下FS[0]表示的KPCR结构体。

  1. 每个CPU都有一个KPCR结构体(一个内核一个)
  2. KPCR中存储了CPU本身要用的一些重要数据:GDT、IDT以及线程相关的一些信息

1644829416491

调整完后,接着构造ZwAllocateVirtualMemory函数的参数。

1644831414097

根据Protext参数的值可以看到,分配了一段可读可写可执行的空间。

1644832196872

可以看到BaseAddress中存储了分配的空间地址310000,成功分配地址。

1644831663296

接下来开始copy shellcode。

1644832646216

在windbg中调试 copy的过程。

1644889062769

获取CreateThread API地址

在将存储在内核态的四阶段shellcode和用户自定义的shellcode拷贝到分配的空间中以后,开始获取CreateThread的API地址。

采取传统的通过获取PEB表,遍历InMemoryOrderModuleList获取kernel32.dll后遍历导出表的方式来获取。

1644890566995

使用PsGetProcessPeb获取PEB结构体。

1644891068738

根据PEB遍历Kernel32.dll

1644892269007

1644892321475

遍历导出表获取CreateThread API的地址。

1644894155185

储存API地址到参数SystemArgument1中。

1644894474295

四阶段shellcode

对310000下执行断点,在三阶段shellcode执行完成后,g命令运行程序,就可以成功断下。

1644894555636

调用链如下:

1644895528227

在四阶段shellcode中,构造调用参数,使用CreateThread API来创建线程执行Ring3 用户自定义的shellcode。

1644894947486

获取压入前的返回地址

1644896061792

获取CreateThread API的地址。

1644896200699

获取用户自定义shellcode的地址。

1644896337515

查看四阶段shellcode构造的CreateThread调用参数。

1644905539547

可以看到lpStartAddr参数中记录了要执行的用户自定义的shellcode地址。

1644905726329

成功执行用户自定义的Ring3 shellcode代码。

至此,已经完成了整个漏洞利用成功后的Ring0 到Ring3 shellcode的引导过程。

参考资料

https://www.cnblogs.com/onetrainee/p/11675225.html

https://www.microsoft.com/security/blog/2017/06/30/exploring-the-crypt-analysis-of-the-wannacrypt-ransomware-smb-exploit-propagation/

https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-zwallocatevirtualmemory

滴水中级 APC注入

Contents
  1. 1. 执行流程
  2. 2. 详细分析
    1. 2.1. 一阶段shellcode
    2. 2.2. 二阶段shellcode
      1. 2.2.1. syscall_hook
        1. 2.2.1.1. 系统调用初始化
        2. 2.2.1.2. 限制APC排队数量
        3. 2.2.1.3. 恢复原有系统调用
      2. 2.2.2. r3_to_r0_start
        1. 2.2.2.1. 遍历内核首地址
        2. 2.2.2.2. 遍历指定进程
        3. 2.2.2.3. 遍历可用于注入的线程
        4. 2.2.2.4. APC注入
    3. 2.3. 三阶段shellcode
      1. 2.3.1. 调整堆栈
      2. 2.3.2. 分配内存拷贝Ring3 shellcode
      3. 2.3.3. 获取CreateThread API地址
    4. 2.4. 四阶段shellcode
  3. 3. 参考资料