1-Windows Shellcode开发(x86 stager)
一、前言
首发:https://xz.aliyun.com/news/17827
作者:一天
在前面的章节中,我已经介绍了如何运用C++和纯汇编开发弹窗shellcode,并用C++开发了远程下载文件的shellcode。本节是上一节的延续,需要的做的事情主要有
完成x86纯汇编远程下载文件的shellcode(wininet)版
完成x86纯汇编远程下载文件的shellcode(winhttp)版
完成x86纯汇编TCP socket传输shellcode(ws2_32版)
本节围绕上述的三件待办事项展开和拓展,详细介绍技术细节和可能遇到的一些问题及解决方案。所以x64在哪里?我的计划是再单独出一篇文章水一下,所以不出意外下一篇文章是关于x64 shellcode的编写(maybe?)。
使用过MSF或CS的朋友肯定或多或少听说过stager这个玩意,这个stager在大部分的C2中是使用汇编语言编写然后制作成一个模板,可以根据设置(port,ip,是否支持SSL)生成适用目标系统的shellcode,所以我将仿照Stephen Fewer的代码,编写出简单的stager并进行简单的测试,具体的扩展就留给想继续专研shellcode的朋友了。
还有一点本人代码水平真的很低,如果哪里写的不好或者有问题,还请大佬们不吝赐教。
用纯汇编开发shellcode需要的必备知识就是对汇编指令足够熟悉,能明白每条指令执行后寄存器和内存的状况,为此需要用到调式工具。在windows上,你可以利用windbg和x32/64dbg进行调式,因本人习惯,我需要在Visual Studio上进行代码的编写和调式masm的汇编代码。
在这里补充一下上一篇我的一个疑问:

我在详细分析了Stephen Fewer的给出的源码后了解到,其实 stager_reverse_http.asm 这个汇编文件才是主入口点,通过 call start 将下一条指令的地址作为返回地址压栈,然后通过 %include "./src/block/block_api.asm" 将block_api.asm的代码给包含进来,所以下一条指令应该是 block_api.asm 的第一条指令 ,将再通过 pop ebp 将这条指令的地址弹出栈并保存到ebp中。这就很好的回答了我的疑问了。

二、x86纯汇编远程下载文件的shellcode(wininet)版
我们继续借鉴Stephen Fewer的代码,完成x86纯汇编远程下载文件的shellcode。
block_api.asm:代码通过动态解析哈希值来定位所需的API函数地址: metasploit-framework/external/source/shellcode/windows/x86/src/block/block_api.asm at master · rapid7/metasploit-framework
block_reverse_http.asm:该汇编代码实现了一个通过HTTP下载并执行远程代码的Shellcode加载器:metasploit-framework/external/source/shellcode/windows/x86/src/block/block_reverse_http.asm at master · rapid7/metasploit-framework
代码我进行了部分修改,如果觉得我的代码写的不够好,还请看Stephen Fewer的源码。
在本文中我会详细介绍怎么调式,怎么看内存和寄存器的情况,如果觉得我太啰嗦可以不看直接拿源代码运行自己分析即可。
因为 GetProcAddressByHash 已经在1-Windows Shellcode开发 | onedaybook详细介绍过了,这个函数的主要作用就是找到目标函数地址并调用它,执行完后清理留存在栈上的值。这里提一嘴windows的API遵从stdcall,这意味着windows API会清理在main中压入的需要用到参数,以达到栈平衡。
2.1 必要的解释
(1)压入字符串wininet和目标hash值
我们在 push 0074656eh 下一个断点,然后运行,再逐语句(F11),可以看到栈上存在了 6e 65 74 00,即 net'\0'

继续F11,看到我们已经将字符串“wininet”写到栈上,并以'00'作为结束的标志。我们都知道栈指针(esp)是从高地址向低地址移动的,对于x86而言是esp-4,而程序读取字符串是从低地址开始读取的,所以压栈的顺序是先压入 net'\0' 再压入 wini。

学过编程的人都应该清楚,一个函数需要将字符串作为参数,那么在参数传递的过程中,我们传入的其实是字符串的地址。所以才需要 push esp 将 wininet 的首地址压入栈中作为 LoadLibraryA 的参数。
一般情况而言,函数的返回值存储在eax中,LoadLibraryA 函数的返回值即 wininet 在内存中的地址。见下图,我们看到了'MZ'标志,这算是PE文件的标志,而我们的DLL属于PE文件。

