☰
Rust恶意PE样本逆向分析:从静态摸排到动态调试的完整实践
2026/10/21 6:54:02 网站建设 项目流程

前阵子连续处理了几个Rust编译的恶意样本,一开始我按老套路开IDA、看导入表、查字符串,以为跟常见的C/C++ PE一样能快速搞定,结果第一个样本就让我多耗了一天。Rust生态在恶意软件领域越来越活跃,甚至有安全社区专门整理了Rust-for-Malware-Development这类研究项目,把加载器、反调试、进程注入这些攻击技巧用Rust重新实现了一遍,目的是研究恶意样本特征、改进防护和检测能力。这篇文章把我实际逆向Rust PE文件的做法整理出来,从静态分析、调试检测到规则沉淀,覆盖一条完整可复用的分析链路。无论你是恶意样本分析师、蓝队应急人员,还是刚接触PE逆向的新手,这篇文章都值得收藏。

1. 背景与威胁画像:为什么Rust样本让分析者头疼

1.1 为什么Rust对恶意样本分析者不友好

Rust编译出来的PE,和C/C++编译出来的那套“老式”PE,几乎不是同一个画风。攻击者选择Rust,一方面是因为语言本身的内存安全特性,写出来的加载器、漏洞利用代码不容易因为野指针或缓冲区溢出把自己搞崩;另一方面是Rust标准库默认静态链接进二进制,运行时不需要额外分发一堆DLL,跨平台能力也强,一个项目可以在Windows、macOS、Linux三端复用。

对分析者来说,这个选择带来的麻烦非常直观。C/C++写的PE,导入表里常常能看到vcruntime140.dll、msvcp140.dll这些运行库,字符串里会有明显的ATL::、CString之类的痕迹,识别成本很低。Rust二进制不一样,哪怕是几百KB的小体量PE,里面塞满了std::io、core::fmt、std::fs这些标准库字符串,符号表中还可能保留大量以_RNv开头的v0格式mangled符号。初次接触你会觉得到处都是噪音,但反过来看,这些特征也成了判断“这是不是个Rust程序”的关键抓手。

再往深了说,Rust二进制还有一个很扎心的特点:就算作者只写了一个下载器的功能,编译出来的文件往往也比等价C++版本大一截。体积大意味着分析面广,入口点附近会先执行标准库的初始化逻辑,创建线程、做运行时准备,然后才走到用户定义的main。动态调试的时候,动不动就会断在一些跟业务无关的框架代码里,新人很容易迷失在调用栈里。

1.2 目前常见的Rust恶意软件形态

我最近接触到的Rust恶意样本,主要集中在这么几类。

第一类是下载器和加载器。这类样本通常只做一件事:从远程地址拉取下一阶段的载荷,写入临时目录或直接内存加载,然后交给后续代码执行。Rust的网络库和文件操作API都很顺手,攻击者写起来很轻松。

第二类是窃密程序。浏览器Cookie、登录凭据、加密钱包文件都是目标。Rust可以方便地遍历目录、读取SQLite数据库、调用Windows API,甚至借用serde这类序列化库把窃取的数据打包上传,整个过程代码量远小于C++。

第三类是勒索软件和代理隧道工具。跨平台部署的需求让攻击者更倾向于Rust,同一个业务逻辑可以在不同平台编译出不同版本,维护成本低。这类样本在静态分析时通常能看到大量与文件遍历、加密库相关的字符串。

分析这些样本时你会发现,Rust恶意软件虽然语言变了,攻击链条没变,还是“投递—加载—执行—驻留—外传”那套流程,只是每一环都换了一层皮。把PE文件的底层特征吃透之后,无论换什么语言,万变不离其宗。

2. 静态分析实操:先把PE文件的家底摸清楚

2.1 Rust PE在文件结构上的标志性差异

拿到一个电子样本,别急着上调试器,先做静态摸底。Rust编译的PE文件有几个标志性特征是可以直接通过工具观察到的。

