~/posts/binary/x86-shellcode-ror13-resolver-assumptions.md

x86 Shellcode:ROR13 解析器的隐藏假设

从 call/pop、PEB 节点基准与 PE 导出表恢复九个 API 常量,核对有符号大小写分支、容量字节和哈希碰撞,再沿标准句柄与退出条件界定样本行为。

date[31:24]
read[23:16]
8 分钟
cat[15:8]
二进制
目录
  1. 0x00从 call / pop 找到共享解析器
  2. 0x01链表节点不是结构起点
  3. 0x02ROR13 之前还有一个有符号分支
  4. 0x03导出名称、序号与函数地址分三步
  5. 0x04离线模型恢复九个 API 常量
  6. 0x05从 API 名恢复句柄关系
  7. 0x06相同哈希不等于相同模块身份
  8. 0x07区分观察模块预置与样本触发
  9. 0x08官方参考

裸 x86 shellcode 没有常规 PE 加载器替它填写导入表。先恢复共享 API 解析器,再沿参数和句柄追踪行为,比逐条翻译所有指令更有效;同一条分析链也能暴露解析器对进程环境的脆弱依赖。

讨论对象是 Metasploit v6.0.30-dev 对应的 324 字节 TCP reverse-shell 样本,记录的 SHA-256 为 3792f355d1266459ed7c5615dac62c3a5aa63cf9e2c3c0f4ba036e6728763903。下文偏移是裸字节流相对位置,不是加载后的虚拟地址;离线哈希核对不等同于执行样本或重新验证该文件摘要。

从 call / pop 找到共享解析器

entry / Main
0x0000fccld
0x0001e8 82 00 00 00call0x88
0x00885dpopebp
0x009468 4c 77 26 07push0x0726774c
0x0099ff d5callebp

这是非连续指令节选。入口 call 0x88 将返回位置 0x06 压栈,Main 的 pop ebp 取出它;后续 call ebp 就回到解析器开头。cld 保证后续字符串指令向地址递增方向读取。

除入口外,调用点 0x99、0xA9、0xB8、0xD2、0xE2、0x115、0x123、0x12F、0x142 都使用这个入口。每次调用前压入的常量不同,结合模块遍历、导出表访问和哈希比较,可以把“共享 API 解析器”从猜测变成结构性结论。

链表节点不是结构起点

module-name input
0x000b64 8b 50 30movedx, dword ptr fs:[eax+0x30]
0x000f8b 52 0cmovedx, [edx+0x0c]
0x00128b 52 14movedx, [edx+0x14]
0x00158b 72 28movesi, [edx+0x28]
0x00180f b7 4a 26movzxecx, word ptr [edx+0x26]

以上片段的 EAX 已清零。fs:[0x30] 提供 PEB,随后经过 Ldr 和 InMemoryOrderModuleList;EDX 最终指向模块结构内部的 InMemoryOrderLinks,不是结构开头。

x86 样本的相对偏移5 行
字段 外层结构偏移 相对链表节点偏移
InMemoryOrderLinks 0x08 0x00
DllBase 0x18 0x10
BaseDllName.Length 0x2C 0x24
BaseDllName.MaximumLength 0x2E 0x26
BaseDllName.Buffer 0x30 0x28

这里实际读取 MaximumLength。Microsoft 将 Length 和 MaximumLength 都定义为字节数,前者描述字符串,后者描述缓冲区容量;有终止符时,Length 不包含该终止符。容量参与哈希意味着字符串文字相同也可能得到不同结果。UNICODE_STRING

模块通过双向链表连接。迭代回到链表头时,头节点并不是模块对象;若把它继续按 LDR_DATA_TABLE_ENTRY 解释,结果可能是无效读取,而非简单的“永久循环”。这些偏移属于该 x86 布局,不适用于直接分析 x64。

ROR13 之前还有一个有符号分支

module hash loop
0x001eaclodsb
0x001f3c 61cmpal, 0x61
0x00217c 02jl0x25
0x00232c 20subal, 0x20
0x0025c1 cf 0droredi, 13
0x002801 c7addedi, eax
0x002ae2 f2loop0x1e

模块名称是 UTF-16LE,循环却用 lodsb 逐字节读取。对普通 ASCII DLL 名,字符字节与零字节交替进入哈希;零字节虽然不增加数值,仍会推动一次旋转。