(2)InternetOpenA
使用 InternetOpenA 初始化Internet会话,xor ebx,ebx 的作用是用于将ebx清零,后续作为0或NULL值(NULL值在C语言中定义为0)。在这里我们不需要设置用户代理、访问类型和选项等。
为什么要用ebx,而不用其他的寄存器呢?在这里补充一下 GetProcAddressByHash 函数它在最后结束的时候不是有清栈再回复调用者的寄存器状态,其中eax、ecx、edx的值会被覆盖掉,main函数中就不能一直使用这三个寄存器,否则会有数据丢失的风险。
⚠注意:在Stephen Fewer的代码代码中,他连续的压入32位0,即8组 00 00 00 00 作为后续API的0或NULL参数。为了简单实现,所有需要0或NULL统一使用 push ebx。当然后续如果需要优化代码,减小shellcode大小,你可以仿照他的写法。
(3)InternetConnectA
使用 InternetConnectA 连接到HTTP服务器
详细代码解释:call got_server_uri:这个指令的作用是将下一条指令的地址压入栈中作为返回地址,并跳转到 got_server_uri 执行。而我们下一条指令(其实不是指令)为 db "/shellcode.bin", 0,这是一个字符串,定义在.text节中,我们是可以访问到的。
/shellcode.bin 字符串对于的ASCII码为 2f 73 68 65 6c 6c 63 6f 64 65 2e 62 69 6e

调式之后,我们能看见,确实是 /shellcode.bin 的地址。

跳转 got_server_uri 之后,执行 pop edi ,其实是将字符串 /shellcode.bin 的地址存储到edi中以备后续使用。

执行 call got_server_host :其实是将字符串 192.168.1.1 压入到栈中作为InternetConnectA的参数,接着跳转到 got_server_host 执行后续的代码逻辑。


(4)HttpOpenRequestA
使用 HttpOpenRequestA 创建HTTP请求。没啥好说的,直接看代码比我哔哔有用多了
(5)httpsendrequest
使用 HttpSendRequestA 发送HTTP请求
我们用python开启一个本地的http服务器,端口是5555,uri为 /shellcode.bin,之后在 jz failure 下一个断点。
这一步主要是用于测试我们是否成功发送了http请求给服务器,如果成功,则在http服务器中留下一个记录证明我们访问成功。

为了测试访问失败的情况,我们可以将修改端口或修改uri或关闭http服务器的,比如说我们将端口改成6666。

(6)VirtualAlloc
使用 VirtualAlloc 创建一个本地缓存,用于存放下载的文件
我们在 xchg eax, ebx 下一个断点,然后查看eax中的值,VirtualAlloc的返回值(缓冲区的地址)存放在eax中。这个地址可以留意一下,后续需要查看我们的shellcode是否写入到这块缓冲区中

(7)分段下载shellcode
关键指令解析
第一次
push ebx,将保存基地址到栈,后续执行到第八步8.跳转并执行shellcode,通过ret将栈上的返回地址(缓冲区的地址)弹出到eip中,然后执行流就会转到缓存区的shellcode当中。

第二次
push ebx配合mov edi, esp将栈顶地址(即占位符的位置)存入edi,此时edi指向存储已读字节数的变量地址(&bytesRead)。根据已读字节数,移动缓冲区指针到下一段空闲内存。
pop eax:清空临时占位符,确保执行到ret指令时,此时的esp指向存储着返回地址(缓冲区的地址)。
我们看一下是否成功将shellcode写入到缓冲区中


确实是我们上一节编写的弹窗shellcode
2.2 测试
其实这个远程下载文件并执行的汇编代码可以编译成exe文件,也可以提取成shellcode的形式。下图是直接使用exe的形式运行

我按照老办法,从编译后的exe文件的.text节中提取机器码作为我们的shellcode,然后用 runshc32 运行我们的*.bin文件