第一个特征是编译器指纹。用Detect It Easy这一类工具扫描,很多Rust样本直接会显示rustc或Rust标识,这是最高效的判断方式。不过要注意,样本经过加壳、压缩或二次修改后,编译器指纹可能被抹掉,看不到指纹不代表它不是Rust。

第二个特征是段表。Rust PE并不像某些资料说的那样一定带.rustc段,实际样本里这个段并不稳定,但.text、.rdata、.pdata这几个段的结构和传统C/C++样本有明显差异。尤其是.rdata段,里面会堆积大量的UTF-8标准库字符串,比如std::io::stdio、core::fmt::Formatter之类,随便搜一下都是。

第三个特征是导入表。Rust的std库默认静态链接,传统C/C++程序里常见的vcruntime140.dll导入在这里大概率不存在。很多Rust写的加载器导入表干净得可怕,只有kernel32.dll、ntdll.dll、ws2_32.dll这几项,甚至有的连ws2_32.dll都没有,网络功能是用系统调用直接做的。当然,如果项目里引用了windows或winapi这些crate,导入会扩展,但整体数量仍然远少于同体量的C++程序。

第四个特征是符号和字符串。Rust的符号mangling格式在不同版本里有差异,新版用的是_RNv开头,老版本是_ZN...17h...E一类。如果没有做strip,导出表里可能直接能看到rust_begin_unwind、lang_start这类运行时符号,这基本可以直接锁定Rust身份。

对比维度C/C++ PERust PE
导入表常见vcruntime/msvcp等运行库DLL通常干净,仅少量系统DLL
字符串特征无统一标准库痕迹大量std::、core::、lang_start
符号mangling_Z或标准修饰名_RNv开头或_ZN...17h...E格式
体积加载器通常几十到几百KB同等功能往往明显更大
运行时依赖可能依赖VC运行库标准库静态链接,依赖少
反调试传统手法,特征明显可嵌入多线程,常配合环境检测

2.2 静态分析实操流程:从哈希到定位关键函数的一整套动作

我处理Rust样本的静态流程大概是这样的,分享出来你可以直接套用。

第一步,先算哈希并提交到公开威胁情报平台做关联查询。看历史检测结果和同源样本信息,能省下大量试探时间。很多Rust恶意样本在平台上早就被其他分析者拆过,文件名称、功能描述、关联的C2地址都有记录。

第二步,用Detect It Easy扫描编译器指纹和加壳状态。这里要注意一个小细节,DIE对于Rust的识别并不总是准确,如果它显示Rust但你存疑,可以再用文本搜索的方式确认:直接在二进制里搜索rustc字符串,带版本号的那种,比如rustc 1.7x.x,如果搜到,基本可以锁定。

第三步,用PE-Bear或者CFF Explorer看段表、导入表、导出表。重点记录导入DLL列表、是否有导出函数、是否存在明显异常的RWX段、是否有多余的加密隐藏数据段。Rust恶意软件很常见的做法是把载荷存放在.rdata或自定义节里,动态解密后放入申请的内存。看到某个节的原始大小和虚拟大小差距很大,就要留个心眼。

第四步,如果样本没有strip,直接上Ghidra或IDA加载。新版Ghidra对Rust符号的解析已经不错,但mangled符号的还原需要插件辅助,这一点后面单独讲。先用字符串窗口过一遍,http、https、.dll、AppData、Mutex、shellcode、AES这些都是高价值关键词,定位到字符串后看交叉引用,大部分关键行为函数就浮出水面了。

第五步,如果符号混乱或完全strip,就退回到入口点分析。Rust程序的入口逻辑不是直接跳到用户main,而是先经过std::rt::lang_start之类的运行时入口,再通过函数指针调用真正的业务入口。你在反汇编里看到对某个函数指针的间接调用,往上看通常能找到用户代码的起始地址,从那里开始分析效率最高。

2.3 快速提取Rust特征的Python脚本:批量分析时能救命