特别注意 jl 是有符号比较。cmp al, 0x61 后,只有字节范围 0x61…0x7F 进入减 0x20 的分支;0x80…0xFF 按负数解释而跳过。因此这既不是 Unicode 大小写转换,也不是只处理 a…z 的 ASCII 转换:例如 0x7B 会变成 0x5B。

ROR13(0x0000004b)32 位
B30x02B20x58B10x00B00x00
值0x02580000= 39321600
位字段值
31–24B30x02
23–16B20x58 (88)
15–8B10x00
7–0B00x00

首字节 K = 0x4B 进入零累加器得到 0x4B;处理随后的零字节时,结果为 ROR13(0x4B) = 0x02580000。每步都保留低 32 位,最终模块哈希与函数哈希再相加。

导出名称、序号与函数地址分三步

PE 内存映像中,从模块基址的 +0x3C 读取 e_lfanew,到达 NT 头后从 +0x78 取 PE32 导出目录 RVA。这个 0x78 相对于 NT 头;常见图示中的 0x60 则相对于 Optional Header,二者基准不同。

IMAGE_EXPORT_DIRECTORY 的关键字段4 行
偏移 字段 作用
0x18 NumberOfNames 名称表项数
0x20 AddressOfNames 名称 RVA 数组
0x24 AddressOfNameOrdinals 名称索引到函数表索引
0x1C AddressOfFunctions 函数 RVA 数组

样本从名称表末尾向前遍历,保留导出名称大小写,并把末尾 NUL 字节纳入 ROR13。它比较 (module_hash + export_hash) mod 2^32;命中后先查 16 位序号表,再查 32 位函数 RVA,最后加模块基址。名称序号不等于函数地址;RVA 也不等于磁盘文件偏移。Microsoft PE format

resolver tail
0x007c59popecx
0x007d5apopedx
0x007e51pushecx
0x007fff e0jmpeax

恢复寄存器后,解析器取出调用方返回地址、丢弃哈希常量,再压回返回地址并跳入 EAX。这个 jmp 保留了 API 返回到原调用点所需的栈形状,因此分析时要同时画出控制流和栈变化。

导出表项也可能指向转发字符串。所示路径未展开转发处理,故不把它描述为完整 Windows 加载器。Rapid7 官方解析器代码署名 Stephen Fewer;当前分支与本样本的长度读取和哈希组合方式存在差异,官方源码用于对照,不覆盖这里的字节证据。

离线模型恢复九个 API 常量

ror13-model.pypython
MASK = 0xffffffff
def ror13(x):
    return ((x >> 13) | (x << 19)) & MASK
def hash_bytes(data, module=False):
    value = 0
    for byte in data:
        if module and 0x61 <= byte < 0x80:
            byte = (byte - 0x20) & 0xff
        value = (ror13(value) + byte) & MASK
    return value
def module_hash(name):
    return hash_bytes((name + "\0").encode("utf-16le"), True)
def api_hash(dll, name):
    return (module_hash(dll) + hash_bytes(name.encode("ascii") + b"\0")) & MASK

assert module_hash("KERNEL32.DLL") == 0x92af16da
assert api_hash("KERNEL32.DLL", "LoadLibraryA") == 0x0726774c
assert api_hash("WS2_32.DLL", "WSAStartup") == 0x006b8029
assert api_hash("WS2_32.DLL", "WSASocketA") == 0xe0df0fea

module_hash 的便捷输入约定是 ASCII DLL 名加一个 UTF-16LE NUL。真实缓冲区有额外容量字节时,应直接把实际字节交给 hash_bytes,而不是把这个约定误认成加载器保证。

完整本地测试覆盖九个调用常量,以及长度、旋转和字节序边界,实际输出如下:

ror13-resolver-model.py
$ python ror13-resolver-model.py
KERNEL32.DLL!LoadLibraryA=0x0726774c
WS2_32.DLL!WSAStartup=0x006b8029
WS2_32.DLL!WSASocketA=0xe0df0fea
WS2_32.DLL!connect=0x6174a599
KERNEL32.DLL!CreateProcessA=0x863fcc79
KERNEL32.DLL!WaitForSingleObject=0x601d8708
KERNEL32.DLL!ExitProcess=0x56a2b5f0
KERNEL32.DLL!GetVersion=0x9dbd95a6
NTDLL.DLL!RtlExitUserThread=0x6f721347
four_module_collisions=0x92af16da
maximum_length_tail_changes_hash=True
signed_jl_boundary_0x7b_0x80=PASS
ror13(0x4b)=0x02580000
ror26(0x4b)+0x45=0x00001305
sockaddr_example=AF_INET,4444,192.0.2.202
PASS: arithmetic only; no shellcode execution

