Rust开发Wasm初试,以及Wasm数据段加密的探索

本文介绍了使用Rust和WebAssembly进行前端代码逻辑保护的实践。作者选择Rust开发Wasm项目,通过wasm-pack等工具构建,并演示了字符串处理函数。针对Wasm逆向中数据段暴露敏感信息的问题,提出了编译期常量加密和依赖路径脱敏两种方案,使用过程宏和Python脚本实现,提升了代码安全性。

文章目录

起-技术选型

最近因为工作需要,开始研究前端代码逻辑的保护,进而了解到WebAssembly(Wasm),这种基于栈式虚拟机的二进制指令格式不仅在效率上比js更胜一筹,其类似汇编的低层级指令风格也使其更难被逆向,于是开始进一步尝试。

了解到主流的开发语言为C/C++、Rust以及Go。我们的场景不希望Wasm大小占用太多带宽,于是首先排除带GC的Go;而基于社区生态等因素考量,最后选择了Rust来进行这次尝试。

关于教程,我推荐这篇电子书:Rust 🦀 和 WebAssembly 🕸

承-上手实战

首先使用Rust进行开发离不开构建环境,包括编译器rustc以及包管理器cargo,这些必要组件都可以通过rustup安装管理。而对于Wasm开发而言,如果想要更好地与js集成,有几个工具十分推荐:

  • wasm-pack:除了能构建Wasm,还能自动生成js胶水文件,可以通过cargo install wasm-pack来安装
  • binaryen:一个Wasm工具集,包含了wasm-opt等优化工具,可以通过Github获取

我们可以使用现有的代码模板来快速创建Wasm项目。首先确保已经安装了cargo-generate(可以通过cargo install cargo-generate进行编译安装),然后通过以下命令来创建项目:

Terminal window
cargo generate --git https://github.com/rustwasm/wasm-pack-template

根据提示输入项目名称后,便可以进入项目文件夹进行下一步开发了。这里我们创建了一个叫simple-signer的项目来演示,简单地将传入的内容与SECRET拼接后,把每个字节与0x1f进行异或,再用base64进行编码返回:

mod utils;
use wasm_bindgen::prelude::*;
use base64::prelude::*;
#[wasm_bindgen]
extern "C" { // 从外部ABI导入
#[wasm_bindgen(js_namespace = console)] // 绑定到js运行时的console.log函数
fn log(s: &str);
}
const SECRET: &str = "1a2B3c4D5e6F7g8H";
const XOR: u8 = 0x1f;
#[wasm_bindgen] // 将声明的函数暴露到Wasm中,供外部调用
pub fn add_str_and_xor(content: &str) -> String {
let final_content = format!("{}{}", content, SECRET);
log(&format!("content to xor: {}", final_content)); // 调用console.log函数打印
let xor_content = final_content.as_bytes()
.iter()
.map(|c| c ^ XOR)
.collect::<Vec<u8>>();
BASE64_STANDARD.encode(&xor_content)
}

接着,我们使用以下命令将源码编译成Wasm:

Terminal window
wasm-pack build -t web

编译完成后会在项目根目录下生成一个pkg文件夹,里面包含了编译后的Wasm文件以及js胶水文件、ts声明文件等,可以直接作为一个npm包安装。

这里我们用vite创建了一个前端项目,并将前面编译后的文件复制到新项目中,使用以下代码进行初始化后即可使用add_str_and_xor函数

import init, { add_str_and_xor } from "./simple_signer/simple_signer";
import wasmUrl from "./simple_signer/simple_signer_bg.wasm?url";
init({
module_or_path: wasmUrl,
}).then(() => {
// 加载完成后,就可以调用add_str_and_xor函数了
});

这是完成后的demo效果,在每次输入之后都触发一次add_str_and_xor函数,并将结果更新到下方的Result框中。可以看到,每次输入时会在控制台中输出拼接后的文本,如此我们便实现了js和Wasm的相互调用。

wasm_demo

转-数据段脱敏/加密

如果对上面编译出来的Wasm文件进行反汇编,我们就能看到Wasm的结构(wat格式,这里不详述),一般如果往下翻到最底会看到一些以data开头的数据段,程序中所有的常量都储存在这些数据段中,在Wasm逆向工程中,通常可以从数据段获取不少信息,如下例:

