最近在定位线上问题时,我在打包产物里发现了一段连续字符。全局搜索后确认它是用于数据加解密的密钥。前端代码天然透明,把密钥硬编码进代码里显然不合适。常见的补救手段是对加解密逻辑做混淆,以提高逆向成本。
const AES_KEY = "xs10qllw4FTcQ8YUrM56triR4Ds4NrmB";const AES_IV = "okukJvVE8fHIX1MymHg7gOo8w15Tj0He";const get = () => {return [AES_KEY, AES_IV];};const params = get();
将这段示例代码混淆后,可读性会大幅下降,代码里会充斥着诸如 0x13db5c 之类的常量与变量。但在浏览器里仍可能通过调试拿到 AES_KEY 与 AES_IV 的真实值。根因在于密钥写死在前端:在缺少服务端参与的前提下,前端能做到的通常只是“增加难度”,而不是“彻底隐藏”。

在运行时,密钥必然会以某种形式出现在执行路径上(哪怕是被拆分、拼接、解码后再使用)。如果能把“密钥如何被构造出来”的过程放进一个黑盒里,至少可以显著降低被直接定位/提取的概率。但如前所述,前端代码透明,严格意义上的黑盒并不存在。
一个更现实的方向是:把关键逻辑迁移到 WebAssembly 中,获得一种“近似黑盒”的效果。把加解密实现用 Go/Rust 等语言重写并编译为 wasm 文件,再由前端调用,这对分析打包后的 JS、以及通过调试直接定位密钥,都有一定的对抗收益。
创建 WebAssembly 项目的步骤这里不展开;我选择 Rust 作为重构语言。
pub const KEY: [u8; 16] = [0x78, 0x73, 0x31, 0x30, 0x71, 0x6c, 0x6c, 0x77, 0x34, 0x46, 0x54, 0x63, 0x51, 0x38, 0x59, 0x55,];pub const IV: [u8; 16] = [0x6f, 0x6b, 0x75, 0x6b, 0x4a, 0x76, 0x56, 0x45, 0x38, 0x66, 0x48, 0x49, 0x58, 0x31, 0x4d, 0x79,];
#[wasm_bindgen]pub fn encrypt(plain_text: &str) -> String {let ct = aes128::encrypt_cbc(&crypto_params::KEY,&crypto_params::IV,plain_text.as_bytes(),);key_iv_codec::hex_encode(&ct)}pub fn decrypt(cipher_text_hex: &str) -> Result<String, JsValue> {let ct =key_iv_codec::hex_decode(cipher_text_hex).map_err(|e| JsValue::from_str(&e.to_string()))?;let pt = aes128::decrypt_cbc(&crypto_params::KEY, &crypto_params::IV, &ct).map_err(|e| JsValue::from_str(&e.to_string()))?;String::from_utf8(pt).map_err(|_| JsValue::from_str("invalid utf-8 plaintext"))}
重构思路大致如下:
- 将原先的
AES_KEY/AES_IV转换为 16 字节数组,避免以明文字符串形式直接出现在 JS(这个步骤在 JS 里也能做,但放在 Rust 更利于统一封装) - 封装 AES-128 CBC 的加解密与
hex_decode/hex_encode等必要的编解码工具 - 对外仅暴露
encrypt/decrypt两个函数(用字符串接口,尽量降低 JS 与 wasm 的交互复杂度)
引入 wasm 后,简单验证如下:
| 项目 | 耗时(ms) | 吞吐(MiB/s) |
|---|---|---|
| WASM encrypt | - | - |
| WASM decrypt | - | - |
| WebCrypto AES-CBC encrypt -> hex | - | - |
| WebCrypto AES-CBC decrypt (from hex) -> string | - | - |
将编译后的 wasm 转换为 wat 格式后,可以尝试直接在文本中检索密钥。你会发现转换后的代码呈现出一种类似 LISP 的结构化风格:符号密集、片段离散,想从中快速定位“散落的密钥”会难很多。
(module(type $t0 (func (param i32 i32)))(type $t1 (func (param i32 i32) (result i32)))(type $t2 (func (param i32 i32 i32) (result i32)))(type $t3 (func (param i32)))(type $t4 (func (param i32 i32 i32)))(type $t5 (func (param i32 i32 i32 i32) (result i32)))(type $t6 (func (result i32 i32 i32 i32)))(type $t7 (func (result i32 i32)))(type $t8 (func))(type $t9 (func (param i32 i32) (result externref)))(type $t10 (func (param i32) (result i32)))(type $t11 (func (param i32 i32 i32 i32 i32)))(type $t12 (func (param i32 i32 i32 i32 i32 i32)))(type $t13 (func (param i32 i32) (result i32 i32 i32 i32)))(type $t14 (func (param i32 i32) (result i32 i32)))(import "./crypto_aes_wasm_bg.js" "__wbindgen_init_externref_table" (func $./crypto_aes_wasm_bg.js.__wbindgen_init_externref_table (type $t8)))(import "./crypto_aes_wasm_bg.js" "__wbindgen_cast_0000000000000001" (func $./crypto_aes_wasm_bg.js.__wbindgen_cast_0000000000000001 (type $t9)))(func $f2 (type $t10) (param $p0 i32) (result i32)(local $l1 i32) (local $l2 i32) (local $l3 i32) (local $l4 i32) (local $l5 i32) (local $l6 i32) (local $l7 i32) (local $l8 i32) (local $l9 i32) (local $l10 i64)(global.set $g0(local.tee $l8(i32.sub(global.get $g0)(i32.const 16))))(block $B0// ...more
需要强调的是:把密钥“藏进 wasm”并不意味着密钥真正安全。只要攻击者能在前端调用 encrypt / decrypt,就仍然可以把它当作一个加解密 Oracle 来使用,最终的效果更多是“提高提取密钥的门槛”,而非“阻止功能被滥用”。要从根本上解决密钥暴露问题,仍需要服务端参与(例如密钥不下发、按会话派生、请求签名等)。