当你手里有一批样本等着归类,手动一个个开DIE再看字符串太慢了。我写了一个简单的pefile脚本,批量提取Rust特征。核心逻辑就是对比MZ头、扫描段名、导入DLL、导出表,再在二进制原始数据里搜索Rust标准库常见字符串。

import pefile import sys def rust_quick_check(path): pe = pefile.PE(path, fast_load=True) print(f"[*] {path}") print("[*] Sections:") for section in pe.sections: name = section.Name.rstrip(b'\x00').decode(errors='ignore') print(f" {name:<12} VSize={section.VirtualSize:>10x} RawSize={section.SizeOfRawData:>10x}") pe.parse_data_directories() if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'): print("[*] Imported DLLs:") for entry in pe.DIRECTORY_ENTRY_IMPORT: print(f" {entry.dll.decode(errors='ignore')}") else: print("[*] No imports") if hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'): symbols = [exp.name.decode(errors='ignore') for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols if exp.name] print(f"[*] Export count: {len(symbols)}") print(f" First exports: {symbols[:10]}") else: print("[*] No exports") with open(path, 'rb') as f: blob = f.read() markers = [b"std::io::", b"std::fmt::", b"core::str::", b"lang_start", b"panicked at", b"rustc"] hits = [m.decode(errors='ignore') for m in markers if m in blob] print(f"[*] Rust markers: {hits}") print("-" * 50) for p in sys.argv[1:]: rust_quick_check(p)

这段脚本有几个点值得说明。段表打印出的虚拟大小和原始大小,如果某个段明显异常,比如.rdata的虚拟大小是原始大小的几十倍,说明程序可能在运行时动态展开这部分数据,需要重点分析。导入表是判断Rust身份的重要依据,遇到只有kernel32和ntdll的PE就要提高警惕。脚本里的标记字符串覆盖了Rust标准库最常见路径,其中rustc匹配面比较大,所以我把rustc和std::系列分开打印,避免误判。

脚本跑完你会得到一个初步画像,比如“一个导入很少、包含std::io、无导出的PE”,这已经足够支撑你决定下一步是用字符串交叉引用定位行为,还是直接开调试器动手了。

3. 动态调试与反调试对抗:在调试器里“稳住”Rust样本

3.1 Rust样本常见的反调试与反沙箱手法

静态分析让你知道程序大概会做什么,但关键行为还是要动态调试确认。可Rust恶意软件往往自带一套环境检测逻辑,调试器一挂上就退出,或者直接在沙箱里休眠。

先看反调试。Rust本身没有内置反调试机制,攻击者通常通过windows或winapicrate直接调用Windows API,所以你能想到的经典反调试手法它都能用,而且Rust写起来很简洁。常见的有直接用IsDebuggerPresent检查PEB的BeingDebugged标志,也有用NtQueryInformationProcess查询调试端口,还有用NtSetInformationThread设置ThreadHideFromDebugger隐藏线程的。

时间差检测也是重灾区。rdtsc指令、GetTickCount、QueryPerformanceCounter,配合简单的延迟比较逻辑,就能判断当前环境是否被调试或插桩。这类检测在单核分析虚拟机里很容易误判,很多样本检测的不是“有没有调试器”,而是“运行速度是不是不正常”——一旦计时偏差超过预期就直接退出。

再来看反沙箱。Rust样本会检查进程名,遍历进程列表找x64dbg、ida、wireshark、procmon这些工具进程;也会检查父进程,看看启动自己的是不是explorer.exe,如果是自动化的沙箱进程名,直接休眠或退出;还会检查当前用户名、是否处于服务器版本系统、最近打开文档数量、交互式桌面是否可见。有些更离谱的会检查CPU核心数和内存大小,不符合预期就退出。

3.2 调试器对策与断点方案:跟反调试周旋的实战方法

面对这些反调试,我总结了一套比较稳的应对流程,适合在虚拟机里复现。

第一步,先拍干净快照。分析机里得备一份不带调试器的干净Windows镜像,把常用工具安装好。快照的意义在于,样本跑挂了或者创建了一堆子进程,你可以秒回初始状态反复试。