(data (;0;) (i32.const 1048576) "~\5c.cargo\5cregistry\5csrc\5crsproxy.cn-0dccff568467c15b\5cwasm-bindgen-0.2.100\5csrc\5cconvert\5cslices.rs\00\00\10\00\5c\00\00\00$\01\00\00\0e\00\00\00\00\00\00\00\04\00\00\00\04\00\00\00\03\00\00\00~\5c.rustup\5ctoolchains\5cstable-x86_64-pc-windows-msvc\5clib/rustlib/src/rust\5clibrary/core/src/iter/traits/iterator.rs|\00FromUtf8Errorbyteserror~\5c.cargo\5cregistry\5csrc\5crsproxy.cn-0dccff568467c15b\5cbase64-0.22.1\5csrc\5cencode.rs\83\01\10\00M~\5c.cargo\5cregistry\5csrc\5crsproxy.cn-0dccff568467c15b\5cbase64-0.22.1\5csrc\5cengine\5cmod.rs\00\00I\02\10\00Q\00\00\00y\00\00\00\12\00\00\00I\02\10\00Q\00\00\00{\00\00\00\1b\00\00\00Invalid UTF8I\02\10\00Q\00\00\00\7f\00\00\00$\00\00\001a2B3c4D5e6F7g8H\d8\02\10\00\10\00\00\00\01\00\00\00\00\00\00\00\01\00\00\00\00\00\00\00content to xor: \00\03\10\00\10\00\00\00\01\00\01ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\ff\ff\ff\ff>\ff\ff\ff?456789:;<=\ff\1f !\22#$%&'()*+,-./0123\00\00\00Some~\5c.cargo\5cregistry\5csrc\5crsproxy.cn-0dccff568467c15b\5cbase64-0.22.1\5csrc\5cencode.rs\00\00\00\e8\04\10\00M\00\00\00\8a\00\00\00\09\00\00\00~\5c.cargo\5cregistry\5csrc\5crsproxy.cn-0dccff568467c15b\5cbase64-0.22.1\5csrc\5cengine\5cgeneral_purpose\5cmod.rs\d4\09\10\00\14\00\00\00+\02\00\00\11\00\00\00alloc/src/string.rs\00\f8\09\10\00\13\00\00\00\ea\01\00\00\17")
(data (;1;) (i32.const 1051172) "\01\00\00\00!\00\00\00a formatting trait implementation returned an error when the underlying stream did notalloc/src/fmt.rs\00\00\82\0a\10\00\10\00\00\00\88\02\00\00\0e\00\00\00\f8\09\10\00\13\00\00\00\8d\05\00\00\1b\00\00\00)\00\00\00\01\00\00\00\00\00\00\00[index out of bounds: the len is but the index is \00\c1\0a\10\00 \00\00\00\e1\0a\10\00\12\00\00\00: \00\00\01\00\00\00\00\00\00\00\04\0b\10\00\02\00\00\00\00\00\00\00\0c\00\00\00\04\00\00\00$\00\00\00%\00\00\00&\00\00\00 { , {\0a,\0a} }((\0a,\0a]0x00010203040506070809101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899\01\01")
(data (;2;) (i32.const 1051859) "\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\02\03\03\03\03\03\03\03\03\03\03\03\03\03\03\03\03\04\04\04\04\04")
(data (;3;) (i32.const 1051921) "range start index out of range for slice of length \00\00\00\11\0d\10\00\12\00\00\00#\0d\10\00\22\00\00\00range end index X\0d\10\00\10\00\00\00#\0d\10\00\22\00\00\00slice index starts at but ends at \00x\0d\10\00\16\00\00\00\8e\0d\10\00\0d")
(data (;4;) (i32.const 1052100) "\0b")

在这些数据段中我们可以检索到项目源码中的一些字符串字面量,如用于打印拼接后字符串的模板content to xor:,以及我们的SECRET:1a2B3c4D5e6F7g8H。并且还可以检索出项目使用的依赖关键字,如wasm-bindgen、base64等。前者是我们代码功能所需的数据;而后者更多是用于rust panic机制(类似于抛异常),和程序主要功能无关。

由于我们的目的是保护代码逻辑,加大逆向难度,而这些内容会使得攻击者更容易从中分析,所以我们可以分为两个方面来解决这个问题:

  • 对敏感的常量数据进行编译期加密
  • 尝试移除panic机制,或者对这部分常量数据进行脱敏

常量数据编译期加密

对于常量数据,一个思路是先进行加密后再硬编码到代码内,但是这样我们每次需要更改secret的值时都需要手动加密之后再更改代码,显得不那么优雅。我们可以考虑将secret的值另存为单独的文件/config/secret.txt,然后利用rust的过程宏来完成编译期导入,类似以下语法:

// rust标准库提供了include_str宏,下面这一行代码在编译完成后就会被替换成指定文件中的文本
const SECRET: &str = include_str!("../config/secret.txt"); // 等同于:const SECRET: &str = "1a2B3c4D5e6F7g8H";
// 受此启发,我们可以尝试开发一个类似的宏,并额外进行加密操作
const SECRET_ENC: &str = include_str_and_xor!("../config/secret.txt", 0x15); // 表示将对应路径文件作为文本导入,并将每个字节与0x15这个值进行异或

由于rust的限制,过程宏需要放在单独的库中。我们在项目根目录下新建一个crates文件夹,然后在该文件夹下运行以下命令创建新项目

Terminal window
cargo new xor_enc --lib

之后在新项目的Cargo.toml文件中添加以下内容,以声明该项目是一个过程宏项目

[lib]
proc-macro = true

现在我们的项目结构应该是这样的:

simple-signer
├─ Cargo.toml
├─ config/
│ └─ secret.txt
├─ crates/
│ └─ xor_enc/
│ ├─ Cargo.toml
│ └─ src/
│ └─ lib.rs
├─ src/
│ └─ lib.rs

然后我们为xor_enc项目添加一些依赖,以帮助我们更好地编写过程宏:

Terminal window
cargo add quote syn

紧接着便可以编写宏的逻辑,rust的过程宏是个高级特性,需要对rust的编译过程和语法树结构有一定了解,这里我用AI帮我写了一段:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr, Expr, Lit, parse::Parse};
use std::{fs, path::PathBuf, env};
struct MacroInput {
file_path: LitStr,
key: u8,
}
impl Parse for MacroInput {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let file_path = input.parse::<LitStr>()?;
input.parse::<syn::Token![,]>()?;
let key_expr = input.parse::<Expr>()?;
let key = match key_expr {
Expr::Lit(expr_lit) => match expr_lit.lit {
Lit::Int(lit_int) => lit_int.base10_parse::<u8>()?,
_ => return Err(syn::Error::new_spanned(expr_lit, "Key must be an integer")),
},
_ => return Err(syn::Error::new_spanned(key_expr, "Key must be a literal")),
};
Ok(MacroInput { file_path, key })
}
}
#[proc_macro]
pub fn include_str_and_xor(input: TokenStream) -> TokenStream {
let MacroInput { file_path, key } = parse_macro_input!(input as MacroInput);
// 构建完整文件路径
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
let mut path = PathBuf::from(manifest_dir);
path.push(file_path.value());
// 读取文件内容
let content = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read file: {:?}", path));
// 执行异或加密
let encrypted: Vec<u8> = content.bytes()
.map(|b| b ^ key)
.collect();
// 转换为字符串(如果无效 UTF-8 会 panic)
let encrypted_str = String::from_utf8(encrypted)
.expect("XOR result contains invalid UTF-8");
// 生成字符串字面量
let expanded = quote! {
#encrypted_str
};
expanded.into()
}

接着回到主项目,修改以下文件,以使用我们创建的过程宏

Cargo.toml
# ...省略其他内容
[dependencies]
wasm-bindgen = "0.2.84"
base64 = "0.22.1"
xor_enc = { path = "./crates/xor_enc" } # 导入本地crate包
lib.rs
mod utils;
use wasm_bindgen::prelude::*;
use base64::prelude::*;
use xor_enc::include_str_and_xor;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
const SECRET_ENC: &str = include_str_and_xor!("config/secret.txt", 0x15); // 编译期用0x15进行异或加密
const XOR: u8 = 0x1f;
#[wasm_bindgen]
pub fn add_str_and_xor(content: &str) -> String {
let secret: Vec<u8> = SECRET_ENC.as_bytes().iter()
.map(|b| *b ^ 0x15).collect(); // 运行时用0x15再异或一次进行解密
let final_content = format!("{}{}", content, String::from_utf8(secret).unwrap());
log(&format!("content to xor: {}", final_content));
let xor_content = final_content.as_bytes()
.iter()
.map(|c| c ^ XOR)
.collect::<Vec<u8>>();
BASE64_STANDARD.encode(&xor_content)
}

至此,我们完成了常量数据的加密,再次编译成Wasm,再去搜索secret明文1a2B3c4D5e6F7g8H,便搜索不到了。实际应用中,可以用AES等加密算法来替换异或操作,使其更加安全。