2.3 完整代码
wininet.dll+InternetOpenA = 0363799Dh wininet.dll+InternetConnectA=2289ACBAh wininet.dll+HttpOpenRequestA=9718794Eh wininet.dll+HttpSendRequestA=0D7022990h kernel32.dll+VirtualAlloc=0BCEF49D9 wininet.dll+InternetReadFile=3E73B975h wininet.dll+InternetCloseHandle=30588F36h kernel32.dll+VirtualFree=07AAD48Ch
后续如果有需要,你可以直接编写支持HTTPS的shellcode,在这里我就不演示了。
三、x86纯汇编远程下载文件的shellcode(winhttp)版
3.1 必要的解释
其实支持http的原生库不只wininet,还有winhttp,它从Windows XP SP1 和 Windows Server 2003 SP1 开始成为操作系统内置组件。
我将尝试使用winhttp的API来完成x86纯汇编远程下载文件的shellcode,大致的流程如下
使用
WinHttpOpen初始化会话句柄使用
WinHttpConnect建立连接使用
WinHttpOpenRequest创建请求句柄使用
WinHttpSendRequest发送 HTTP 请求到服务器,在这一步htpp服务器会出现日志使用
WinHttpReceiveResponse接收响应使用
VirtualAlloc申请一个本地缓存使用
WinHttpReadData将数据读到本地缓存中
⚠注意:ip和uri是宽字节表示的,即每个字符占两个字节,下文会解释
(1)字符串问题
在这一段代码中,我原先是用 db '1','9','2','.','1','6','8','.','1','.','1',0 来表示IP地址的,但是我调式的时候发现eax的值居然为0,这表明这段代码中出现了问题。具体见下图


调了半天然后又翻看API文档时发现 pswzServerName 参数的类型居然是 LPCWSTR,我以为是 LPCSTR,/(ㄒoㄒ)/。它是一个指向 16 位 Unicode 宽字符常量字符串 的长指针,所以应该使用 dw '1','9','2','.','1','6','8','.','1','.','1',0 表示IP地址。


有了这个经验,我就找哪些参数需要宽字符串来表示,排查下来我发现uri也需要用宽字符串来表示。

不是说其他参数不需要宽字符串来表示,而是我想尽可能简化,能使用NULL就使用NULL。
(2)查看是否访问成功
为了验证下面的这段代码是否正常达到目的,即发送HTTP请求,需要用日志来验证,在 call GetProcAddressByHash 下一个断点,然后F10,查看左边的情况或者查看eax的值是否为1

(3)查看是否读到内存中
为了验证我们是否将shellcode读取到内存缓冲区中,我们在 jz failure 处下一断点,ebx中的值是缓冲区的地址,我们可以根据这个地址查看缓冲区的情况。


其余的代码应该没什么要解释的地方了(maybe?),看看注释应该就能明白,细节方面从这里开始到下文都不会过多展示,详细还请看 x86纯汇编远程下载文件的shellcode(wininet)版 和我的另一篇文章 Windows Shellcode开发-先知社区。如果有问题请私信。
3.2 测试
我们将先用exe来测试是否成功下载http服务器的shellcode并执行。

当然我们的目的是将汇编代码制作成shellcode,所以用010editor工具提取.text的机器码,用runshc32来运行*.bin文件

3.3 完整代码
winhttp.dll+WinHttpOpen=332D226Eh winhttp.dll+WinHttpConnect=39AE9EB0h winhttp.dll+WinHttpOpenRequest=0D3431402h winhttp.dll+WinHttpSendRequest=094B5BFFh winhttp.dll+WinHttpReceiveResponse=0E82D8B6Fh winhttp.dll+WinHttpReadData=0F5B42CD6h
后续如果有需要,你可以直接编写支持HTTPS的shellcode,在这里我就不演示了。
四、x86纯汇编TCP socket传输shellcode(ws2_32版)
4.1 必要的解释
一说到网络,必定离不开socket编程,而微软早就提供给我们有关socket编程的原生库。我们通过微软提供的socket API完成客户端和服务器之间的数据传输(在本节中尤指恶意shellcode),并将数据保存到本地缓冲区中,等待一切就绪即可执行存放在缓冲区的shellcode。
关于socket编程的原生库,一共有两个,一个是wsock32,另一个是ws2_32。其中wsock32是ws2_32早期版本,支持古老的windows 操作系统进行socket编程。在本节中只介绍ws2_32,而wsock32在现代操作系统中,只是作为一个中转站,通过函数转发的方式调用ws2_32里的函数