查找表应保存一个哈希对应的全部候选,再用调用参数与上下文消歧。这样的表是哈希反查表,不需要称作具有链式压缩结构的“彩虹表”。

从 API 名恢复句柄关系

主路径及证据5 行
阶段 已识别 API 关键证据
初始化 LoadLibraryA、WSAStartup、WSASocketA ws2_32、IPv4、SOCK_STREAM
建立连接 connect 栈上 sockaddr,失败重试计数为 5
创建子进程 CreateProcessA cmd、标准句柄、继承标志
等待结束 WaitForSingleObject 子进程句柄与无限等待
退出选择 GetVersion、ExitProcess、RtlExitUserThread 版本与出口哈希分支

加载字符串由压栈字节 77 73 32 5F 33 32 00 00 表示,即 ws2_32。个别注释中的 ws3_32 与这些字节不符,应以字节为准。

sockaddr_in
000000000200115CC00002CA
  1. AF_INET0x00–0x01
  2. port0x02–0x03
  3. IPv40x04–0x07

上方是已替换地址的字段示例:前两字节按小端得到 AF_INET = 2,接下来两字节按网络字节序得到端口 4444,地址为 192.0.2.202。它说明内存字节序,不表示记录了一次对该示例地址的连接。

连接成功后,样本把同一个 socket 写入 STARTUPINFOA 的 hStdInput、hStdOutput 和 hStdError,设置 STARTF_USESTDHANDLES 并将 bInheritHandles 置为真,再创建 cmd。这组参数关系支持远程命令通道的行为判断;单独看见 connect 不够。CreateProcessA

出口还包含版本判断和哈希选择。当前显示的 ExitProcess 常量低字节并不满足切换到另一出口的比较条件,所以应区分“代码里存在 RtlExitUserThread 分支”与“这组常量实际选择了它”。

相同哈希不等于相同模块身份

fixed-collision-check.pypython
names = ["KERNEL32.DLL", "HERNEL32.DLX", "IERNEL32.DLT", "JERNEL32.DLP"]
assert {module_hash(name) for name in names} == {0x92af16da}
raw = "KERNEL32.DLL\0".encode("utf-16le")
assert hash_bytes(raw, True) != hash_bytes(raw + b"\0", True)
assert hash_bytes(b"\x80", True) == 0x80
assert hash_bytes(b"\x7b", True) == 0x5b

四个不同模块名都得到 0x92AF16DA。位贡献会因旋转和加法重叠,例如 ROR26(0x4B) + 0x45 与 ROR26(0x49) + 0xC5 都得到 0x1305;完整名称的碰撞仍以逐字节模型验证,不能仅凭局部图形下结论。

名称碰撞并不足以自动转移调用。候选模块还要进入进程、提供对应导出名称、在搜索顺序中先被命中,并维持正确调用约定。这个缺陷属于特定 shellcode 的自定义解析器,不等于 Windows 模块加载器按哈希识别 DLL。

使用 MaximumLength 又引入另一条依赖:即便名称文字保持不变,额外容量字节也可能改变哈希。人为改动加载器结构会同时影响宿主,必须考虑长度单位、对象寿命、加载器锁、并发枚举及正常名称比较的兼容性。

区分观察模块预置与样本触发

观察模块预置、样本执行提示与解析告警的界面示意图

界面示意图:三个阶段依次为观察模块预置、324 字节样本执行提示,以及 LoadLibraryA / ws2_32 告警。

记录的时序很重要:观察模块先进入进程,样本随后才开始解析 API,最终到达观察函数。它支持的是该样本、该进程状态下的一条路径,不证明所有变体都会命中,也不是通用检测产品的兼容性验证。

本地完成的是算术与结构核对,没有注入观察 DLL,也没有执行 reverse shell。可复用的分析成果是解析模型:明确输入字节、指针基准、终止条件与调用约定,再将模型识别出的 API 和真实参数关联起来。

官方参考

NORMAL~/posts/binary/x86-shellcode-ror13-resolver-assumptions.md§--
0%zh-CN