第二步,用x64dbg打开样本前,先把ScyllaHide插件配上。重点勾选隐藏PEB的BeingDebugged、NtGlobalFlag,再勾选HookNtQueryInformationProcess、NtSetInformationThread这些关键函数,让反调试API的返回值看起来一切正常。对于ThreadHideFromDebugger,ScyllaHide也有对应选项。不要以为挂了插件就万事大吉,我遇到过样本在GetTickCount和rdtsc时间差上做文章,插件管不了这种,所以还要准备下一步。

第三步,时间差检测要靠“跑放”策略绕过。先在非调试状态下运行一次样本,观察它落地了哪些文件、改了哪些注册表、建立了哪些网络连接,形成基线。再带调试器复跑,如果它在时间检测点卡住,不要傻等在断点里,调整思路:在GetTickCount、QueryPerformanceCounter、rdtsc这些关键指令处下断点,观察断下来时的时间和返回的值的差距,必要时直接改寄存器和栈返回值,让它认为时间正常。

第四步,设置API断点要有针对性。Rust加载器再花哨,最终也要落到具体行为上。文件操作就断CreateFileW、WriteFile、NtWriteFile,内存加载就断VirtualAlloc、VirtualProtect、NtProtectVirtualMemory,进程注入就断VirtualAllocEx、WriteProcessMemory、CreateRemoteThread,网络通信就断WSAStartup、InternetOpenW、WinHttpOpen。在Windows 10以上的系统里,很多API会走ntdll的zw系列或直接走系统调用,所以关键断点建议同时下在高层API和ntdll层的对应模式上。

第五步,Rust程序的线程很多,调试时经常会在不相关的线程里被断下来。建议用条件断点缩小范围,比如只在CreateFileW打开某个特定文件名时才断下。硬件断点在关键内存写入点上很有用,一旦Rust代码向分配的内存区域写入解密后的载荷,你就能第一时间跟进。

3.3 定位用户主逻辑的一种通用思路

我发现许多新手在调试Rust样本时最大的困惑是:好不容易在调试器里跑起来了,却分不清哪段代码才是恶意行为的主逻辑,因为运行时初始化的代码太多了。

这里分享一个我认为最有效的切入角度。Rust程序的入口经过std::rt::lang_start后,最终会通过一个函数指针间接调用用户定义的main函数。你可以在静态分析阶段就通过Ghidra找到lang_start的调用点,动态调试时在它后面紧跟的间接调用处下断点,一旦断下,当前栈帧往上一层就是用户主逻辑的调用现场。

如果没有静态分析条件,那就依靠API断点反推。断在VirtualAlloc上,返回地址通常会落在用户代码或某个动态生成的可执行区域。向上回溯调用栈,记录哪些地址属于.text段,哪些属于堆内存。属于.text段的地址背后往往就是你要找的Rust主逻辑函数。接下来以那个函数为起点重新分析,比从入口点顺着标准库代码一点点走完整得多。

4. 识别Rust运行时特征:从人工分析到检测规则沉淀

4.1 让Ghidra重新读懂Rust符号

Rust符号mangling格式和C++不一样,Ghidra默认的Demangle插件能处理一部分,但效果不理想。如果你不想在成千上万个_RNv开头的符号里迷失,需要做符号修复。

目前方案有两种。一个是使用Ghidra社区里现成的Rust符号恢复插件,加载后能识别较新的v0 mangling格式,把std::fs::read这种符号还原成可读形式。另一个是对较老的_ZN...17h...E格式符号,利用Ghidra自带Demangle的C++解析做部分还原,再加上人工修正。符号恢复后,交叉引用图的阅读难度直线下降,你能很快看到程序到底调用了哪些标准库函数,进而推测出功能模块。

要注意的是,符号修复只是辅助手段,恶意样本的作者很多时候会设置strip = true来删除符号表。这种情况下符号修复插件无能为力,只能靠字符串和API调用模式推断。这时我会优先搜索字符串窗里的关键URL、文件路径、互斥体名,再从这些字符串的交叉引用处往周边分析,效率远高于盲看反汇编。