移除依赖名等敏感字符串

首先我尝试了不少方式,均无法移除数据段中的依赖路径信息,那么我们只能从别的层面下手,比如在编译完成后将Wasm中的路径信息进行脱敏(不能直接移除,因为Wasm使用常量是通过下标索引来获取数据段的特定数据来实现)。通过观察可以发现带有依赖名称的字符串格式均为Cargo路径\.cargo\registry\镜像路径\依赖名称-版本\源码路径\源码.rs,如此甚好,这种特征足以使用正则表达式来匹配,于是我编写了一个python脚本,将匹配到的内容都替换为x

import re
import sys
def wasm_desensitize(input_file):
"""
替换 .wasm 文件中所有类似 `.cargo\...\xxxxx.rs` 的字符串为 `x` 字符序列
以保护使用的依赖和版本信息
"""
# 读取二进制内容
with open(input_file, 'rb') as f:
data = bytearray(f.read())
# 备份原文件
with open(input_file + '.old', 'wb') as f:
f.write(data)
print(f"已备份原文件至: {input_file}.old")
# 定义匹配规则(UTF-8 编码的 .rs 路径)
pattern = re.compile(
rb'(\.cargo[\w\-\.\\/]+\.rs)', # 匹配路径格式
flags=re.IGNORECASE
)
# 查找所有匹配项
matches = list(pattern.finditer(data))
if not matches:
print("未找到 .rs 路径")
return
# 替换匹配项为 x 字符(保持长度不变)
print(f"找到 {len(matches)} 处 .rs 路径,开始替换...")
for match in matches:
start, end = match.span()
original = data[start:end]
replaced = original.copy()
# 检查每一个字符,将字母和数字替换为 x
for i in range(len(replaced) - 2):
if replaced[i:i+1].isalnum():
replaced[i:i+1] = b'x'
data[start:end] = replaced
# 打印替换信息
print(f"\n替换位置 [{start}-{end}]:")
print(f"{original.decode(errors='replace')} =>")
print(replaced.decode(errors='replace'))
# 写入原文件
with open(input_file, 'wb') as f:
f.write(data)
print(f"处理完成,已覆盖原文件: {input_file}")
if __name__ == '__main__':
if len(sys.argv) != 2:
print("用法: python replace_wasm_strings.py 输入文件.wasm")
print("输入文件会被备份为.old后缀,而源文件会被替换为修改后的文件")
sys.exit(1)
wasm_desensitize(sys.argv[1])

执行时可以看到替换了哪些字符:

替换位置 [25120-25210]:
.cargo\registry\src\rsproxy.cn-0dccff568467c15b\wasm-bindgen-0.2.100\src\convert\slices.rs =>
.xxxxx\xxxxxxxx\xxx\xxxxxxx.xx-xxxxxxxxxxxxxxxx\xxxx-xxxxxxx-x.x.xxx\xxx\xxxxxxx\xxxxxx.rs
替换位置 [25507-25582]:
.cargo\registry\src\rsproxy.cn-0dccff568467c15b\base64-0.22.1\src\encode.rs =>
.xxxxx\xxxxxxxx\xxx\xxxxxxx.xx-xxxxxxxxxxxxxxxx\xxxxxx-x.xx.x\xxx\xxxxxx.rs
替换位置 [25705-25784]:
.cargo\registry\src\rsproxy.cn-0dccff568467c15b\base64-0.22.1\src\engine\mod.rs =>
.xxxxx\xxxxxxxx\xxx\xxxxxxx.xx-xxxxxxxxxxxxxxxx\xxxxxx-x.xx.x\xxx\xxxxxx\xxx.rs
替换位置 [26376-26451]:
.cargo\registry\src\rsproxy.cn-0dccff568467c15b\base64-0.22.1\src\encode.rs =>
.xxxxx\xxxxxxxx\xxx\xxxxxxx.xx-xxxxxxxxxxxxxxxx\xxxxxx-x.xx.x\xxx\xxxxxx.rs

至此,我们完成了依赖名称的脱敏。使用以上两种手段之后,大大提高了Wasm的逆向难度。

合-一些废话

这次又是一次全新的尝试,Rust和Wasm都是我之前未曾涉及的领域,尤其是Rust的强大和安全性,一上手就会给人留下很深的印象(虽然大部分时间都在和编译器干架)。相信随着生态的发展Rust会变得越来越流行(不愧是编程界的原神☝️😋)。