跳转到帖子

Windows 样本初筛中的 API 哈希识别与还原思路

精选回复

发布于

背景

在分析 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.dll
  • advapi32.dll
  • wininet.dll
  • ws2_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.dll
  • kernel32.dll
  • kernelbase.dll
  • advapi32.dll
  • user32.dll
  • ws2_32.dll
  • wininet.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 = 0
  • dwSize 为一段 shellcode 或解密后数据长度
  • flAllocationType = 0x3000
  • flProtect = 0x40 或 0x04

如果参数形态完全对不上,就要怀疑算法或 DLL 范围是否弄错了。

注意事项

  • 不同系统导出函数有差异:建议在与样本运行环境接近的系统上提取导出表,尤其是 kernelbase.dll。
  • 转发导出需要处理:例如某些 API 会从 kernel32.dll 转发到 kernelbase.dll,静态还原时要注意函数地址可能不是最终实现。
  • 哈希常量可能加密存放:有些样本会先解密哈希表,再逐项解析,静态搜索立即数可能找不到。
  • 同一哈希算法可能有碰撞:虽然概率不高,但遇到短函数名或自定义截断时要结合调用上下文判断。
  • 模块名可能参与计算:不能只按函数名跑字典,必要时把 DLL 名也加入枚举。

总结

API 哈希并不复杂,但会明显降低初筛效率。比较稳妥的处理方式是:先识别导出表遍历逻辑,再确认哈希算法,最后用本机 DLL 导出表批量还原,并结合调试参数验证。

在实际样本分析中,不需要一开始就完整反编译所有逻辑。只要先把关键 API 还原出来,例如内存分配、解密、进程注入、网络通信相关函数,样本的大致行为链路通常就能很快展开。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意
我一般初筛遇到 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 不同。可以按调用点分组处理,不要把所有常量丢进一个算法里硬撞。
补一个实际还原时比较省时间的办法:把 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` 三个模块一起作为候选,不要只盯一个导出表。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。