JS 逆向中的 WebAssembly(WASM)特征识别与逆向

逆向遇到 WebAssembly(WASM)时,第一选择是把 .wasm 当成一个编译好的 .so/.dll,在 Node 里加载并黑盒调用它的导出函数,而不是逐行把字节码翻译回 JS。WASM 是一种独立的二进制字节码格式(Binary Bytecode Format),由 C/C++/Rust 等语言编译而来,浏览器原生执行,加载快、体积小,也因此成了把签名、加密、风控逻辑藏起来的常见手段——抖音 a_bogus/bdms 这类把算法塞进 WASM 甚至字节码虚拟机(Bytecode VM)的方案,目的就是对抗"扣代码"。 什么时候该黑盒调用而非硬扣

分享

官方文档:

适用版本:WebAssembly Core Spec 3.0(含异常处理 Tag、bulk-memory)、Emscripten 6.x、WABT/Binaryen/wasm-tools 当前发行版。
核实日期:2026-06-07。

概述

逆向遇到 WebAssembly(WASM)时,第一选择是把 .wasm 当成一个编译好的 .so/.dll,在 Node 里加载并黑盒调用它的导出函数,而不是逐行把字节码翻译回 JS。WASM 是一种独立的二进制字节码格式(Binary Bytecode Format),由 C/C++/Rust 等语言编译而来,浏览器原生执行,加载快、体积小,也因此成了把签名、加密、风控逻辑藏起来的常见手段——抖音 a_bogus/bdms 这类把算法塞进 WASM 甚至字节码虚拟机(Bytecode VM)的方案,目的就是对抗"扣代码"。

什么时候该黑盒调用而非硬扣算法:当 WASM 内部经过编译器优化、自定义 VM 或混淆,逐行还原成本极高且极易出错时。只要能在受控环境里复现"相同输入产生相同输出",就完全满足签名/加密需求,无需理解算法内部。反汇编一个 VM 型 WASM 只会看到解释器循环,看不到算法本身,这正是它的反逆向设计——把整个 VM 连同字节码一起当黑盒调用,才是正解。

什么时候才需要真正读懂逻辑:当目标是判别用了哪种已知算法(MD5/AES/SM3 等)、需要在纯算法层面复现、或要修改逻辑时。此时通过魔法常量(Magic Constant)和导出函数名快速判定算法族,再决定是否值得手翻。本文按"识别 → 工具 → 调试 → 黑盒复现 → 算法还原"的顺序展开,目标读者是有 JS 逆向基础但没系统接触过 WASM 的工程师。

WASM 二进制结构

模块头(Module Preamble)

每个 .wasm 文件以 8 字节固定头开始,这是最强也最简单的识别特征。

偏移 字节 含义
0x00 0x00 0x61 0x73 0x6D 魔数(Magic Number),即 ASCII \0asm\0asm
0x04 0x01 0x00 0x00 0x00 版本(Version),小端 u32 = 1(自 MVP 至今仍为 1)

判别要点:任何文件前 8 字节是 00 61 73 6D 01 00 00 00,100% 是 WASM 模块。0x61 0x73 0x6D 三个字节正好是 asm 的 ASCII。十六进制速记:\0asm = 00 61 73 6d,后跟版本 01 00 00 00

Section 编码格式

紧跟模块头之后是一系列段(Section),每个段的编码格式如下:

section ::= section_id : byte    # 1 字节段 ID
            size       : u32     # LEB128 变长,段内容字节数
            content    : ...     # size 字节

size 用 LEB128(无符号变长整数编码,Little Endian Base 128)表示,这是 WASM 二进制里随处可见的整数编码方式——读 wat 或 hex 时遇到的多字节整数大多是 LEB128。

Section 速查表

WebAssembly 3.0 规范定义的段 ID 如下。除自定义段(ID 0)外,每个标准段至多出现一次,且必须按规范规定的固定顺序排列——该顺序并非简单的 ID 升序(Tag 段紧跟 Memory 段、DataCount 段位于 Code 段之前,见表后规范顺序);自定义段可插在任意两个标准段之间,且可多次出现。

ID 段名称 作用 逆向关注点
0 Custom(自定义) 调试信息/第三方扩展,被 WASM 语义忽略;可出现在任意位置、可多次 name 子段含函数名/局部变量名(调试符号);sourceMappingURL 子段指向 source map;逆向第一目标
1 Type(类型) 函数签名(递归类型)声明列表 看导出函数的参数/返回值类型
2 Import(导入) 从宿主(JS)导入 function/table/memory/global 看模块依赖 JS 提供哪些环境函数,是补垫片的线索
3 Function(函数) 函数到类型索引的映射(仅声明,函数体在 Code 段) 与 Code 段配合阅读
4 Table(表) 表定义,通常存函数引用,用于间接调用 间接调用 / dynCall 相关
5 Memory(内存) 线性内存(Linear Memory)定义,初始/最大页数,1 页 = 64 KiB 内存大小,构造 importObject 时参考
6 Global(全局) 全局变量定义 常量、状态
7 Export(导出) 导出 function/table/memory/global 给 JS 逆向核心:导出名如 encrypt/sign/_md5 直接暴露逻辑
8 Start(启动) 可选的启动函数,实例化后自动执行 初始化逻辑
9 Element(元素) 表初始化数据段 填充函数表
10 Code(代码) 函数体(局部变量 + 指令字节码) 算法逻辑所在,反编译主战场
11 Data(数据) 初始化线性内存的字节 藏 S-Box / 魔法常量 / 字符串的地方
12 DataCount(数据计数) 可选 u32,data 段数量;启用 bulk-memory 时出现,必须在 Code 段之前(单遍验证需要) bulk memory 特征
13 Tag(标签) 异常处理 tag 定义(exception handling 提案,已并入 3.0) 较新模块才有

规范要求的固定段顺序(注意不是 ID 升序):Type(1) → Import(2) → Function(3) → Table(4) → Memory(5) → Tag(13) → Global(6) → Export(7) → Start(8) → Element(9) → DataCount(12) → Code(10) → Data(11)。Tag 段插在 Memory 与 Global 之间,DataCount 段排在 Code 段之前(单遍验证需要先知道 data 段数量)。

说明:很多教学材料只列 0–11 共 12 类段(custom 到 datacount)。规范当前实为 0–13 共 14 个段 ID,其中 Tag(13) 是异常处理扩展段,较新工具链产物才出现。Function 段(声明)与 Code 段(函数体)拆分是为了支持并行编译。