大致的流程如下:
使用
WSAStartup启动进程对 Winsock DLL 的使用使用
WSASocketA创建一个套接字使用
bind将ip:port绑定到套接字使用
listen开启监听模式使用
accept线程阻塞,等待数据传输使用
closesocket关闭原始套接字,但要保留closesocket的返回值(新套接字)使用
VitualAlloc申请一块RWX缓冲区使用
recv接收数据
代码参考
block_bind_tcp.asm:使用ws2_32的socket编程API传输shellcodemetasploit-framework/external/source/shellcode/windows/x86/src/block/block_bind_tcp.asm at master · rapid7/metasploit-framework
block_recv.asm:申请缓冲区,接收shellcode并向缓冲区写入shellcode metasploit-framework/external/source/shellcode/windows/x86/src/block/block_recv.asm at master · rapid7/metasploit-framework
block_api.asm:代码通过动态解析哈希值来定位所需的API函数地址 metasploit-framework/external/source/shellcode/windows/x86/src/block/block_api.asm at master · rapid7/metasploit-framework
(1)加载ws2_32.dll
不必多说,必须加载ws2_32.dll才能使用相关的socket API,相信看到这里的师傅也会写了,我就不解释了
(2)调用WSAStartup函数
WSAStartup 动态加载并绑定当前进程所需的 Winsock 动态链接库,检查应用程序请求的版本和系统实际支持的版本,为进程分配必要的 Winsock 运行时资源。
WSADATA 结构包含有关 Windows 套接字实现的信息,具体字段不用理解,结构体大概在400字节左右,当然也不用细究。
push 0202h:我查看Stephen Fewer的源码时发现,他使用的是0x0190作为版本号,这好像不对吧?我查阅相关资料,WS2_32.dll在Windows 98、2000及以后版本中支持2.2(0x0202),所以我们还是用2.2版本吧。


(3)调用WSASocketA函数
WSASocket:创建一个套接字(socket),使用的是AF_INET(ipv4地址系列),传输控制协议使用TCP。
(4)调用bind函数
bind 函数将本地地址与套接字相关联。如果执行成功返回值为0
在这里要用到一个sockaddr_in结构体,这个结构体用来存储 IP 地址和端口号信息,其定义如下
我们使用 push ebx ,所以sockaddr.sin_addr=0.0.0.0,表明全地址监听
可以从上述的结构体得知端口号和地址族应该分别占两个字节,结构体按照小端序存储,故
5C110002h高两个字节作为端口号,低两个字节作为地址族。但是这里又出现了一个问题5C11也不是按照小端的模式啊。
查阅资料后发现,在网络字节序列中端口号应该使用大端模式,所以要修改端口的师傅请注意!
push 16:addr指向的值的长度,2(sin_family)+2(sin_port)+4(sin_addr),并包括了8字节的填充字段。
(5)调用listen函数
listen 函数将套接字置于侦听传入连接的状态。如果执行成功,返回0
在这里我就说一下其实每个函数执行完后都可以 错误检查 ,但是我为了减少shellcode体积,只在这一步进行 错误检查。
(6)调用accept函数
accept 函数允许对套接字进行传入连接尝试。如果该值是新套接字的描述符。此返回值是建立实际连接的套接字的句柄。
push eax:为了差异化,当然也可以用push ebx,下条指令同理。如果我们成功执行到这一步,listen的返回值存储在eax中,eax=0。
call GetProcAddressByHash :调用后,程序阻塞,等待连接
(7)调用closesocket
closesocket:关闭原始套接字,因为我们只尝试建立一个socket连接,后续用不到了原始socket。并将新套接字socket句柄保存到edi中,以备后续接收(recv)使用
(8)申请缓冲区
老步骤了,不多讲
(9)接收数据
recv 函数从连接的套接字或绑定的无连接套接字接收数据。其返回值为已接收的字节数。
这里与Stephen Fewer的代码大不相同,下图是Stephen Fewer的代码。

我并没有实现下面的代码,这个代码主要就是用于接收前4个字节,这4个字节表示stage的长度。如果要实现,就必需在原始数据的开头patch 4个字节作为接下来接收的数据长度。
为了解决这个问题,我就仿照
block_reverse_http.asm的download_more。
如果一切正常,执行到 execute_stage 标签的指令时,esp存放缓冲区的地址,通过ret指令弹出栈上的地址并跳转执行。
4.2 测试
在这里需要用python运行一个客户端,存放着payload,而我们的汇编代码作为服务器接收客户端发来的payload并执行(可能与传统的客户端和服务器有些区别,别太在意)。
首先调式一下,在read_more的call GetProcAddressByHash下一个断点,然后运行,并执行python脚本

其次是编译成exe

最后是shellcode形式

其实TCP shellcode还分什么正向连接还有反向连接,感兴趣的师傅的自己去修改吧
4.3 完整代码
ws2_32.dll+WSAStartup=78A22668h ws2_32.dll+WSASocketA=5915B629h ws2_32.dll+bind=0DF6E8201h ws2_32.dll+listen=776F8FF6h ws2_32.dll+accept=597292B3h ws2_32.dll+closesocket=0D98414B4h ws2_32.dll+recv=0D7FF7F41h
Last updated