4.2 YARA规则:怎么写出低误报的Rust PE特征

分析完成后,要沉淀成检测规则,YARA是最直接的手段。很多分析者喜欢用单一的std::字符串来识别Rust,这种做法误报率很高,因为C++程序也可能引入Rust库或者包含类似字符串。

我自己的经验是,Rust PE检测规则至少要有两层证据叠加。第一层是PE结构特征,比如MZ头、导入DLL数量较少、没有常规VC运行库依赖。第二层是Rust运行时特有的字符串,比如std::io、core::str、lang_start、panicked at、rustc版本号。两类条件同时满足,才能给出“疑似Rust PE”的判定。

下面这条规则是我常用的模板,侧重恶意样本常见的Rust编译特征,你可以直接拿去做基础版本再调整:

rule Rust_PE_Indicators { meta: author = "analyst" description = "Detect Rust compiled PE files with common runtime markers" strings: $rust_fmt = "std::fmt::" ascii $rust_io = "std::io::" ascii $rust_core = "core::str::" ascii $rust_init = "lang_start" ascii $rust_panic = "panicked at" ascii $rust_ver = /rustc\s+\d+\.\d+\.\d+/ ascii condition: uint16(0) == 0x5A4D and (1 of ($rust_*) and ($rust_init or $rust_panic or $rust_ver)) and filesize < 20MB }

规则的关键在uint16(0) == 0x5A4D这个MZ头判断,先把目标限定在PE文件,再用两个独立的Rust特征组加强条件,避免单纯靠std::fmt这类字符串误伤其它程序。filesize < 20MB这个限制是为了过滤掉某些合法的大型Rust应用,实际使用人员可以根据样本库分布调整。

规则的维护是一项长期工作。Rust官方版本更新后字符串会有变化,建议定期用已知样本集重新跑一遍规则,统计命中率和误报率。我每个月都会把新收集的Rust恶意样本加入回归测试集,验证规则有没有失效。

4.3 行为检测设计:动态分析怎么沉淀成可持续的防御能力

YARA规则能解决“已知样本”的快速识别,但面对新的变种,静态特征往往不够。要让检测能力持续有效,必须把动态分析中观察到的高价值行为沉淀成行为检测规则。

我在实际做检测规则时,重点关注几个行为组合。第一个是进程链异常,比如一个文档进程启动了powershell,或者一个不常做这类操作的进程调用了CreateRemoteThread,这类行为组合本身就是很强的信号。第二个是内存属性变化,Rust加载器几乎都要用到VirtualAlloc加VirtualProtect把RW内存改成可执行,大量在短时间内执行这个模式的进程需要重点跟踪。第三个是多个Rust样本共有的网络特征,比如固定的User-Agent、请求路径、心跳间隔,这些从动态分析日志里很容易提取。

行为检测的价值在于它不完全依赖语言的字符串特征,即使攻击者把Rust二进制做了全面混淆,只要最终行为还是“下载—解密—执行”的流程,行为层就能抓住尾巴。对于新上手的朋友,我建议先把动态分析里的每一条关键API调用记录下来,形成行为时间线,再根据时间线写EDR规则或Sysmon采集规则,这套做法非常实用。

5. 常见问题与排查技巧实录:我在实战里踩过的坑

5.1 样本在沙箱里不运行怎么办

这是最经常遇到的问题。Rust样本进了沙箱就退出,或者一直休眠,可能不是样本被破坏了,而是它检测到了非真实环境。

我的排查顺序是:先看它的退出行为是干净退出还是异常崩溃。如果退出码是0,大概率是环境检测主动退出;如果是崩溃,可能是依赖缺失或者兼容性问题。再用Process Monitor记录进程启动后的文件、注册表、网络访问,看它在退出前都做过哪些操作,判断检测点大致落在哪一类。