在网页中识别用到了 WASM

判断一个页面是否用了 WASM,从网络层、JS API、内联编码三个维度入手,任一命中即可确认。

网络层特征

特征 表现 说明
资源请求 DevTools Network 出现 .wasm 后缀请求(如 app.wasm),有时无后缀 用 Type 列或文件名过滤
MIME 类型 响应头 Content-Type: application/wasm instantiateStreaming/compileStreaming 强制要求服务器返回该 MIME,否则流式编译报错,是强信号
响应体头部 二进制响应以 00 61 73 6D 01 00 00 00 开头 直接抓包看前 8 字节

JS API 调用特征

页面 JS 中出现以下符号即说明用到了 WASM,全文搜索这些关键字定位入口。

// app.js —— WebAssembly 命名空间下的各类符号
// 编译类
WebAssembly.compile(bytes);
WebAssembly.compileStreaming(fetch("x.wasm"));
WebAssembly.validate(bytes);             // 返回 true 即合法 WASM

// 实例化类(最常见)
WebAssembly.instantiate(bytesOrModule, importObject);
WebAssembly.instantiateStreaming(fetch("x.wasm"), importObject);

// 构造器 / 命名空间
new WebAssembly.Module(bytes);
new WebAssembly.Instance(module, importObject);
new WebAssembly.Memory({ initial: 256, maximum: 32768 });
new WebAssembly.Table({ initial: 8, element: "anyfunc" });
new WebAssembly.Global({ value: "i32", mutable: true }, 0);

// 错误类型(出现在 catch / 报错栈里)
WebAssembly.CompileError;   // 解码 / 校验阶段
WebAssembly.LinkError;      // 实例化链接阶段
WebAssembly.RuntimeError;   // 运行期 trap

WebAssembly 是命名空间对象(类似 Math/Intl),不是构造器:

// 正确:调用命名空间上的方法
const { instance } = await WebAssembly.instantiate(bytes, importObject);

// 错误:WebAssembly 不是构造器,不能 new
const w = new WebAssembly();   // 错误:WebAssembly 是命名空间对象,不可实例化

最典型的加载链(流式)与兼容写法(手动 fetch → arrayBuffer → instantiate,老代码常见):

// 流式:要求服务器返回 application/wasm
WebAssembly.instantiateStreaming(fetch("simple.wasm"), importObject)
  .then(obj => obj.instance.exports.exported_func());

// 兼容写法:先取 ArrayBuffer 再实例化(适配旧浏览器 / MIME 不对的服务器)
fetch("module.wasm")
  .then(r => r.arrayBuffer())
  .then(bytes => WebAssembly.instantiate(bytes, importObject))
  .then(results => {
    const exports = results.instance.exports;   // 访问导出
  });

Base64 内联 WASM 的识别

为减少请求或反爬,WASM 常被 base64 内联进 JS。关键判别常量:base64 字符串以 AGFzbQ 开头,正是 00 61 73 6D\0asm)的 base64 编码;完整头 00 61 73 6D 01 00 00 00 的 base64 是 AGFzbQEAAAA。在 JS 里全文搜 AGFzbQ 能直接定位内联 WASM。

// glue.js —— 内联 base64 WASM 的典型解码流程
// 特征:一段超长 base64 字符串,以 AGFzbQ 开头
var wasmBinary = "AGFzbQEAAAA...";        // "AGFzbQ" = "\0asm" 的 base64
var binary = atob(wasmBinary);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
WebAssembly.instantiate(bytes, importObject);

也可能是 data:application/wasm;base64,AGFzbQ... 形式的 data URI,或十六进制数组 [0,97,115,109,1,0,0,0,...](开头是十进制的 0 97 115 109)。

其他线索

线索 含义
Module 全局对象 Emscripten 默认配置对象
.js 胶水文件 + 同名 .wasm 成对出现 a.out.js + a.out.wasmxxx.js + xxx.wasm
Sources 面板出现 wasm:// 协议虚拟文件 DevTools 把二进制反汇编为 wat 直接展示

Emscripten 产物特征识别

Emscripten 是 C/C++ 编译到 WASM 的主流工具链,生成胶水 JS(Glue Code) + .wasm 两部分。胶水 JS 里有一批高度固定、硬编码的运行时符号,看到它们基本可断定"这是 Emscripten 编译的 WASM"。这是实战中最常遇到的形态,也是本文重点。

Emscripten WASM 胶水代码中的 HEAP 视图与 ccall 调用

图解读:调试器里出现 HEAPU8/HEAP32 等视图、以及 t.ccall("cmd_clientDecrypt", "number", [...]) 这种调用,就是 Emscripten 编译 WASM 的铁证,cmd_clientDecrypt 是 WASM 导出的解密函数。

HEAP 类型化数组视图(最强铁证)

Emscripten 把 WASM 线性内存(一块 ArrayBuffer)用多种类型化数组(TypedArray)视图暴露,命名极其固定。这 8 个名字是硬编码的,手写或别的工具链不会恰好用这套命名,全文搜 HEAPU8 命中即 Emscripten。

视图名 类型 元素宽度 说明
HEAP8 Int8Array 1 字节 8 位有符号
HEAPU8 Uint8Array 1 字节 8 位无符号,操作字节最常用
HEAP16 Int16Array 2 字节 16 位有符号
HEAPU16 Uint16Array 2 字节 16 位无符号
HEAP32 Int32Array 4 字节 32 位有符号
HEAPU32 Uint32Array 4 字节 32 位无符号
HEAPF32 Float32Array 4 字节 32 位浮点
HEAPF64 Float64Array 8 字节 64 位浮点

逆向用途:读写 WASM 内存就靠它们。读地址 ptr 处一个字节 = HEAPU8[ptr];读 32 位整数 = HEAP32[ptr >> 2]>> 2 是因为 Int32Array 索引以 4 字节为单位)。

内存与栈管理符号

符号 签名 说明
_malloc _malloc(size) -> ptr 在 WASM 堆分配内存,返回字节偏移(指针)
_free _free(ptr) 释放
stackAlloc stackAlloc(size) -> ptr 在 WASM 栈上分配临时缓冲
stackSave stackSave() -> sp 保存栈指针
stackRestore stackRestore(sp) 回退栈指针,一次性释放 stackAlloc 的分配

