【JS逆向百例】Vercel 盾 Worker 执行链,纯算法与补环境分析

0 阅读5分钟

pV5GxQP.png转存失败,建议直接上传图片文件

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!

前言

最近有小伙伴微信留言,询问关于某网站 wasm 的解法,经过分析是通过 wasm+worker 执行链来进行加密的,属于初级难度,网上的相关文章基本都是水文,本文就对该 demo 站进行逆向分析。

逆向目标

  • 目标:Hyperlane 空投网
  • 网址:aHR0cHM6Ly9jbGFpbS5oeXBlcmxhbmUuZm91bmRhdGlvbi8=

抓包分析

抓包可知首次访问返回状态码 429,随后返回 main.js 文件以及 wasm 文件,如下: NNu9qV.png 首次访问返回 token,该 token 作用于 request-challenge 接口: NNqeFI.png 通过 token 生成 x-vercel-challenge-solution 参数,最后经过该接口返回 cookie 后,再次请求之前返回 429 的接口,即可返回状态码 200: NNukDL.png

逆向分析

request-challenge 接口的堆栈进入,跟栈后发现生成位置如下,是通过 Solve 将 token 传入异步算法生成的,Solve 为 wasm 暴露出来的方法: NNujhb.png 这里存在代码混淆,用 AST 解下方便分析(相关解混淆后的代码会上传到知识星球中,以供学习交流),将解混淆后的文件替换到浏览器分析即可,调用逻辑是非常清晰的: NNH7Rs.png Slove 即为 wasm 交互层的调用方法,可以看到这样一个统一包装器: NNHlgq.png 记录 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 结合上下文,这里对上的就是 PromiseFuncOf(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) 连起来,可知:

  1. main.Solve 把 27 传给 main.newPromise
  2. main.newPromise 再把参数交给 main.newPromise$1
  3. main.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 传 27
  • main.newPromise$1 把 27 存进 task
  • gowrapper 再把 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$1main.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 必须是 undefined
  • createImageBitmap 必须存在

然后 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 解析图

Nu9XRZ.png

看一组示例 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_offset
  • local.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。

算法结果验证

NqKVZb.png

补环境

大致流程可以分为如下:

  • Worker 侧运行时

    • challenge.v2.deobfuscated.js
    • 实例化 wasm
    • 提供 self / window / globalThis
    • 提供 fetch / crypto / performance / WebAssembly
  • 宿主页面侧

    • 执行 requestEval 发回来的 probe 表达式
    • 返回 WebGL / Canvas / antiBot 检测结果

总链路如下:

Nu9JgH.png

最关键的就是异步 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 对象本身传回去了,也是一个小坑点。

环境结果验证

NqKcqe.png