声明
本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!
本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!
前言
最近有小伙伴微信留言,询问关于某网站 wasm 的解法,经过分析是通过 wasm+worker 执行链来进行加密的,属于初级难度,网上的相关文章基本都是水文,本文就对该 demo 站进行逆向分析。
逆向目标
- 目标:Hyperlane 空投网
- 网址:aHR0cHM6Ly9jbGFpbS5oeXBlcmxhbmUuZm91bmRhdGlvbi8=
抓包分析
抓包可知首次访问返回状态码 429,随后返回 main.js 文件以及 wasm 文件,如下:
首次访问返回 token,该 token 作用于
request-challenge 接口:
通过 token 生成
x-vercel-challenge-solution 参数,最后经过该接口返回 cookie 后,再次请求之前返回 429 的接口,即可返回状态码 200:
逆向分析
从 request-challenge 接口的堆栈进入,跟栈后发现生成位置如下,是通过 Solve 将 token 传入异步算法生成的,Solve 为 wasm 暴露出来的方法:
这里存在代码混淆,用 AST 解下方便分析(相关解混淆后的代码会上传到知识星球中,以供学习交流),将解混淆后的文件替换到浏览器分析即可,调用逻辑是非常清晰的:
Slove 即为 wasm 交互层的调用方法,可以看到这样一个统一包装器:
记录
id / this / args,把事件挂到 _pendingEvent,调用 _resume() 恢复 wasm 执行,等 wasm 内部执行结束后,再把结果写回 event.result。
纯算分析
怎么反编译 wasm 可以看往期文章,这里不多赘述:
tianai 行为验证 wasm 逆向分析:mp.weixin.qq.com/s/a5QUQR-2l…
首先通过函数表,找到对应的函数关系:
elem[27] = ref.func:309 <main.Solve$1>
elem[28] = ref.func:311 <main.verifyBrowser$1>
elem[29] = ref.func:312 <main.Solve$1$1>
elem[30] = ref.func:313 <main.newPromise$1>
elem[31] = ref.func:314 <main.newPromise$1$1$gowrapper>
先看 main.Solve 相关代码段:
(func $main.Solve (type 13) (param i32 i64 i32 i32 i32 i32 i32)
...
local.get 8
i32.const 1
i32.eq
...
local.get 6
local.get 1
local.get 4
call $_syscall/js.Value_.String
...
local.get 8
i32.const 2
i32.eq
...
local.get 2
local.get 5
i32.const 27
call $main.newPromise
...
)
main.newPromise 决定了整条链是异步的,27 被传进了 main.newPromise,但是它后面会不会真的被 runtime 执行是不知道的,所以我们继续看 newPromise 。
关键片段如下:
(func $main.newPromise (type 1) (param i32 i32 i32)
...
call $_syscall/js.Value_.Get
...
i32.const 30
call $syscall/js.FuncOf
...
call $_syscall/js.Value_.New
...
)
Value_.Get 结合上下文,这里对上的就是 Promise,FuncOf(30) 的真实含义,是把函数表第 30 项,也就是 main.newPromise$1,包装成一个 JS 可调用的函数,Value_.New 再对前面拿到的 Promise 执行 new,所以逻辑应是:
const executor = FuncOf(main.newPromise$1);
const promise = new Promise(executor);
接下来必须看 main.newPromise$1,关键片段如下:
(func $main.newPromise$1 (type 13) ...)
...
local.get 3
local.get 5
i32.store offset=8
local.get 3
local.get 8
i32.store offset=4
local.get 3
local.get 6
i32.store
i32.const 31
local.get 3
call $internal/task.start
...
)
这里最关键的是:
local.get 3
local.get 6
i32.store
把这一步和前面的 main.Solve -> newPromise(27) 连起来,可知:
main.Solve把 27 传给main.newPromisemain.newPromise再把参数交给main.newPromise$1main.newPromise$1把这个 27 真正写进 task
27 这里被 runtime 保存起来、等待后续真正执行的函数索引,所以接着看所以下一步必须看 newPromise$1$1$gowrapper。
关键片段:
(func $main.newPromise$1$1$gowrapper (type 4) ...)
...
local.get 3
i32.load
local.tee 7
...
call_indirect (type 20)
...
)
也就是说,前一层存进去的那个 27,在这里又被取出来了,所以最终得到调用链:
main.Solve传 27main.newPromise$1把 27 存进 taskgowrapper再把 27 从 task 里读出来call_indirect根据函数表执行索引 27- 而 27 对应的正是
main.Solve$1
接着看 main.Solve$1 ,关键片段:
(func $main.Solve$1 (type 20) (param i64 i32 i64 i32 i32)
...
local.get 1
local.get 3
i32.const 28
call $main.newPromise
...
local.get 4
local.get 1
i32.const 29
call $syscall/js.FuncOf
...
local.get 3
local.get 0
local.get 9
i32.const 127939
i32.const 4
local.get 1
call $_syscall/js.Value_.Call
...
)
可知 main.Solve$1 先创建的是一个由 main.verifyBrowser$1 调起的 Promise,接着 main.Solve$1$1 在这里被包装成了 JS callback,开始接到前一个 Promise 的后续链上,接着第三步看 Value_.Call(..., 127939, ...),127939 通过地址查看是 then。
所以整段逻辑如下:
const p = newPromise(verifyBrowser$1);
const next = FuncOf(Solve$1$1);
p.then(next);
main.Solve$1 的作用,是把 main.verifyBrowser$1 和 main.Solve$1$1 按照 “先环境、后算法” 的顺序串进同一条 Promise 主链。
先看 verifyBrowser$1,关键片段:
(func $main.verifyBrowser$1 (type 20) ...)
...
i64.const 498216206448
i64.store offset=420 align=4
...
call $runtime.stringFromRunes
...
call $main.checkGlobalFunction
...
call $main.checkGlobalUndefined
...
call $main.eval
...
call $main.randomHex
...
)
可知:
importScripts必须是函数process / require / module必须是undefinedcreateImageBitmap必须存在
然后 eval 它会把脚本丢回宿主环境执行,verifyBrowser$1 负责先把环境单独剥离出来,然后宿主执行继而结果回传。
最终看加密流程部分关键片段:
(func $main.Solve$1$1 (type 13) ...)
...
call $_syscall/js.Value_.String
...
call $strings.Split
...
call $strconv.ParseInt
...
call $runtime.stringFromBytes
...
call $main.randomHex
...
call $_*crypto/sha256.digest_.Write
...
call $runtime.stringConcat
...
call $_*encoding/json.encodeState_.reflectValue
)
通过分析可知,这项操作最终是在:
- 把某段值转成字符串
- 拆字符串
- 解析数字
- 把字节转回字符串
- 生成随机 hex
- 计算 sha256
- 拼接字符串
token 解析图
看一组示例 token:
2.1773373298.60.MDY3ZTYzNWUyODQ1YmY5MmVlOTYxNjAxNmI0Yzk2YjY7MDhhYTMwMDU7ZTBkNzUwZGE0ZDQ4ZDdjY2VhNmNkMDhlYTE2MjI4NjZlMWNmZmZjYzszOzWjY7reU+Ftlq6LCvYAaVuiNRb+jRhIc3nDQYuMIzf8kEjA1GcUhJTDJQ==.79e410308c410d8537849d8ba9253a35
第一部分相关代码段如下:
(func $main.Solve$1$1 (type 13) ...)
...
call $_syscall/js.Value_.String
...
i32.const 127880
call $strings.Split
...
call $strconv.ParseInt
...
call $runtime.stringFromBytes
...
)
对应解析结果:
{
"version": "2",
"seed": 1773373298,
"window": 60,
"field0": "067e635e2845bf92ee9616016b4c96b6",
"prefix": "08aa3005",
"target_hex": "e0d750da4d48d7ccea6cd08ea1622866e1cfffcc",
"count": 3,
}
继续分析,初始便宜计算,关键片段如下:
local.get 6
i32.load offset=16
local.tee 12
local.get 32
local.get 1
i64.const 5
i64.rem_s
local.tee 1
...
i32.const 127952
i32.add
i64.load
i64.mul
i64.const 36
i64.rem_s
local.set 1
local.get 6
i32.const 20
i32.add
i64.load32_u
local.get 1
i64.const 4
i64.add
i64.lt_u
br_if 9 (;@2;)
local.get 12
local.get 1
i32.wrap_i64
i32.add
local.set 23
local.get 7
local.get 23
i32.store offset=636
i32.load offset=16 -> local.tee 12- 先把原始
target_hex的起始指针取出来
- 先把原始
i64.rem_s 5- 对
5取模,选MAGIC的哪一项
- 对
i32.const 127952+i64.load+i64.mul+i64.rem_s 36- 从只读区常量表取
MAGIC - 乘上
seed对 36 取模,得到initial_offset - 最后对 36 取模,得到
initial_offset
- 从只读区常量表取
i64.load32_u ... local.get 1 ... i64.const 4 ... i64.lt_u- 边界检查,剩余长度必须至少还能拿出 4 个字符做比较
local.get 12+local.get 1+i32.wrap_i64+i32.add- “原地址 + 偏移”
local.set 23- 把偏移后的新起点保存起来
seed % 5 = 3
MAGIC[3] = 708403
initial_offset = (708403 * 1773373298) % 36 = 2
所以第一轮真正的开始是从便宜地址开始的,也就是:
target_hex[2:] = d750da4d48d7ccea6cd08ea1622866e1cfffcc
下部分:
call $main.randomHex
...
call $runtime.stringConcat
...
call $_*crypto/sha256.digest_.Write
...
local.get 3
i32.const 0
i32.const 64
memory.fill
loop
...
i32.const 98155
i32.add
i32.load8_u
...
end
local.get 2
local.get 3
i32.const 64
call $runtime.stringFromBytes
...
local.get 7
i32.load offset=104
local.tee 6
...
local.get 7
i32.load offset=108
local.tee 3
i32.const 4
i32.lt_s
br_if 1 (;@13;)
local.get 6
i32.const 4
local.get 23
i32.const 4
call $runtime.stringEqual
而这里又明确出现了:
local.get 6
i32.const 4
local.get 23
i32.const 4
call $runtime.stringEqual
runtime.stringEqual 的定义本身也说明它按 “指针 + 长度” 逐字节比较:
(func $runtime.stringEqual (type 2) (param i32 i32 i32 i32) (result i32)
local.get 1
local.get 3
i32.ne
br_if 0
...
local.get 2
i32.load8_u
local.get 0
i32.load8_u
i32.eq
)
所以这层就可以显然的看到命中条件:
digest[:4] == current_target[:4]
文章示例输入:
prefix = 08aa3005
current_target = d750da4d48d7ccea6cd08ea1622866e1cfffcc
need = current_target[:4] = d750
第一轮找到的合法候选值:
candidate_0 = f31a80ecdff86632
把它代进去以后:
sha256(08aa3005f31a80ecdff86632)
= d75089cc5fab8d98573af3163e34851932373367ab35d6f8d539980e7b231e56
它对应的 digest 前 4 位正好就是:
digest_0[:4] = d750
所以最终 digest_0[:4] == current_target[:4] == d750,python 复现如下:
candidate = secrets.token_hex(8)
digest = sha256_hex(prefix + candidate)
if digest[:4] == current_target[:4]:
accepted.append(candidate)
第一轮命中后继续链式滚动,体现为:
local.get 2
local.get 3
i32.const 64
call $runtime.stringFromBytes
local.get 7
i32.load offset=104
local.tee 6
...
local.get 7
i32.load offset=108
local.tee 3
local.get 6
i32.const 4
local.get 23
i32.const 4
call $runtime.stringEqual
local.get 4
local.get 6
i32.store offset=8
local.get 4
i32.const 12
i32.add
local.get 3
i32.store
i32.const 127952
i64.load
i64.mul
i64.const 60
i64.rem_s
local.tee 1
...
local.get 6
local.get 1
i32.wrap_i64
i32.add
local.set 23
可以看到,前一轮比较成立后,代码没有回到最开始那个 target_hex 指针上,而是直接执行了:
local.get 6
local.get 1
i32.wrap_i64
i32.add
local.set 23
local.get 6= 刚刚生成并命中的digest_0字符串起点local.get 1= 新算出来的next_offsetlocal.set 23= 下一轮current_target的起点
所以这一步实际为:
current_target = digest_0[next_offset:]
在本文里面就是:
digest_0[26:] = 34851932373367ab35d6f8d539980e7b231e56
next_target[:4] = 3485
接着第三轮继续做相同的滚动,最终滚到了 3 个结果,f31a80ecdff86632;39e4c6ee978ae986;9454002860eaeacc 最终包装返回:
call $runtime.stringConcat
...
call $_*encoding/json.encodeState_.reflectValue
所以最终总结算法其实就是从 raw token 中拆出 seed / window / prefix / target_hex / count,再通过 MAGIC 计算目标窗口,随后不断随机生成 16 位 hex 候选值,直到每一轮都让 sha256(prefix + candidate) 的前 4 位命中当前目标前缀,并在每轮命中后按偏移规则滚动到下一轮,最终把命中的候选片段用 ; 拼成 solution。
算法结果验证
补环境
大致流程可以分为如下:
-
Worker 侧运行时
- 跑
challenge.v2.deobfuscated.js - 实例化 wasm
- 提供
self / window / globalThis - 提供
fetch / crypto / performance / WebAssembly
- 跑
-
宿主页面侧
- 执行
requestEval发回来的 probe 表达式 - 返回 WebGL / Canvas / antiBot 检测结果
- 执行
总链路如下:
最关键的就是异步 Promise 和回调恢复链不能断,剩下就是一些家常的 WebGL probe Canvas probe 等等。
js 里有几段关键逻辑:
function requestEval(argv) {
postToMessagePort({
type: "eval-request",
id: requestId,
argv,
token: runtimeState.token
});
return new Promise((resolve, reject) => {
pendingEvalRequests.set(requestId, { resolve, reject });
});
}
probe 不是直接在 Worker 里执行,而是发消息出去,再等 Promise 回来。
这里的 setTimeout 不只是定时器,它还是桥接函数的挂载点。
self.setTimeout.e = requestEval;
self.setTimeout.d = detectDevtools;
Go wasm runtime 恢复链
this._resolveCallbackPromise = () => {
setTimeout(resolve, 0);
};
goRuntime._pendingEvent = event;
goRuntime._resume();
eval 结果回来以后,不只是拿值,还要把 Go runtime 的回调恢复链接回去,处理 worker方法大概如下:
function createWorkerContext(createPage) {
const pageEval = async (expression) => {
const page = createPage();
try {
const script = new vm.Script(expression);
const value = script.runInContext(page.getInternalVMContext(), {
timeout: 2000,
});
if (!value || typeof value.then !== "function") {
return value;
}
return await Promise.race([
value,
new Promise((_, reject) => {
setTimeout(() => reject(new Error("page eval promise timeout")), 2000);
}),
]);
} finally {
page.window.close();
}
};
const workerSetTimeout = function workerSetTimeout(fn, ms, ...args) {
return setTimeout(fn, ms, ...args);
};
workerSetTimeout.e = pageEval;
workerSetTimeout.d = () => false;
const localFetch = async (input) => {
const url = typeof input === "string" ? input : input.url;
if (
url === "/.well-known/vercel/security/static/challenge.v2.wasm" ||
url.endsWith("/.well-known/vercel/security/static/challenge.v2.wasm")
) {
return new Response(fs.readFileSync(WASM_PATH), {
headers: {
"content-type": "application/wasm",
},
});
}
throw new Error(`Unsupported fetch URL: ${url}`);
};
const sandbox = {
console,
fetch: localFetch,
crypto: webcrypto,
performance,
WebAssembly,
TextEncoder,
TextDecoder,
ArrayBuffer,
DataView,
Uint8Array,
Uint8ClampedArray,
Int8Array,
Int16Array,
Int32Array,
Uint16Array,
Uint32Array,
BigInt64Array,
BigUint64Array,
Float32Array,
Float64Array,
Promise,
Map,
Set,
WeakMap,
WeakSet,
Symbol,
Reflect,
Object,
Array,
String,
Number,
Boolean,
Math,
Date,
Error,
TypeError,
RegExp,
JSON,
URL,
URLSearchParams,
Response,
Request,
Headers,
setTimeout: workerSetTimeout,
clearTimeout,
importScripts: () => undefined,
createImageBitmap: async () => ({}),
process: undefined,
require: undefined,
module: undefined,
};
let workerSelf = null;
workerSelf = new Proxy(
{},
{
get(_target, prop) {
if (
prop === "self" ||
prop === "window" ||
prop === "globalThis" ||
prop === "global"
) {
return workerSelf;
}
if (prop in sandbox) {
return sandbox[prop];
}
return globalThis[prop];
},
set(_target, prop, value) {
sandbox[prop] = value;
return true;
},
has(_target, prop) {
return prop in sandbox || prop in globalThis;
},
ownKeys() {
return Reflect.ownKeys(sandbox);
},
getOwnPropertyDescriptor(_target, prop) {
if (prop in sandbox) {
return {
configurable: true,
enumerable: true,
writable: true,
value: sandbox[prop],
};
}
return undefined;
},
}
);
sandbox.self = workerSelf;
sandbox.window = workerSelf;
sandbox.globalThis = workerSelf;
sandbox.global = workerSelf;
const context = vm.createContext(sandbox);
return { context, workerSelf, pageEval };
}
之前错误的姿势是:
workerSetTimeout.e = (expression) => script.run(...);
问题在于 probe 可能返回 Promise 你直接回传,就把 Promise 对象本身传回去了,也是一个小坑点。