下划线前缀 _ 代表对应的 C 函数(C 符号 malloc 编译为 JS 端 _malloc)。新版 Emscripten 默认不再导出 _malloc/_free,需 -sEXPORTED_FUNCTIONS=['_malloc','_free'];但 stackAlloc/stackSave/stackRestore 几乎总在胶水里。

字符串转换辅助函数(铁证)

JS 字符串与 WASM 内存(UTF-8)互转的工具,命名固定。

函数 签名 说明
UTF8ToString UTF8ToString(ptr, maxBytesToRead?, ignoreNul?) 从堆里读 NUL 结尾的 UTF8 串转 JS String
UTF8ArrayToString UTF8ArrayToString(heap, ptr) 从指定 heap 数组读 UTF8 串
stringToUTF8 stringToUTF8(str, outPtr, maxBytesToWrite) JS String 写入堆(含 NUL);返回实际写入字节数(不含 NUL),可据此推进指针,常与 lengthBytesUTF8 配合
stringToNewUTF8 stringToNewUTF8(str) -> ptr 分配并写入,返回指针需 _free
lengthBytesUTF8 lengthBytesUTF8(str) -> number 计算 UTF8 字节数(不含 NUL,分配前用)

逆向用途:当 C 函数参数/返回是字符串,必经这些函数。Hook UTF8ToString 能拿到 WASM 返回的明文字符串;Hook stringToUTF8 能截获传入 WASM 的明文(如密码、待加密原文)。

调用桥接 ccall / cwrap

// ccall: 调一次 C 函数
// 参数依次为:函数名、返回类型、参数类型数组、实参数组
Module.ccall("func_name", "number", ["string", "number"], ["hello", 42]);

// cwrap: 返回一个可复用的 JS 包装函数
var func = Module.cwrap("func_name", "number", ["string", "number"]);
func("hello", 42);

ccall/cwrap 需通过 -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap'] 导出后才能在 Module. 上访问。逆向时它们是"JS 调 WASM"的入口,Hook Module.ccall 的第一个参数能枚举被调用的所有 C 函数名。

WASM 导出对象 / asm 别名(关键演变)

