简介:一份WannaCry勒索病毒反编译源码包,共3个文件,由2个C语言源文件与1个Markdown说明文档组成,压缩包大小仅212KB,便于快速查看。源码覆盖蠕虫传播模块和文件解密器模块,适合安全研究员、逆向工程师以及高校网络安全专业学生,用来研究永恒之蓝漏洞利用链、勒索加密流程和横向扩散机制;该病毒当年曾利用高危漏洞在全球百余国家爆发,加密文档、表格、图片等大量办公文件。C源文件是逆向分析的重点,MD文档则给出病毒背景、行为注释和受影响文件类型,能帮助还原攻击脉络。资源以开源形式发布,已有2301人学习下载,对理解WannaCry事件及日常安全加固具有参考价值。通过研读代码,可具体看到文件枚举、扩展名筛选、加密算法调用和SMB漏洞利用等关键实现。
1. WannaCry 反编译:把勒索逻辑从二进制里抠出来
2017 年那场勒索风波里,WannaCry 的样本在公开后很快就被反编译出完整执行链:先利用 Windows 的 SMB 漏洞做横向渗透,再对磁盘文件批量加密,释放勒索说明,最后尝试自删除抹掉痕迹。对做安全和应急响应的从业者来说,对勒索样本做反编译不是只为了看它怎么加密,而是要在不运行样本的前提下,把传播入口、加密流程、赎金地址、自毁逻辑这些关键决策全部从机器码里还原出来。这篇笔记就围绕 WannaCry 反编译这件事,按我平时分析恶意样本的顺序展开:环境怎么隔离、工具怎么选、代码怎么追、哪些地方最容易翻车,以及最后怎么把分析结果落成检测规则。新手可以按步骤照做,熟手可以直接跳到第三节的追码路线和第四节的排错清单。
2. 先关笼子再解剖:反编译前的最小隔离环境与工具链
2.1 为什么静态反编译优先于动态运行
勒索样本和普通恶意软件最大的区别在于,一旦执行就会产生不可逆的文件加密,删除的原始文件可能永远找不回来。虚拟机快照确实可以回滚系统盘,但样本仍可能利用内核漏洞尝试逃逸,或者顺着局域网 SMB 漏洞感染同一网段的其他机器,这些风险会直接扩散到整个分析环境。我的分析顺序因此始终是静态优先:先在反编译视图里把逻辑读明白,把所有可疑行为列成清单,最后才考虑在隔离沙箱里做一次受控验证,而且验证前必须再次确认快照存在。
静态反编译的核心价值在于“把决策过程摊开”。动态运行时你只能看到进程创建了哪些文件、访问了哪些 URL,看不到它为什么这么做——什么时候走加密分支、什么时候自杀退出、域名请求失败之后怎么回退,这些只有读反编译伪代码才能看清楚。对 WannaCry 这种带开关和自删除逻辑的样本,这一差距尤其明显,光靠行为日志很难还原出完整的退出条件。
还有一个务实原因:动态分析要等样本执行很久才能收集完行为,文件多、线程多的时候甚至要跑十几分钟,反编译则可以在几十分钟内把代码骨架搭出来,直接定位关键 API 调用,再回到动态环境验证疑点。先静后动是我一直保持的习惯,下面所有步骤都建立在这个顺序上,如果你上来就把样本丢进沙箱,等于把最宝贵的第一手信息全让给了执行引擎。
2.2 分析机隔离检查与快照命令
分析机只需要一台干净的 Windows 虚拟机,关键在网络和共享配置。我一般只启用仅主机网络,不配置网关,宿主机与虚拟机之间只通过单向共享文件夹交换样本,分析结束立即恢复快照。下面这组命令在宿主机上执行,用来确认网络状态并给分析 VM 打快照:
# 宿主机:确认仅主机网络存在,并给分析 VM 打一个干净快照 VBoxManage list hostonlyifs VBoxManage snapshot "Win10Analyzer" take "pre-analysis" \ --description "clean baseline before any sample analysis"逻辑说明:VBoxManage 是虚拟机软件的官方命令行工具,list hostonlyifs 打印仅主机网卡列表,用于确认分析网络没有绑定到物理网卡;snapshot 命令给名为 Win10Analyzer 的虚拟机打快照,pre-analysis 是快照名,--description 里的备注会随快照一起保存,便于多轮分析后区分基线。打完快照还要顺手确认虚拟机的共享文件夹是空的,剪贴板设为单向,拖拽关闭,串口并口全部禁用,这些配置在虚拟机的设置面板里花两分钟就能检查完。
快照打完,还要在虚拟机内部检查共享盘残留,防止宿主机目录被意外映射到分析机里。之前有一次我就是漏了这一步,样本运行后顺着映射盘把宿主机上的测试资料全部加了密,回滚样本机已经来不及,教训很刻骨。
# Windows 分析机:确认共享盘残留为 0 net use wmic share get name,path逻辑说明:net use 显示当前会话中的 SMB 映射盘,输出为空说明没有残留网络盘;wmic share 列出本机共享目录,正常情况下只有 ADMIN$、C$、IPC$ 这类系统默认共享,看到用户自建共享就手动移除。检查完顺便在分析机上执行下面的命令,把样本的 SHA256 记下来,后面和反编译结果一起归档:
Get-FileHash C:\samples\wncry_sample.exe -Algorithm SHA256Get-FileHash 计算得到哈希值用于样本去重和团队内部标记,避免同一份样本被重复分析。这一步不是可选项,哈希一致才能确认你手里的文件和反编译结果对应的是同一个二进制,后续写检测规则时也要靠它核对样本家族。
快照创建完成后,建议把虚拟机的自动更新服务停掉,同时关闭 Windows Defender 的实时防护。防病毒组件会把样本当威胁清除,还会产生大量干扰日志,让分析误以为样本有反调试行为。我的做法是先把样本导入虚拟机再关闭防护,顺序反了样本可能已经被隔离区吞掉。
2.3 开源反编译框架的无头分析命令
工具选型上,我目前用开源反编译框架处理勒索样本,足够应付常见的混淆和壳,还支持脚本批量分析。商业反编译器交互体验更顺,但开源框架的无头模式更适合样本排队:不需要打开图形界面,直接导入工程,跑完分析后导出函数列表和字符串,整条链路能脚本化。
# 无头模式导入单个样本,并在分析后运行自定义脚本 analyzeHeadless /opt/re_workspace wncry_project \ -import /samples/wncry_sample.exe \ -postScript ExportFunctions.java \ -scriptPath /opt/analyst_scripts \ -deleteProject \ -overwrite逻辑说明:第一个参数是工程目录,第二个是工程名;-import 指定要分析的样本绝对路径;-postScript 指定分析完成后执行的自定义脚本,这里我用自己写的 ExportFunctions.java,把每个函数的偏移和名称导出成文本;-scriptPath 是脚本搜索路径;-deleteProject 表示分析完成后删除临时工程,避免磁盘被大型工程占满;-overwrite 允许覆盖同名工程。如果你想先手工看一遍,可以去掉 -postScript,框架默认分析完会在图形界面里直接呈现反编译结果。
跑这一步通常需要几分钟到十几分钟,样本越大越久。完成后我第一件事不是翻伪代码,而是看导出的函数列表和字符串,先建立索引再深入阅读,比从头到尾翻反编译结果高效得多。无头模式失败时,优先检查样本路径里有没有空格和中文,工程目录是否可写,这两类问题占了八成失败原因。
提示:样本只能来自合规渠道,比如机构沙箱捕获或威胁情报平台交换的样本。分析目标是还原行为用于防御,不要在个人环境里保存和分享原始二进制,更不要在任何联网机器上直接运行。
3. 从 PE 头到加密例程:WannaCry 核心逻辑的反编译路线
3.1 用 Python 解析导入表,建立恶意 API 清单
导入表是静态分析的第一站。勒索样本要完成加密和传播,必然要导入网络、加密、进程和服务相关的系统 API,把这些 API 从 PE 头里拉出来,等于先拿到了执行骨架。WannaCry 这类样本里,wininet.dll 的 InternetOpenUrlA 负责网络请求,advapi32.dll 的 CryptAcquireContextA 和 CryptEncrypt 负责加密,kernel32.dll 的 CreateThread 负责并发加密任务,导入表分析可以把这些名字一次性暴露出来。
下面是一段用 Python 标准库解析导入表的脚本,不依赖第三方包,直接读取 PE 文件:
import struct def pe_imports(data): if data[:2] != b'MZ': return [] pe_off = struct.unpack_from('<I', data, 0x3C)[0] if data[pe_off:pe_off+2] != b'PE': return [] opt_off = pe_off + 24 magic = struct.unpack_from('<H', data, opt_off)[0] # PE32 与 PE32+ 的数据目录起始偏移不同 dd_off = opt_off + (96 if magic == 0x10B else 112) imp_rva, _ = struct.unpack_from('<II', data, dd_off + 8) if imp_rva == 0: return [] num_sections = struct.unpack_from('<H', data, pe_off+6)[0] opt_size = struct.unpack_from('<H', data, pe_off+20)[0] sec_off = opt_off + opt_size sections = [] for i in range(num_sections): s = sec_off + i*40 vsize, va, raw_size, raw_ptr = struct.unpack_from('<IIII', data, s+8) sections.append((va, vsize, raw_ptr, raw_size)) def rva_to_off(rva): for va, vsize, raw_ptr, raw_size in sections: if va <= rva < va + max(vsize, raw_size): return raw_ptr + (rva - va) return None imports = [] idx = 0 while True: entry = rva_to_off(imp_rva + idx*20) if entry is None: break name_rva, first_thunk = struct.unpack_from('<II', data, entry+12) if name_rva == 0 and first_thunk == 0: break dll_off = rva_to_off(name_rva) dll_name = data[dll_off:data.find(b'\x00', dll_off)].decode('ascii', errors='ignore') funcs = [] thunk_rva = first_thunk bitness = 8 if magic == 0x20B else 4 while True: t_off = rva_to_off(thunk_rva) if t_off is None: break raw = int.from_bytes(data[t_off:t_off+bitness], 'little') if raw == 0: break if raw & (0x80000000 if bitness == 4 else 0x8000000000000000): funcs.append(f'ord_{raw & 0xFFFF}') else: hint_off = rva_to_off(raw) if hint_off is not None: fname = data[hint_off+2:data.find(b'\x00', hint_off+2)].decode('ascii', errors='ignore') funcs.append(fname) thunk_rva += bitness imports.append((dll_name, funcs)) idx += 1 return imports if __name__ == '__main__': for dll, funcs in pe_imports(open(r'C:\samples\wncry_sample.exe','rb').read()): print(f'{dll}: {", ".join(funcs[:20])}')逻辑说明:先从 DOS 头的 0x3C 偏移读取 e_lfanew,跳到 PE 头,再从可选头读取数据目录表的起始位置,导入表目录固定是数据目录的第 1 项;RVA 转文件偏移通过节表完成,我这里只支持常规节表,遇到畸形节表会直接返回空。bitness 按 PE 是 32 位还是 64 位决定 thunk 的宽度,WannaCry 这类老样本一般是 32 位,也就是 magic 等于 0x10B。导入函数名如果通过序号导入,会输出 ord_ 加序号,这种情况在系统 DLL 里不常见,但做兼容时不能忽略。
跑完脚本,重点看几个 DLL:kernel32.dll 里的 CreateThread、CreateServiceA,advapi32.dll 里的 CryptAcquireContextA、CryptEncrypt,wininet.dll 里的 InternetOpenUrlA。如果这组函数扎堆出现,基本可以按勒索样本的路径继续追,下一步就是去字符串区找触发这些 API 的条件。
3.2 按字符串交叉引用定位赎金、后缀与开关域名
PE 导入表只能说明样本具备网络和加密能力,具体决策逻辑还得靠字符串。WannaCry 的样本里存在几个高辨识度字符串:加密后文件的后缀、勒索说明里的比特币地址、释放出来的子文件名称,以及一个决定是否继续运行的内网开关域名。先在原始二进制里做一次快速筛选:
import re data = open(r'C:\samples\wncry_sample.exe','rb').read() cands = [b'Wanna', b'WNCRY', b'.WNCRY', b'@', b'http', b'bit', b'task'] for s in re.findall(rb'[\x20-\x7e]{4,}', data): if any(k.lower() in s.lower() for k in cands): print(s.decode('ascii', errors='ignore'))逻辑说明:正则表达式[\x20-\x7e]{4,}抽取所有长度大于等于 4 的可打印 ASCII 连续序列,过滤条件是字符串里包含候选关键词中的任意一个;输出结果会展示释放文件名、URL、比特币地址和勒索说明文本片段。拿到这些字符串之后,回到反编译视图里按字符串做交叉引用,每个引用都会指向某个具体函数,从那个函数出发就能连到执行逻辑。
这里有个容易被忽略的点:开关域名字符串通常只被访问一次,访问结果决定样本是否继续执行。反编译视图里看到类似if (InternetOpenUrlA(handle, url, ...) != NULL) return;的结构,就是典型的开关逻辑。不要急着把这个域名当 C2,它对分析的价值是帮你确认样本在当前环境里会不会继续干活,网络分析时还要结合请求频率和回传数据综合判断。
字符串筛选结果和导入表放在一起,就能形成一份“样本档案”:它要加密什么、往哪回传、释放哪些文件、在什么条件下退出。接下来才进入真正的反编译阅读阶段,按入口函数往下追。
3.3 从入口函数追到加密循环:四步阅读反编译伪代码
进入反编译视图之后,不建议漫无目的地翻函数,而是按下面的顺序四步走,每一步都以下一步的输出作为输入:
- 从函数列表里找入口点,通常是 entry 或 WinMain,双击进入后确认它做的事情:获取系统版本、检查语言设置、创建工作互斥体。工作互斥体常出现在勒索样本里,目的是避免同一进程被重复启动,CreateMutexA 的第一个参数就是互斥体名,记下这个名字。
- 在入口函数内部找到 CreateThread 调用,第三个参数是线程回调函数的起始地址,双击跳过去。主逻辑很少直接在入口函数里展开,大概率被塞进某个工作线程。
- 进入线程回调后,重点找循环结构。勒索样本的加密行为必然是一个大循环:遍历根目录、枚举文件、跳过系统目录、把后缀替换成自定义扩展名,最后调用 CryptEncrypt 写回文件。反编译视图里看到 FindFirstFileA、FindNextFileA、CryptEncrypt 三个调用点在同一个函数中连续出现,就可以断定这里是加密主循环。
- 顺着数据引用往回找,定位全局配置结构体,这个结构体里通常放着要遍历的盘符列表、密钥句柄、赎金文本路径和开关域名。在反编译视图里把结构体字段标记出来,后续分析效率会提高很多。
阅读伪代码时,我习惯把关键 API 的用途先列成一张表,遇到调用就对照表判断它在整个恶意行为里的位置,这张表在写报告和做检测规则时也能直接复用:
| 反编译中看到的 API | 可能用途 | 下一步追什么 |
|---|---|---|
| CreateServiceA / StartServiceA | 注册系统服务实现持久化 | 服务启动路径指向哪个释放文件 |
| CryptAcquireContextA / CryptEncrypt | 加密文件内容或会话密钥 | 密钥句柄来源,确认是 RSA 还是 AES |
| InternetOpenUrlA / HttpSendRequestA | 访问开关域名或回传信息 | 返回值分支逻辑,确认开关条件 |
| CreateThread / CreateProcessInternalW | 开启加密线程或释放子进程 | 线程回调地址和子进程参数 |
| DeleteFileA / MoveFileExA | 自删除或清理原始文件 | 触发时机,是否在加密完成后调用 |
还有一点要注意:某些样本在入口会先检测系统语言,发现特定语系就直接退出,这是为了避开特定地区用户,分析时遇到语言判断分支可以跳过,不要死磕。某些本地化勒索样本也会在字符串区里留下大量语言判断表,直接看分支条件就知道它针对哪些地区。到这一步为止,反编译核心链已经建立:入口函数创建互斥体和工作线程,线程进入文件扫描循环,在每个文件上调用加密 API,完成加密后通过开关域名判断是否继续,最后自删除。有了这条链,回到动态环境验证时只需要确认几个关键断点,不会再一头扎进行为日志里大海捞针。
4. 反编译勒索样本的五个常见翻车点与排查思路
4.1 一运行就加密全盘:把动态验证的成本想得太低
现象:分析机快照没打,双击样本后几分钟发现整个 C 盘文件后缀全变了,正在跑的分析进程直接崩掉,想回滚才发现虚拟机没有可用快照。
原因:没确认共享文件夹和映射盘状态,样本按盘符遍历时把宿主机映射盘也扫了进去;更常见的是快照根本不存在,或者快照打在很久之前,分析过程产生的大量样本和工具数据全部丢失。
解决:任何样本进虚拟机前,先看 2.2 的检查命令输出,并把虚拟机所有网络断开;快照必须在分析前命名创建,并实际验证一次回滚流程。分析过程中网卡断开能彻底避免局域网横向传播,我现在的习惯是快照名称里带上样本哈希前八位,多份样本同时分析时不会搞混。动态验证前打开任务管理器,把基线进程列表截图保存,样本跑起来后直接对比多出来的进程,而不是等行为日志生成完毕再找线索。
4.2 反编译视野里全是数据没有代码:加壳样本先脱壳再分析
现象:无头分析完成后打开函数列表,看到的都是短小的 sub_401000 这类函数,字符串表里几乎没有业务字符串,只有壳本身的特征。
原因:样本被压缩壳或加密壳保护,导入表和字符串区段都被隐藏,反编译框架默认分析没有先还原内存映像,所以拿到的不是真实代码。
解决:先用节区熵值判断壳,我常用的办法是按节区计算香农熵,超过 7.0 就高度可疑。下面是一个快速熵计算函数,可以直接套用:
import math, collections def shannon_entropy(data: bytes) -> float: if not data: return 0.0 freq = collections.Counter(data) n = len(data) return -sum((c / n) * math.log2(c / n) for c in freq.values())逻辑说明:shannon_entropy 把字节分布映射到 0 到 8 之间,普通代码节熵值大多在 5 到 6.5,加壳或加密后的节区因字节分布接近均匀,熵值通常超过 7.0。生成样本后,遍历每个节区分别计算熵,挑出异常高的节区,再用对应壳的脱壳工具还原原始入口,重新导入反编译框架分析。如果是自实现的壳,静态脱不掉,只能放到隔离 VM 里让样本自己解码后 dump 内存,这个动作的前提是快照和网络隔离都已完成。
4.3 追踪不到自删除与服务创建:回调函数入口找错了
现象:按导入表找到 CreateServiceA,交叉引用只有一个地址,跳过去发现是系统库的代码,不是样本自身的调用点。
原因:样本通过 GetProcAddress 动态获取系统 API 地址,导入表里的调用点只在延迟绑定区,真正的调用发生在运行时,静态反编译视图无法直接关联。
解决:先找 GetProcAddress 的调用点,把 API 名字符串和它的交叉引用对应上;或者直接在调试器里对 CreateServiceA 下断点,运行时回调用栈找返回地址。调试器附加进程后,对目标 API 下断点,样本触发断点时调用栈里会显示从样本哪个地址发起的调用,把这个地址记下来,回到反编译视图顺着这个地址向上翻函数边界,就能找到真正的封装函数。
4.4 把 RSA 会话密钥当成文件加密:算法定位张冠李戴
现象:反编译视图里找到了 CryptEncrypt,以为这就是文件加密点,往前追参数时发现传入的缓冲区只有 256 字节,和文件大小完全对不上。
原因:样本往往有两层加密:先用 RSA 加密对称会话密钥,再用 AES 加密文件本身。CryptEncrypt 调用点有两种,传大文件、处在遍历循环里的是文件加密;只调用一次、输入是固定长度密钥块的是会话密钥加密。
解决:先看 CryptEncrypt 的参数长度和它是否处在外层目录遍历循环中。处在外层循环、调用频率高的是文件加密;只调用一次、输入长度固定的是会话密钥加密。如果混淆了这两层,后面分析密钥恢复方案会完全走偏。区分时还可以看上下文代码里有没有显式的密钥长度常量,例如 0x80 表示 128 位 AES,0x100 表示 256 位 AES,而 RSA 密钥通常以证书或密钥容器的形式出现。
4.5 只看主线程漏掉工作线程:加密进度条的干扰
现象:主线程逻辑看完没找到文件遍历循环,却在字符串视图里看到进度百分比相关文本,进度计算代码存在但找不到调用者。
原因:样本创建了多个工作线程,每个线程负责不同盘符的遍历和加密,主线程只负责创建任务队列和更新进度显示,只跟着入口函数走就会漏掉真正的加密体。
解决:建立线程分析清单,把入口函数里所有 CreateThread 的第三个参数都拉出来逐个反编译。勒索样本普遍多线程,凡是回调函数里出现 FindFirstFileA、FindNextFileA 或 CryptEncrypt 的,都是核心逻辑,至少要往下追五到十层调用关系才算完。不放心就用调试器附加进程,枚举线程的起始地址,和反编译视图里的函数地址一一对应,地址能对上的就是工作线程入口,对不上的再去查线程穿越系统 DLL 后真正返回到哪个样本地址。
5. 把反编译结果变成检测能力:IOC 提取与 YARA 规则落地
5.1 从反编译视图提取主机与网络 IOC
反编译的最终目的是防御,拿到执行链后要把它转成机器可识别的特征。我通常把 IOC 分成主机和网络两类,主机类包括释放文件名、互斥体名、加密后缀、注册表服务名,网络类包括开关域名、支付地址和 HTTP 请求特征。下面是从 WannaCry 反编译结果里最常见的提取位置:
| 类别 | IOC 项 | 提取位置 |
|---|---|---|
| 主机 | 自定义文件后缀 .WNCRY | 文件遍历循环里的后缀赋值处 |
| 主机 | 释放的可执行文件 | 写入文件的 API 参数 |
| 主机 | 互斥体名 | 入口函数的 CreateMutexA 参数 |
| 网络 | 开关域名 | 开关逻辑的 InternetOpenUrlA 参数 |
| 网络 | 支付地址 | 勒索说明文本字符串 |
提取时注意区分通用特征和家族特征:通用特征如“调用 CryptEncrypt”太宽,误报会很高;家族特征如互斥体名和释放文件名才适合做规则。WannaCry 的互斥体名和释放文件名都是固定字符串,这类特征命中后基本可以锁定家族。
5.2 一条可验证的 YARA 检测规则
把上面的特征落成 YARA 规则,可以这样写:
rule wannacry_like_ransomware { meta: author = "analyst-tips" description = "detect pe with wannacry-like strings and crypto imports" strings: $s_wncry = "WNCRY" ascii wide $s_suffix = ".wncry" ascii wide $s_mutex = "wncry" ascii wide $s_task = "taskdl.exe" ascii wide $s_url = "http" ascii wide condition: uint16(0) == 0x5A4D and filesize < 5MB and 3 of ($s_*) and pe.imports("advapi32.dll", "CryptEncrypt") }规则说明:uint16(0) == 0x5A4D确认文件是 PE 格式,filesize < 5MB过滤掉大文件降低扫描成本,3 of ($s_*)表示五个特征字符串至少命中三个,最后一条条件要求样本必须导入了 CryptEncrypt。验证时准备两组文件,一组是系统自带可执行文件,一组是收集到的同家族样本,分别跑 yara 并开启 -s 选项显示命中的字符串,观察误报和漏报,再调整字符串数量和阈值。
我现在拿到一个新样本,第一件事并不是立刻丢进反编译框架,而是先算熵、拉导入表、提取字符串,把三样结果摆在一起再决定追哪条逻辑线。这个顺序是从几次翻车里换来的,尤其是那次把会话密钥加密当成文件加密,白白浪费了半天时间。希望帮到你。
本文还有配套的精品资源,点击获取