1-Windows Shellcode开发(x86 stager)

一、前言

首发:https://xz.aliyun.com/news/17827arrow-up-right

作者:一天

在前面的章节中,我已经介绍了如何运用C++和纯汇编开发弹窗shellcode,并用C++开发了远程下载文件的shellcode。本节是上一节的延续,需要的做的事情主要有

  1. 完成x86纯汇编远程下载文件的shellcode(wininet)版

  2. 完成x86纯汇编远程下载文件的shellcode(winhttp)版

  3. 完成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。

  1. block_reverse_http.asm:该汇编代码实现了一个通过HTTP下载并执行远程代码的Shellcode加载器:metasploit-framework/external/source/shellcode/windows/x86/src/block/block_reverse_http.asm at master · rapid7/metasploit-frameworkarrow-up-right

代码我进行了部分修改,如果觉得我的代码写的不够好,还请看Stephen Fewer的源码。

在本文中我会详细介绍怎么调式,怎么看内存和寄存器的情况,如果觉得我太啰嗦可以不看直接拿源代码运行自己分析即可。

因为 GetProcAddressByHash 已经在1-Windows Shellcode开发 | onedaybookarrow-up-right详细介绍过了,这个函数的主要作用就是找到目标函数地址并调用它,执行完后清理留存在栈上的值。这里提一嘴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 espwininet 的首地址压入栈中作为 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。

PixPin_2025-04-12_22-07-42.gif

(6)VirtualAlloc

使用 VirtualAlloc 创建一个本地缓存,用于存放下载的文件

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

(7)分段下载shellcode

关键指令解析

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

  1. 第二次 push ebx 配合 mov edi, esp 将栈顶地址(即占位符的位置)存入 edi,此时 edi 指向存储已读字节数的变量地址(&bytesRead)。

  2. 根据已读字节数,移动缓冲区指针到下一段空闲内存。

  1. pop eax:清空临时占位符,确保执行到ret指令时,此时的esp指向存储着返回地址(缓冲区的地址)。

我们看一下是否成功将shellcode写入到缓冲区中

确实是我们上一节编写的弹窗shellcode

2.2 测试

其实这个远程下载文件并执行的汇编代码可以编译成exe文件,也可以提取成shellcode的形式。下图是直接使用exe的形式运行

PixPin_2025-04-13_14-49-31.gif

我按照老办法,从编译后的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 SP1Windows Server 2003 SP1 开始成为操作系统内置组件。

我将尝试使用winhttp的API来完成x86纯汇编远程下载文件的shellcode,大致的流程如下

  1. 使用 WinHttpOpen 初始化会话句柄

  2. 使用 WinHttpConnect 建立连接

  3. 使用 WinHttpOpenRequest 创建请求句柄

  4. 使用 WinHttpSendRequest 发送 HTTP 请求到服务器,在这一步htpp服务器会出现日志

  5. 使用 WinHttpReceiveResponse 接收响应

  6. 使用 VirtualAlloc 申请一个本地缓存

  7. 使用 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开发-先知社区arrow-up-right。如果有问题请私信。

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里的函数

大致的流程如下:

  1. 使用WSAStartup启动进程对 Winsock DLL 的使用

  2. 使用WSASocketA创建一个套接字

  3. 使用bind将ip:port绑定到套接字

  4. 使用listen开启监听模式

  5. 使用accept线程阻塞,等待数据传输

  6. 使用closesocket关闭原始套接字,但要保留closesocket的返回值(新套接字)

  7. 使用VitualAlloc申请一块RWX缓冲区

  8. 使用recv接收数据

代码参考

(1)加载ws2_32.dll

不必多说,必须加载ws2_32.dll才能使用相关的socket API,相信看到这里的师傅也会写了,我就不解释了

(2)调用WSAStartup函数

WSAStartup 动态加载并绑定当前进程所需的 Winsock 动态链接库,检查应用程序请求的版本和系统实际支持的版本,为进程分配必要的 Winsock 运行时资源。

  1. WSADATA 结构包含有关 Windows 套接字实现的信息,具体字段不用理解,结构体大概在400字节左右,当然也不用细究。

  2. 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

  1. 在这里要用到一个sockaddr_in结构体,这个结构体用来存储 IP 地址和端口号信息,其定义如下

我们使用 push ebx ,所以sockaddr.sin_addr=0.0.0.0,表明全地址监听

  1. 可以从上述的结构体得知端口号和地址族应该分别占两个字节,结构体按照小端序存储,故 5C110002h 高两个字节作为端口号,低两个字节作为地址族。但是这里又出现了一个问题 5C11 也不是按照小端的模式啊。

查阅资料后发现,在网络字节序列中端口号应该使用大端模式,所以要修改端口的师傅请注意

  1. 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的代码。

  1. 我并没有实现下面的代码,这个代码主要就是用于接收前4个字节,这4个字节表示stage的长度。如果要实现,就必需在原始数据的开头patch 4个字节作为接下来接收的数据长度。

  1. 为了解决这个问题,我就仿照 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