历史上 Emscripten 把 WASM 实例的 exports 命名为 asm(沿用 asm.js 时代命名),自 Emscripten 3.1.43(2023 年,issue #19792)起改名为 wasmExports

// 老版本:exports 别名为 asm
var asm = output.instance.exports;
asm["_main"]();
asm["functionName"];

// 新版本(Emscripten 3.1.43+):改名 wasmExports
var wasmExports = wasmInstance.exports;
wasmExports["functionName"];

判别:胶水里出现 var asm =wasmExports =,且后续大量 asm["..."] / wasmExports["..."] 形式的下标访问。这是真正的 WASM 函数表,所有导出函数都挂在这里。

动态调用与构造器

符号 说明
dynCall_<sig> 通过函数表做间接调用,<sig> 是签名串:vi=void(i32)、iii=i32(i32,i32) 等
dynCall('vi', funcPtr, [arg]) 通用动态调用入口
__wasm_call_ctors 调用全局构造器(C++ 全局对象初始化),常用 createExportWrapper("__wasm_call_ctors") 包装

dynCall_xxx__wasm_call_ctors 都是 Emscripten 专有命名。

Emscripten 铁证清单

任意命中 ≥2 项基本可确认是 Emscripten 产物:

[ ] HEAP8 / HEAPU8 / HEAP16 / HEAPU16 / HEAP32 / HEAPU32 / HEAPF32 / HEAPF64
[ ] var asm = ... 或 wasmExports = ...   且大量 asm["xxx"] / wasmExports["xxx"]
[ ] _malloc / _free / stackAlloc / stackSave / stackRestore
[ ] ccall / cwrap
[ ] UTF8ToString / UTF8ArrayToString / stringToUTF8 / lengthBytesUTF8
[ ] dynCall_ 开头的函数 / __wasm_call_ctors
[ ] Module 全局对象 + EXPORTED_RUNTIME_METHODS / EXPORTED_FUNCTIONS 痕迹
[ ] 成对的 xxx.js(胶水) + xxx.wasm
[ ] 字符串 "memory grew"、"wasm" 或 emscripten 报错文案

这些符号是 Emscripten 运行时模板写死的,不属于 WASM 标准 API(标准只有 WebAssembly.*)。它们的存在说明 .wasm 不是手写/Rust wasm-bindgen/AssemblyScript 产物,而是 Emscripten(C/C++)产物。

区分其他工具链

工具链 旁证符号
Emscripten(C/C++) HEAP* 视图、ccall/cwrap_malloc/UTF8ToString
Rust + wasm-bindgen 胶水里有 wasm_bindgen__wbindgen_ 前缀、__wbg_ 前缀函数
AssemblyScript 导出常含 __new/__pin/__collect/__rtti_base,无 HEAP* 命名
Go(TinyGo/标准 Go) go.importObjectruntime.wasmExitsyscall/js

asm.js 与 WASM 的区分

asm.js 是 WASM 的前身(一种 JS 严格子集),WASM 标准化后取代了它。Emscripten 早年用 asm 命名 WASM exports 就是这段历史的遗留。实战区分:

维度 asm.js WebAssembly
本质 JavaScript 严格子集(文本 JS 源码) 独立的二进制字节码格式
标志 函数体顶部 "use asm"; 指令 文件头 \0asm 魔数
体积 冗长,按 JS 解析慢 紧凑,解析快
现状 已被取代,仅作旧浏览器 fallback 主流

看到 .wasm 文件 / \0asm 头 / WebAssembly.* API 就是 WASM;看到 JS 函数体里有 "use asm"; 加大量 x = x | 0(强制整型)、+x(强制浮点)就是 asm.js。注意 asm.js 也用 HEAP32 命名(与 Emscripten 同源),区别在于 asm.js 是纯文本 JS、有 "use asm"、无 .wasm 文件。

WASM 逆向工具链

核心目标:把不可读的 .wasm 二进制转为可读 .wat(文本格式)→ 类 C 伪代码 → 在动态调试器里跑起来。

WABT(WebAssembly Binary Toolkit)

官方仓库 WebAssembly/wabt。定位是 1:1 高保真往返,不做优化、不改指令,最贴合规范,是看 wat、看段、反汇编、剥符号的首选。

# 安装
brew install wabt            # macOS / Linux (Homebrew)
sudo apt install wabt        # Debian / Ubuntu
# Windows / 通用:GitHub Releases 下载预编译二进制
工具 用途 典型命令
wasm2wat 二进制转文本(.wat),逆向第一步 wasm2wat app.wasm -o app.wat
wat2wasm 文本转二进制(改完 wat 后回编验证) wat2wasm app.wat -o app.wasm
wasm-decompile 反编译为类 C 伪代码,可读性远高于 wat wasm-decompile app.wasm -o app.dcmp
wasm-objdump 看段/反汇编/十六进制,类 UNIX objdump wasm-objdump -x app.wasm
wasm-strip 删除自定义段(如 name 段)减小体积 wasm-strip app.wasm
wasm-validate 校验二进制是否合法 wasm-validate app.wasm
wasm-interp 栈式解释器,直接跑 wasm wasm-interp app.wasm --run-all-exports
wasm2c wasm 转 C 源码 + 头文件 wasm2c app.wasm -o app.c

wasm-objdump 选项表(逆向最常用):

选项 长选项 含义
-x --details 显示各段详情(imports/exports/functions/globals/memory 等结构)
-d --disassemble 反汇编函数体
-h --headers 打印段头(每段的偏移和大小)
-s --full-contents 打印原始段内容(十六进制),找 Data 段常量用
-j --section=SECTION 只看某一段
-r --reloc 反汇编时内联显示重定位
--section-offsets 用段偏移而非文件偏移

常用组合:wasm-objdump -x app.wasm 先看全貌结构,再 -d 反汇编关键函数。wasm-decompile 的伪代码(V8 团队出品)比 wat 更接近源码语义,是无源码场景下最快的读懂逻辑手段。

Binaryen

官方仓库 WebAssembly/binaryen。优化器,逆向价值在 wasm2js(转回 JS 调试)和 wasm-dis(反汇编)。

npm install binaryen         # Node 版命令行工具
brew install binaryen        # macOS
工具 用途 典型命令
wasm-opt wasm 转 wasm 优化器,跑各种 pass wasm-opt input.wasm -O3 -o out.wasm
wasm2js wasm 转 JS,便于在 Node 里跑/调试/打断点 wasm2js app.wasm -o app.mjs
wasm-dis 二进制转文本(经 Binaryen IR,命名更可读) wasm-dis input.wasm -o output.wat
wasm-as 文本转二进制(汇编) wasm-as in.wat -o out.wasm
wasm-merge 合并多个 wasm,自动连接 import 与 export wasm-merge a.wasm a b.wasm b -o m.wasm

注意:wasm-dis 与 WABT 的 wasm2wat 区别——前者经过 IR 不保证 1:1 往返,但命名更可读;要高保真往返用 WABT。

bytecodealliance/wasm-tools(Rust)

官方仓库 bytecodealliance/wasm-tools。Rust 写的低层 wasm 操控 CLI + 库,对 Component Model / WIT 支持最好,独有符号反修饰能力。

cargo install --locked wasm-tools     # 源码编译
cargo binstall wasm-tools             # 预编译产物
子命令 用途
print 二进制转文本(约等于 wasm2wat)
parse 文本转二进制(约等于 wat2wasm)
dump 打印二进制格式的详细调试信息(按字节注解)
objdump 打印段头调试信息
demangle 反修饰 name 段里的 Rust/C++ 符号名(WABT 无此能力)
strip 删除自定义段
validate 校验 wasm
addr2line 用 DWARF 把 wasm 偏移翻译成 文件:行号
component 操作组件(new/wit/embed/unbundle)
metadata 查看/添加 name 与 producer 元数据

官方示例管道:

# 先反修饰符号名,再剥自定义段,最后看段头
wasm-tools demangle foo.wasm | wasm-tools strip | wasm-tools objdump

运行时(动态分析)

运行时 安装 黑盒调用命令
wasmtime cargo install wasmtime-cli / curl https://wasmtime.dev/install.sh -sSf | bash wasmtime run --invoke main app.wasm arg1 arg2
wasmer cargo install wasmer-cli / curl https://get.wasmer.io -sSfL | sh wasmer run ./app.wasm

两者默认安全沙箱(secure by default),不给文件/网络权限,需显式授权(wasmtime 用 --dir,wasmer 用 --dir/--net/--enable-all)。wasmtime explore foo.wasm 生成 HTML 并排展示 wat 与机器码,看编译映射很有用。

反编译器 / 专业分析器

工具 类型 说明
JEB Decompiler(PNF Software) 商业 业界最佳 WASM 反编译,输出类 C 代码,JEB 5.36 已支持 WebAssembly 3.0;有在线 demo
Ghidra WASM 插件 开源 nneonneo/ghidra-wasm-plugin,反汇编 + 反编译 + 交叉引用,可脚本化
wasm-decompile 开源 WABT 自带,伪代码反编译,无需大型框架,最轻量

选型表

需求 推荐工具
看二进制对应的 wat wasm2wat(高保真)/ wasm-dis(可读)/ wasm-tools print / Chrome DevTools
看类 C 伪代码(快速理解逻辑) wasm-decompile(轻量)
最强反编译(无源码逆向) JEB(商业)/ Ghidra WASM 插件(免费)
看段结构 / imports / exports wasm-objdump -x / wasm-tools objdump
反汇编函数体 wasm-objdump -d
看原始字节(hex) wasm-objdump -s / wasm-tools dump
反修饰 Rust/C++ 符号名 wasm-tools demangle
把 wasm 转成 JS 来调试 wasm2js
直接运行 / 黑盒探测导出函数 wasmtime run --invoke / wasmer run / wasm-interp
源码级断点调试(带 DWARF) Chrome DevTools(-g 编译)
偏移转源码行号 wasm-tools addr2line

实战推荐顺序:wasm-decompile(懂逻辑)→ wasm2wat / wasm-objdump -x -d(看细节)→ 需要时上 JEB/Ghidra 做深度反编译 → 动态阶段用 Chrome DevTools(带 -g)或 wasmtime --invoke 验证。

动态调试

Chrome DevTools 调试 wat 与查看线性内存

页面加载 .wasm 时,即便没有调试信息,Sources 面板也会把二进制即时反汇编为 WAT 文本展示,整个模块合并显示在单个文件里,使用 wasm:// 前缀标识。

操作 方法
下断点 在反汇编视图里点代码左侧的地址列(offset 列,形如 0x0042),刷新页面触发
单步 命中后用 Step over / into / out 逐条 wasm 指令执行
看调用栈 右侧 Debugger 面板的 Call Stack;无 DWARF 时帧名为 $funcN 或 name 段名字
看作用域 Scope 区展示 Local(局部变量)、Global(全局)、Stack(操作数栈)、Module(含 memory)

查看线性内存用 Memory Inspector(菜单 More Tools 打开,或在 Scope 里右键 WebAssembly.Memory/ArrayBuffer 选 Reveal in Memory Inspector)。它把内存按 Int8/Uint8、Int16/Uint16、Int32/Uint32、Pointer 32/64-bit、Float 32/64-bit 多类型解释,工具栏可切大小端(WASM 线性内存是小端),地址输入框输入 hex 地址(如 0x00000008)跳转。

DWARF 与 source map

若编译时带 -g,Emscripten 会在 wasm 内嵌入 DWARF 调试信息。

# 内嵌 DWARF,可在 DevTools 调原始 C/C++ 源码
emcc -g source.cc -o app.html

# 分离调试信息(减小线上体积)
emcc -g source.cc -o app.html -gseparate-dwarf=debug.wasm -s SEPARATE_DWARF_URL=path/URL

# 生成轻量 source map(把 wat 行映回源码行)
emcc -gsource-map source.cc -o app.html

安装 Chrome 扩展 C/C++ DevTools Support (DWARF)(扩展 ID pdcpmagijalfljmkmjngeonclgbbannb),Chrome 114+ 无需开实验开关即可直接调原始 C/C++ 源码、源码行下断点、悬停看变量。

JS 层 Hook importObject 与 instantiate

抓取实例、exports、memory 的核心 Hook,必须在目标 wasm 加载之前注入(油猴 @run-at document-start,或扩展 content script "run_at": "document_start" 注入主世界)。

// hook.js —— 注入到页面主世界,必须早于 wasm 加载
(function () {
  const origInstantiate = WebAssembly.instantiate;
  const origInstantiateStreaming = WebAssembly.instantiateStreaming;

  window.__wasm = { instances: [], memory: null, exports: null };

  function captureResult(result) {
    // result 可能是 WebAssembly.Instance,或 { module, instance }
    const instance = result.instance || result;
    if (instance && instance.exports) {
      window.__wasm.exports = instance.exports;
      window.__wasm.instances.push(instance);
      if (instance.exports.memory instanceof WebAssembly.Memory) {
        window.__wasm.memory = instance.exports.memory;
      }
      console.log("[wasm-hook] exports:", Object.keys(instance.exports));
    }
    return result;
  }

  // 包裹 importObject,拦截 env 里所有导入函数并打印入参/返回
  function wrapImportObject(importObject) {
    if (!importObject) return importObject;
    const out = {};
    for (const ns of Object.keys(importObject)) {      // 通常是 "env"、"wasi_snapshot_preview1"
      const mod = importObject[ns];
      out[ns] = {};
      for (const name of Object.keys(mod)) {
        const val = mod[name];
        if (typeof val === "function") {
          out[ns][name] = function (...args) {
            console.log(`[import] ${ns}.${name}(`, args, ")");
            const ret = val.apply(this, args);
            return ret;
          };
        } else {
          out[ns][name] = val;                          // Memory/Table/Global 原样传递
        }
      }
    }
    return out;
  }

  WebAssembly.instantiate = function (bufferOrModule, importObject, ...rest) {
    return origInstantiate
      .call(this, bufferOrModule, wrapImportObject(importObject), ...rest)
      .then(captureResult);
  };

  // 注意:streaming 走 Response 流,无法改字节,只能包 importObject 和抓 exports
  WebAssembly.instantiateStreaming = function (source, importObject, ...rest) {
    return origInstantiateStreaming
      .call(this, source, wrapImportObject(importObject), ...rest)
      .then(captureResult);
  };
})();

读 WASM 内存里的 C 字符串(注意每次重建视图,见常见陷阱):

// 从 window.__wasm.memory 读以 NUL 结尾的字符串
function wasmReadCString(ptr, maxLen = 4096) {
  // memory.buffer 是 ArrayBuffer;grow 后旧 buffer 会 detach,所以每次重新取
  const u8 = new Uint8Array(window.__wasm.memory.buffer);
  let end = ptr;
  while (end < ptr + maxLen && u8[end] !== 0) end++;
  return new TextDecoder("utf-8").decode(u8.subarray(ptr, end));
}

实战:抽取 wasm 与在 Node 黑盒调用

从网站抽取 .wasm

方法 A 最简单:F12 → Network 面板,过滤 wasm,找到目标请求右键 Save as,落盘为 sign.wasm

方法 B 最可靠(适用于运行时解密/拼接的 wasm):在页面最早期 Hook 编译入口,从字节落盘。关键是 instantiateStreaming 走 Response 流,必须 clone() 后才能既 dump 又给 WASM 用(流只能消费一次)。

// dump-wasm.js —— @run-at document-start 注入,截获进入编译的字节并下载
(function () {
  function dump(bytes, tag) {
    const buf = bytes instanceof ArrayBuffer ? bytes
              : ArrayBuffer.isView(bytes) ? bytes.buffer : null;
    if (!buf) return;
    const a = document.createElement("a");
    a.href = URL.createObjectURL(new Blob([buf], { type: "application/wasm" }));
    a.download = tag + "_" + buf.byteLength + ".wasm";
    a.click();
  }

  const _inst = WebAssembly.instantiate;
  WebAssembly.instantiate = function (bytes, ...rest) {
    if (!(bytes instanceof WebAssembly.Module)) dump(bytes, "instantiate");
    return _inst.call(this, bytes, ...rest);
  };

  const _instS = WebAssembly.instantiateStreaming;
  if (_instS) {
    WebAssembly.instantiateStreaming = async function (src, ...rest) {
      const resp = await src;
      resp.clone().arrayBuffer().then(b => dump(b, "stream"));   // 必须 clone
      return _instS.call(this, resp, ...rest);
    };
  }
})();

落盘后先看头 4 字节是否为 00 61 73 6d\0asm)确认是裸 wasm,是则可直接 wasm2wat