对策上,优先调整虚拟机环境:把用户名改成常见管理员名称,确认桌面会话是交互式的,检查CPU核心数设置,确保分析机的配置不像自动化沙箱。如果是互斥体检测,可以在样本启动前手动创建对应的互斥体来绕过。核心思路是让样本相信自己在真实用户的电脑上。

5.2 符号错乱导致找不到main入口

Rust样本如果strip了符号,你会看到大量FUN_140001020之类的未知地址,很难找到分析切入点。

我试过最可靠的方式是“字符串+交叉引用”组合。先在Ghidra或IDA的字符串窗口里搜cmd、powershell、http、AppData这些行为相关关键词,对每个命中字符串看交叉引用,通常能顺藤摸瓜找到使用它们的函数。再结合API断点法,动态跑一遍,在VirtualAlloc、CreateProcess这类行为API上下断点,断下后回溯调用栈,就能锁定负责关键行为的函数区域。这个方法比从入口点顺着运行时初始化代码硬走要快得多。

5.3 线程过多、一调试就崩溃

Rust标准库自带线程池和运行时机制,样本自身也会创建多个工作线程。在线程切换频繁的情况下,你一旦在任意线程里设置了可能触发异常的断点,程序很容易崩溃。

应对方式有两个要点。一是优先使用条件断点,严格限定到特定模块地址、特定文件路径或特定API参数,尽量不要设全局普通断点。二是遇到ThreadHideFromDebugger行为时,不要硬刚,改用硬件断点或观察模式,在某些关键内存写操作上下断,避免触动线程隐藏逻辑引发系统异常。如果样本在调试器运行过程中频繁崩溃,建议备份当前内存快照,用分析工具对比崩溃前后的内存变化,有时数据比逆向过程更能说明问题。

5.4 内存里看到密文但不知道数据往哪走

Rust恶意样本经常会把第二阶段的shellcode或配置文件加密存放在PE文件里,运行时再解密。你在调试时发现内存中有大段看起来很随机的高熵数据,这就是典型的密文区域。

这时不要着急盯着数据本身,而是往前追。在VirtualAlloc之后观察分配大小,在memcpy或RtlCopyMemory处查看数据来源,在可能的解密函数附近下断点,看它调用前后内存内容变化。很多Rust样本会直接用aes、chacha之类的crate实现解密,只要确认算法,就可以用已知明文或固定密钥来验证。注意,Rust标准库自带的一些加密辅助类会出现在.rdata中,如果你看到chacha20、aes等字符串,顺着字符串交叉引用往往能找到解密处理点。

5.5 环境缺依赖库导致分析失败

有些Rust样本虽然标准库静态链接,但如果用了第三方C库,或者编译时选择了动态链接模式,分析机上会出现缺少vcruntime140.dll、msvcp140.dll这类VC运行库的情况。还有一种情况是系统缺少某些系统服务或功能组件,样本启动时报错。

分析机里常备一份Visual C++运行库合集,遇到报错随手装上。关键是判断这个报错到底是环境问题还是样本自身的行为陷阱。有些恶意样本会故意检测到缺少运行库后弹出错误对话框来迷惑分析者,实际上它什么都不缺。这种时候拿Process Monitor确认一下,样本初始化阶段是否真的加载了缺失的模块,再判断是否需要补齐依赖。

写在最后

Rust样本的逆向分析,说难也难,说简单也简单。难的是你不了解它的运行时结构,容易在标准库代码里绕圈子;简单的是它的语言特征足够清晰,只要静态摸排做扎实,后续调试和规则沉淀都有章可循。我个人最大的体会是,分析Rust恶意样本时,别急着上调试器,先把是不是Rust、有没有反调试、关键字符串在哪这三件事搞清楚,再决定调试策略,通常会顺畅很多。最近我也在整理一套基于Ghidra的批量Rust符号恢复小工具,目标是让一批样本可以在无人值守的情况下自动完成初步特征提取和符号还原,等验证稳定了再写一篇更完整的分享。如果你最近也在处理Rust样本,欢迎在实践中尝试这些方法,遇到新的坑位也欢迎一起交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询