发布于14小时前14小时 背景在分析 Windows 恶意样本时,经常会遇到一类比较常见的隐藏手法:样本不直接保存 API 名称,而是通过哈希值动态解析函数地址。这样做可以减少导入表特征,也会干扰静态检索,例如看不到 VirtualAlloc、WriteProcessMemory、CreateRemoteThread 这类敏感 API。这类手法本身不新,但在实际分析中仍然很常见。本文结合逆向初筛场景,整理一套比较实用的识别和还原流程,重点放在样本观察、调试定位和批量还原上。技术分析典型 API 哈希解析逻辑通常包含几个特征:遍历 PEB,定位已加载模块,例如 kernel32.dll、ntdll.dll。解析 PE 导出表,遍历 AddressOfNames。对导出函数名逐字节计算哈希。将计算结果与样本中硬编码的常量比较。匹配后通过 AddressOfFunctions 得到真实函数地址。在反汇编中,这类逻辑通常不会很大。比较明显的特征是出现大量对字符串字符的循环处理,例如 lodsb、ror、rol、add、xor 等指令组合,随后与某个立即数比较。常见算法包括:ROR13 + ADD:shellcode 和 loader 中很常见。CRC32 变体:有时会混合大小写转换。DJB2 / SDBM:实现简单,也容易改。自定义轮转异或:为了绕过现成规则。以 ROR13 为例,一个函数名 VirtualAlloc 可能不会直接出现在样本里,而只留下一个类似 0x91AFCA54 的哈希常量。分析重点就是确认算法,然后用系统 DLL 的导出函数名反向计算。操作步骤1. 先看导入表和字符串如果导入表极少,只剩下 LoadLibraryA、GetProcAddress,或者干脆只有 ntdll 的少量函数,就需要留意动态解析逻辑。字符串里如果没有明显 API 名称,但存在 DLL 名,例如:kernel32.dlladvapi32.dllwininet.dllws2_32.dll也可能是模块名明文、函数名哈希的混合方案。2. 定位导出表遍历逻辑静态分析时可以重点找这些结构访问:PEB:x86 下常见 fs:[0x30],x64 下常见 gs:[0x60]。DOS 头:判断 MZ,读取 e_lfanew。PE 头:定位 Export Directory。导出表字段:AddressOfNames、AddressOfNameOrdinals、AddressOfFunctions。如果样本循环遍历导出函数名,并对每个字符做运算,基本可以确定是 API hash。3. 调试时在比较点下断如果静态看算法费时间,可以动态调试:在哈希计算循环结束后的 cmp eax, imm32 或 cmp ecx, imm32 附近下断。观察当前正在计算的导出函数名指针。记录硬编码哈希值和匹配成功后的函数地址。如果有反调试,可以先只做快照执行,或者用断点记录而不频繁单步。很多 loader 的解析函数会连续解析多个 API,一次跟住就能拿到一批结果。代码示例下面是一个用于批量还原 ROR13 哈希的 Python 示例。它会读取指定 DLL 的导出函数名,计算哈希,并尝试匹配样本中提取出的哈希常量。import pefile import sys def ror32(value, bits): value &= 0xffffffff return ((value >> bits) | (value << (32 - bits))) & 0xffffffff def api_hash_ror13(name: str) -> int: h = 0 for ch in name: h = ror32(h, 13) h = (h + ord(ch)) & 0xffffffff return h def load_exports(dll_path): pe = pefile.PE(dll_path) exports = [] if not hasattr(pe, "DIRECTORY_ENTRY_EXPORT"): return exports for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols: if exp.name: exports.append(exp.name.decode("ascii", errors="ignore")) return exports def main(): if len(sys.argv) < 3: print(f"Usage: {sys.argv[0]} kernel32.dll 0x91afca54 0xec0e4e8e") return dll_path = sys.argv[1] targets = set(int(x, 16) for x in sys.argv[2:]) for name in load_exports(dll_path): h = api_hash_ror13(name) if h in targets: print(f"0x{h:08x} => {name}") if __name__ == "__main__": main() 使用示例:python resolve_hash.py C:\Windows\System32\kernel32.dll 0x91afca54 0xec0e4e8e实际分析时建议同时跑多个常用 DLL,例如:ntdll.dllkernel32.dllkernelbase.dlladvapi32.dlluser32.dllws2_32.dllwininet.dll算法确认技巧如果不确定是不是 ROR13,可以从反汇编里看循环结构。常见 ROR13 伪代码大概如下:hash = 0 for c in function_name: hash = ror(hash, 13) hash = hash + c return hash如果发现样本在计算前统一把小写转大写,例如:if 'a' <= c <= 'z': c -= 0x20那就要在脚本里同步处理大小写,否则会全部匹配失败。还有些样本会把模块名也加入哈希,例如先计算 KERNEL32.DLL,再混合函数名。此时单独对函数名计算不会命中,需要还原完整算法。动态验证方法还原出 API 名后,不建议立刻完全相信脚本结果,最好做一次动态验证:在解析函数返回点下断,确认返回地址是否落在对应 DLL 的导出函数范围内。用调试器查看返回地址符号,例如 x64dbg 中跟随到模块。对照调用点参数,确认语义是否一致。例如脚本推测某哈希是 VirtualAlloc,调用点附近通常能看到类似参数:lpAddress = 0dwSize 为一段 shellcode 或解密后数据长度flAllocationType = 0x3000flProtect = 0x40 或 0x04如果参数形态完全对不上,就要怀疑算法或 DLL 范围是否弄错了。注意事项不同系统导出函数有差异:建议在与样本运行环境接近的系统上提取导出表,尤其是 kernelbase.dll。转发导出需要处理:例如某些 API 会从 kernel32.dll 转发到 kernelbase.dll,静态还原时要注意函数地址可能不是最终实现。哈希常量可能加密存放:有些样本会先解密哈希表,再逐项解析,静态搜索立即数可能找不到。同一哈希算法可能有碰撞:虽然概率不高,但遇到短函数名或自定义截断时要结合调用上下文判断。模块名可能参与计算:不能只按函数名跑字典,必要时把 DLL 名也加入枚举。总结API 哈希并不复杂,但会明显降低初筛效率。比较稳妥的处理方式是:先识别导出表遍历逻辑,再确认哈希算法,最后用本机 DLL 导出表批量还原,并结合调试参数验证。在实际样本分析中,不需要一开始就完整反编译所有逻辑。只要先把关键 API 还原出来,例如内存分配、解密、进程注入、网络通信相关函数,样本的大致行为链路通常就能很快展开。 网络请求、日志与边界流量分析示意
6小时前6小时 我一般初筛遇到 API hash,会先把“算法”和“候选 API 集合”拆开处理,不急着全量还原。 几个比较实用的切入点: 1. 先找解析逻辑,不要只搜常量 很多样本会有一段典型流程:遍历 PEB → Ldr → InMemoryOrderModuleList → ExportDirectory → Name/Ordinal/Address → 对导出名算 hash。 静态看可以重点搜这些结构访问特征,比如 x64 下常见的: gs:[0x60] ; PEB PEB+0x18 ; Ldr LDR+0x20/0x30 ; module list IMAGE_EXPORT_DIRECTORY AddressOfNames / AddressOfNameOrdinals / AddressOfFunctions 如果是 IDA,可以从 `gs:60h`、`fs:30h` 引用往下追,通常比直接搜 hash 常量更快。 2. 动态断在导出表遍历处 如果静态混淆比较重,可以在 x64dbg 里对常见模块导出区下硬件读断点,或者直接断 `LdrGetProcedureAddress` / `GetProcAddress`。 有些样本自己解析 API,但最后加载 DLL 还是绕不开 `LoadLibraryA/W`、`LdrLoadDll`: bp LoadLibraryA bp LoadLibraryW bp LdrLoadDll bp LdrGetProcedureAddress bp GetProcAddress 如果完全手写解析,断 `kernel32.dll`、`ntdll.dll` 的 export directory 读访问也比较有效。跑到循环里后,观察每次导出名、hash 累加值、最终比较值,基本就能还原算法。 3. 算法识别看循环形态 常见 API hash 其实不复杂,初筛阶段看几个特征就够: - ROR/ROL + ADD/XOR:很常见,Metasploit 风格也类似 - `hash = hash * 33 + c`:djb2 变种 - `hash = hash * 0x1003F` 或其他乘法:自定义变种 - 是否统一大小写:`or 0x20`、`sub 0x20`、`CharUpper` - 是否把模块名也混进去:比如 `KERNEL32.DLL!CreateFileA` 一起算 尤其要注意宽字符和大小写,有些样本 hash 的是 `LoadLibraryA`,有些会先转大写,有些连 DLL 名一起算,差一个规则结果就完全对不上。 4. 建一个本地候选库批量撞 还原出算法后,不建议手工一个个算。可以把本机 DLL 导出拉出来批量匹配,先覆盖常见目录: C:\Windows\System32 C:\Windows\SysWOW64 简单 Python 方向如下,配合 `pefile` 就够用: import pefile import os def ror32(v, n): return ((v >> n) | (v << (32 - n))) & 0xffffffff def api_hash(name): h = 0 for c in name: # 根据样本逻辑决定是否 upper/lower h = ror32(h, 13) h = (h + ord(c)) & 0xffffffff return h targets = { 0xEC0E4E8E, 0x7C0DFCAA, } for root in [r"C:\Windows\System32", r"C:\Windows\SysWOW64"]: for fn in os.listdir(root): if not fn.lower().endswith(".dll"): continue path = os.path.join(root, fn) try: pe = pefile.PE(path, fast_load=False) if not hasattr(pe, "DIRECTORY_ENTRY_EXPORT"): continue for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols: if not exp.name: continue name = exp.name.decode(errors="ignore") h = api_hash(name) if h in targets: print(hex(h), fn, name) except Exception: pass 如果怀疑混入模块名,可以改成: full = fn.upper() + "!" + name 或者按样本逻辑对 DLL 名逐字符参与计算。 5. 初筛阶段优先还原关键 API 没必要一开始追求 100% 命中。优先确认这些类别: - 进程/内存:`VirtualAlloc`, `VirtualProtect`, `WriteProcessMemory`, `CreateRemoteThread` - 文件:`CreateFile`, `ReadFile`, `WriteFile` - 网络:`WinHttp*`, `Internet*`, `WSA*`, `connect`, `send`, `recv` - 持久化:`RegSetValue`, `CreateService`, `schtasks` 相关 - 反调试:`IsDebuggerPresent`, `NtQueryInformationProcess` 只要关键 API 对上,样本行为轮廓基本就出来了。 另外一个经验点:同一个样本里可能不止一套 hash,尤其 loader 和 payload 分离时很常见。看到少量 hash 对不上,不一定是算法错,可能是另一段解析器,或者 seed 不同。可以按调用点分组处理,不要把所有常量丢进一个算法里硬撞。
5小时前5小时 补一个实际还原时比较省时间的办法:把 hash 函数单独“抠出来”验证,不要一开始就手工推导所有变种。 很多样本的 API hash 不是纯 ROR13,常见还会混入这些因素: - 模块名是否参与 hash,例如 `kernel32.dll!CreateFileW` - 大小写是否统一,尤其是模块名转大写、API 名不转 - Unicode / ANSI 名称处理差异 - 是否包含结尾 `\0` - seed 是否固定,还是每个模块不同 - DLL 名有没有去掉 `.dll` 我的习惯是先在反汇编里定位到“单个导出名输入 hash”的函数边界,然后用 Unicorn / Qiling / dump 出来的 shellcode 跑一遍,直接喂几个已知 API 名看结果是否一致。这样比肉眼猜 rotate / xor / add 顺序稳很多。 如果不想上模拟器,也可以先写个小脚本批量撞参数。比如 ROR/ROL + add/xor 的简单变体,可以先快速试一轮: def ror32(v, n): return ((v >> n) | ((v << (32 - n)) & 0xffffffff)) & 0xffffffff def rol32(v, n): return (((v << n) & 0xffffffff) | (v >> (32 - n))) & 0xffffffff apis = [ "LoadLibraryA", "GetProcAddress", "VirtualAlloc", "VirtualProtect", "CreateThread", "WaitForSingleObject", ] target = 0x12345678 # 替换成样本里的 hash for name in apis: bs = name.encode() for seed in [0, 0xffffffff]: for r in range(1, 32): h = seed for c in bs: h = ror32(h, r) h = (h + c) & 0xffffffff if h == target: print("match add/ror", name, seed, r) h = seed for c in bs: h = ror32(h, r) h ^= c if h == target: print("match xor/ror", name, seed, r) h = seed for c in bs: h = rol32(h, r) h = (h + c) & 0xffffffff if h == target: print("match add/rol", name, seed, r) 另外候选 API 集合也建议按“行为阶段”缩小,不一定全扫 Windows SDK 导出。比如: - 解密/解包阶段:`VirtualAlloc`、`VirtualProtect`、`RtlMoveMemory` - 注入阶段:`OpenProcess`、`VirtualAllocEx`、`WriteProcessMemory`、`CreateRemoteThread` - 网络阶段:`WSAStartup`、`socket`、`connect`、`InternetOpenA/W` - 持久化阶段:`RegCreateKeyExA/W`、`RegSetValueExA/W`、`CreateServiceA/W` 这样即使 hash 算法还没完全还原,也能先通过少量高频 API 做锚点。锚点对上以后,再扩展到对应 DLL 的导出表批量跑,准确率会高很多。 最后注意一点:有些样本会对 `kernel32` 实际解析到 `kernelbase` 的 API 做兼容处理,或者先找 `GetProcAddress` 再混用明文导入。遇到还原结果“不像”的时候,可以把 `kernel32.dll`、`kernelbase.dll`、`ntdll.dll` 三个模块一起作为候选,不要只盯一个导出表。
创建帐户或登录后发表意见