判断 wasm 形态决定复现路径

形态 特征 复现方式
Emscripten 模块 同目录有胶水 JS(Module/onRuntimeInitialized/ccall/_malloc);import 段大量 env.emscripten_* 直接复用胶水 JS,最省事
裸 SIDE_MODULE / 手写 wasm import 少而干净(仅 env.memory、几个 env.__*);无胶水 JS 手工补 import 垫片
字节码 VM(抖音 bdms 风格) 导出极少,内部是解释器循环,算法是被解释执行的字节码数据 仍黑盒调用导出,别反推字节码语义

wasm-objdump -x sign.wasm 看 import/export 段:(import "env" "...") 列出必须在 importObject 里补齐的垫片,(export "...") 列出能调用的函数。

Node 手工加载裸 wasm + import 垫片

WebAssembly.instantiate(bufferSource, importObject) 返回 Promise 解析为 { module, instance }importObject 每个声明 import 都必须有对应属性,否则抛 LinkError——报错会精确告诉你缺哪个,这是补垫片的导航。new WebAssembly.Memory({initial, maximum}) 单位是页(1 页 = 64 KiB)。

// load_wasm.js  (Node 18+,WebAssembly 是全局对象)
const fs = require("fs");

async function main() {
  const bytes = fs.readFileSync("./sign.wasm");

  // initial/maximum 看 wasm2wat 里的 (memory ...) / (table ...)
  const memory = new WebAssembly.Memory({ initial: 256, maximum: 32768 });
  const table = new WebAssembly.Table({ initial: 8, element: "anyfunc" });

  const importObject = buildImports(memory, table);
  const { instance } = await WebAssembly.instantiate(bytes, importObject);
  console.log("exports:", Object.keys(instance.exports));
}

// 按 LinkError 报缺什么就补什么,不要一次性全填
function buildImports(memory, table) {
  let HEAPU8 = new Uint8Array(memory.buffer);
  const refresh = () => { HEAPU8 = new Uint8Array(memory.buffer); };
  const env = {
    memory, table, __indirect_function_table: table,
    __memory_base: 0, __table_base: 0,
    __stack_pointer: new WebAssembly.Global({ value: "i32", mutable: true }, 5 * 1024 * 1024),
    abort: (w) => { throw new Error("wasm abort: " + w); },
    __assert_fail: () => { throw new Error("assert_fail"); },
    // 内存拷贝类用 copyWithin 实现;旧版符号名 emscripten_memcpy_big,
    // Emscripten 3.1.43+(含当前 6.x)改名为 emscripten_memcpy_js,两个都补上最稳妥
    emscripten_memcpy_big: (dest, src, num) => { refresh(); HEAPU8.copyWithin(dest, src, src + num); return dest; },
    emscripten_memcpy_js: (dest, src, num) => { refresh(); HEAPU8.copyWithin(dest, src, src + num); return dest; },
    emscripten_resize_heap: () => false,
    // 时间/随机务必给确定值,便于复现
    emscripten_get_now: () => Date.now(),
    emscripten_random: () => Math.random(),
    // 无关 syscall 直接返回 0
    setTempRet0: () => {}, getTempRet0: () => 0,
    __syscall_openat: () => -1,
  };
  return {
    env,
    wasi_snapshot_preview1: { fd_write: () => 0, fd_close: () => 0, proc_exit: (c) => { throw new Error("proc_exit " + c); } },
    "GOT.mem": {}, "GOT.func": {}, global: { NaN: NaN, Infinity: Infinity },
  };
}
main().catch(console.error);

补垫片的实战节奏:先只给 memory/table/abort 跑,看 LinkError 报缺哪个 env.xxx,回去补一个再跑,循环到不报错为止。绝大多数缺失要么是内存拷贝(用 copyWithin),要么是无关 syscall(返回 0 / throw)。

Emscripten 模块复现:直接用胶水 JS

如果抽 .wasm 时同时拿到胶水 JS,不要自己补 import——直接复用它生成的运行时。需要三件套:locateFile(告诉运行时去哪找 .wasm)、onRuntimeInitialized(实例化完成回调,必须等它触发后才能调导出)、Module 预置对象。

// run_emscripten.js  —— 老式(非 MODULARIZE)胶水 JS
const path = require("path");

// 在 require 胶水 JS 之前预置全局 Module 配置
global.Module = {
  locateFile: (file) => path.join(__dirname, file),    // 让它找到本地 sign.wasm
  onRuntimeInitialized: () => {
    callSign();                                          // 在回调里调用,保证已就绪
  },
};
require("./glue.js");   // 执行胶水 JS,它读 Module、实例化 wasm、触发回调

调用导出函数的完整骨架

典型 C 签名 char* sign(const char* input):入参是字符串指针、返回结果字符串指针。流程:_malloc 写入参 → 调用 → UTF8ToString 读返回 → _free

function callSign() {
  // 方式 1:cwrap(推荐,包一次反复用),'string' 入参自动 malloc+写堆+调用+free
  const sign = Module.cwrap("sign", "string", ["string"]);
  console.log(sign("hello_payload"));

  // 方式 2:ccall(一次性调用)
  const out = Module.ccall("sign", "string", ["string"], ["hello_payload"]);
  console.log(out);

  // 方式 3:手动管内存(传二进制 / 自己解析返回指针时)
  const input = "hello_payload";
  const nBytes = Module.lengthBytesUTF8(input) + 1;     // +1 给 \0
  const inPtr = Module._malloc(nBytes);                 // 在 wasm 堆上分配
  Module.stringToUTF8(input, inPtr, nBytes);            // 写入 UTF8(带终止符)

  const retPtr = Module._sign(inPtr);                   // 调底层导出(带下划线),返回 char* 指针
  const result = Module.UTF8ToString(retPtr);           // 从指针读字符串(按 \0 截断)
  console.log("sign result:", result);

  Module._free(inPtr);
  // 返回指针是否要 free 取决于 C 端:内部 malloc 的要 _free(retPtr),静态缓冲区则不要
}

二进制入参/出参不用 'string',用 HEAPU8.set 写、HEAPU8.slice 读:

function callBinary(inputBytes /* Uint8Array */) {
  const inPtr = Module._malloc(inputBytes.length);
  Module.HEAPU8.set(inputBytes, inPtr);                 // 写入参
  const outLen = 32;
  const outPtr = Module._malloc(outLen);
  Module._hash(inPtr, inputBytes.length, outPtr);       // void hash(in, len, out)
  // grow 后视图会失效,读之前重取 Module.HEAPU8
  const out = Module.HEAPU8.slice(outPtr, outPtr + outLen);   // 拷出,脱离 wasm 堆
  Module._free(inPtr);
  Module._free(outPtr);
  return out;
}

wasm2js 转 JS 后在 Node 调试

把不可读的二进制转成可断点的 JS,每条 wasm 指令变成具名 JS 语句,比 wat 易读、易插桩。

wasm2js secret.wasm -o secret.mjs --debug     # 转换(输入可为 .wasm 或 .wat)
node --inspect-brk run.mjs                     # 首行暂停,等 attach 后再继续
# 打开 chrome://inspect -> Open dedicated DevTools for Node -> Sources 里给函数下断点

局限:浮点/trap 语义在纯 JS 下是模拟的(--pedantic 可逼近但不完全等价),大模块转出体积大、性能低,仅用于分析而非生产。

WASM 内加密算法的判别与还原

两条路,强烈优先黑盒调用:不管它内部是 MD5/AES/自研,只要能"喂相同输入得相同输出"就够了。验证方法:在浏览器对同一输入观察真实输出,与 Node 复现结果逐字节对比,一致即收工。抖音 a_bogus/bdms 推荐走这条。

实在要看算法时才反编译。加密算法即便编译进 WASM,其魔法常量/S-Box 通常原样躺在 Data 段(ID 11)或字节码立即数里,可直接搜出来。先 wasm2wat module.wasm -o module.watwasm-objdump -s 转 hex,再搜常量。

哈希算法初始向量/常量

字节序两头都搜(大端原值 + 小端反转,如 67452301 / 01234567)。

算法 标志常量 说明
MD5 0x674523010xefcdab890x98badcfe0x10325476 4 个初始 IV(A/B/C/D),0x67452301 是最经典指纹
SHA-1 前 4 个同 MD5,多出第 5 个 0xC3D2E1F0 0xC3D2E1F0 是区分 SHA-1 与 MD5 的关键
SHA-256 0x6a09e6670xbb67ae85 等 8 个 IV;常量表 K 以 0x428a2f98 开头 0x428a2f98 辨识度极高,优先搜
SHA-512 0x6a09e667f3bcc908 等 64 位 IV 64 位版本
SM3(国密哈希) IV 0x7380166f 开头;常量 T 0x79cc4519(0≤j≤15)、0x7a879d8a(16≤j≤63) 0x7380166f0x79cc4519 是 SM3 铁证

对称加密 S-Box / 常量

算法 标志 说明
AES 正向 S-Box 以 63 7c 77 7b f2 6b 6f c5 开头;逆 S-Box 以 52 09 6a d5 开头 256 字节 S-Box 在 Data 段最常见,搜 637c777b
SM4(国密对称) S-Box 以 d6 90 e9 fe cc e1 3d b7 开头;系统参数 FK 首项 0xa3b1bac6 d690e9fe0xa3b1bac6
DES/3DES IP/FP 置换表、S1–S8 盒(值域 0–15) 特征是大量小数值表
Base64 字符表 ABCD...wxyz0123456789+/(URL 变体末尾 -_ Data 段里直接可见,最易识别
RC4 无固定常量,但有 for i in 0..256: S[i]=i 初始化模式 看代码逻辑

导出名与字符串线索

name 自定义段(若未 strip)或 Export 段可能直接暴露:encrypt/decrypt/sign/verify/hash/md5/sha256/sm3/sm4/aes/getSign/makeToken,C++ 修饰名如 __Z7encryptPKc。Data 段明文常泄露算法描述("AES/CBC/PKCS5Padding")、硬编码 key/iv、错误信息。

判别流程清单

1. 确认是 WASM:前 8 字节 00 61 73 6D 01 00 00 00 / 搜 AGFzbQ
2. wasm2wat module.wasm -o module.wat(或浏览器 DevTools 看反汇编)
3. 在 wat / hex 里搜魔法常量:
   [ ] 67452301        -> MD5 或 SHA-1
   [ ] C3D2E1F0        -> 确认是 SHA-1(而非 MD5)
   [ ] 6a09e667 / 428a2f98 -> SHA-256
   [ ] 7380166f / 79cc4519 -> SM3
   [ ] 637c777b        -> AES S-Box
   [ ] d690e9fe / a3b1bac6 -> SM4
   [ ] ABCDEFGH...+/   -> Base64 字符表
4. 搜 Export 段 / name 段函数名:encrypt/sign/md5/sm3 ...
5. 搜 Data 段明文字符串:算法描述、硬编码 key/iv
6. 字节序两头都搜(大端原值 + 小端反转)
7. 定位到 Code 段对应函数,结合 Hook 验证输入输出

最佳实践

  1. 优先黑盒调用,不要硬扣算法。 WASM 经过编译优化和混淆,逐行翻译成本极高且易错;只要在 Node 复现"相同输入产生相同输出"就满足签名需求。把 .wasm.so 加载即可。

    const sign = Module.cwrap("sign", "string", ["string"]);
    console.log(sign("payload"));   // 与浏览器输出逐字节对比,一致即收工
    
  2. 先看导出与导入再动手。wasm-objdump -x 先弄清模块暴露了哪些函数、依赖哪些环境,决定复现路径和补垫片清单,避免盲目反汇编 VM 循环。

    wasm-objdump -x sign.wasm     # Export 段告诉你能调什么,Import 段告诉你要补什么
    
  3. 每次访问内存都从 buffer 重建视图,不要缓存。 memory.grow()(如 -sALLOW_MEMORY_GROWTH)会让旧 ArrayBuffer detach,缓存的 TypedArray 失效。

    function readByte(ptr) {
      return new Uint8Array(window.__wasm.memory.buffer)[ptr];   // 每次重建
    }
    
  4. Hook 必须在 wasm 加载前注入。 instantiate/instantiateStreaming 一旦执行就抓不到了,用 @run-at document-start 或 content script "run_at": "document_start" 注入主世界。streaming 走 Response 流,dump 字节前必须 clone()

    resp.clone().arrayBuffer().then(b => dump(b));   // clone 后流才能既 dump 又给 WASM
    
  5. 复现时给环境依赖确定值。 算法若读 Date.now/Math.random/navigator 指纹,输出会随环境变化。在垫片里固定这些值,必要时配合补环境(见 补环境)。

    env.emscripten_get_now = () => 1700000000000;   // 固定时间确保可复现
    
  6. 用魔法常量快速判定算法族再决定要不要手翻。 搜到 637c777b(AES)或 6a09e667(SHA-256)就知道是标准算法,可直接用现成库验证,不必逐行还原。

    wasm2wat app.wasm -o app.wat   # 然后在 app.wat 里搜 637c777b / 6a09e667 / 7380166f
    

常见陷阱

陷阱一:memory.grow 后旧视图失效

  • 现象:调用一次导出函数后再读内存,HEAPU8.length 变 0 或读到全 0,报 RuntimeError: memory access out of bounds

  • 原因:模块用 -sALLOW_MEMORY_GROWTH,内部 memory.grow() 让旧 ArrayBuffer detach(byteLength 归零),所有缓存的 TypedArray 视图失效。

  • 解决:每次访问前从 memory.buffer / Module.HEAPU8 重新建视图,绝不缓存。

    // 错误:缓存视图,grow 后失效
    const HEAPU8 = new Uint8Array(memory.buffer);
    function read(ptr) { return HEAPU8[ptr]; }   // 错误:grow 后 HEAPU8 已 detach
    
    // 正确:每次重建
    function read(ptr) { return new Uint8Array(memory.buffer)[ptr]; }
    

陷阱二:LinkError 缺少 import

  • 现象WebAssembly.instantiateLinkError: Import #N "env" "emscripten_memcpy_big": function import requires a callable(Emscripten 3.1.43+ 产物里该符号名为 emscripten_memcpy_js)。

  • 原因:手工加载裸 wasm 时,importObject 没提供模块声明的某个导入符号。

  • 解决:报错精确指出缺哪个,按需补一个再跑,循环到不报错。内存拷贝类用 copyWithin,无关 syscall 返回 0 或 throw。

    // 按 LinkError 提示补:内存拷贝用 copyWithin,杂项 syscall 返回 0
    // 旧版符号名 emscripten_memcpy_big;Emscripten 3.1.43+(含 6.x)为 emscripten_memcpy_js
    const memcpy = (dest, src, num) => {
      new Uint8Array(memory.buffer).copyWithin(dest, src, src + num);
      return dest;
    };
    env.emscripten_memcpy_big = memcpy;
    env.emscripten_memcpy_js = memcpy;
    env.__syscall_fcntl64 = () => 0;
    

陷阱三:Module.ccall / Module._xxx 不存在

  • 现象TypeError: Module.ccall is not a function,或 Module._sign is not a function

  • 原因:编译时未导出运行时方法(ccall 等需 -sEXPORTED_RUNTIME_METHODS)或函数(_sign-sEXPORTED_FUNCTIONS);也可能是导出名带不带下划线弄反了。

  • 解决:用 wasm-objdump -x 确认真实导出名;若运行时方法没导出,改走手动 _malloc + Module._fn + UTF8ToString 路径。

    // ccall 不存在时,直接调底层导出(注意带下划线前缀)
    const inPtr = Module._malloc(16);
    Module.stringToUTF8("data", inPtr, 16);
    const retPtr = Module._sign(inPtr);          // 导出名以 wasm-objdump -x 确认
    console.log(Module.UTF8ToString(retPtr));
    Module._free(inPtr);
    

陷阱四:instantiateStreaming 在 Node 报 source must be Response

  • 现象:Node 里跑 Emscripten 胶水,streaming 路径报 source must be Response

  • 原因:Node 内置 undici 的 Response 与外部 npm 装的 undici 实例不互认。

  • 解决:用 Module.wasmBinary 注入字节,走非 streaming 路径。

    const fs = require("fs");
    global.Module = { wasmBinary: fs.readFileSync("./sign.wasm") };   // 跳过 fetch/streaming
    require("./glue.js");
    

参见

阅读更多

Web 安全基础

1. HTML 转义(服务端渲染必须): 2. CSP(Content Security Policy): 3. HttpOnly Cookie:防止 JS 读取会话 Cookie: 4. 前端框架防护: 攻击者在第三方网站构造一个表单,诱导已登录用户提交,浏览器会自动携带目标站的 Cookie。 触发条件: 1. 用户已登录目标网站(Cookie 有效) 2. 目标 API 仅凭 Cookie 识别用户身份 3. 请求来源未验证 1. CSRF Token(推荐): 2. SameSite Cookie: 3. 验证 Origin/Referer 头:

By yellowdog

HTTP 协议深度指南

HTTP(HyperText Transfer Protocol)是 Web 的基础传输协议,基于 TCP/IP,采用请求/响应模型。 相关文档:Web安全基础(/web-an-quan-ji-chu/) FastAPI完全指南(/fastapi-wan-quan-zhi-nan/) Nginx完全指南(/nginx-wan-quan-zhi-nan/) 幂等性:多次执行相同请求,服务器状态结果相同。PUT /users/1 多次执行结果一致;POST /users 每次创建新资源,非幂等。 浏览器直接从本地缓存读取,不向服务器发送请求。 缓存命中时,状

By yellowdog

系统设计基础

SLA 对照表: 选择建议:无状态服务(Web 层、API 层)优先水平扩展;数据库初期垂直扩展,达到瓶颈后考虑分库分表或读写分离。 缓存穿透(查询不存在的 key,每次都打到 DB): 缓存击穿(热点 key 过期,瞬间大量请求打到 DB): 缓存雪崩(大量 key 同时过期,或缓存服务宕机): 令牌桶 Python 实现: Redis 实现分布式限流(滑动窗口): URL 命名规则: Cursor 分页响应格式: 雪花算法结构(64 bit): 定义:分布式系统不能同时满足以下三个特性: 在分布式环境中 P 是必须保证的,所以实际是 CP vs AP

By yellowdog

算法思路与模板

二分查找要求序列有序,每次将搜索范围缩减一半,时间复杂度 O(log n)。 两个指针从两端向中间收缩,常用于有序数组。 滑动窗口维护一个满足条件的区间 left, right,right 不断向右扩张,条件不满足时收缩 left。 滑动窗口通用框架: 1. 确定"子问题":原问题可以分解为哪些规模更小的同类问题 2. 定义 dpi 或 dpij 的含义,要足够清晰 3. 推导状态转移方程 4. 确定初始状态(边界条件) 5. 确定计算顺序(确保依赖的子问题先计算) 每件物品最多选一次。dpj = 容量为 j 时的最大价值,逆序遍历容量防止重复选取。 每

By yellowdog