JS 作用域劫持的死穴:动态 this 穿透,以及 AST 怎么救

这是 Web 全量代理系列里最硬核的一篇,讲一个让所有"作用域劫持"方案翻车的语言特性:动态 this。

如果你在做 Web 代理 / 浏览器沙箱 / JS 沙盒 / 注入式前端 SDK,几乎一定会撞上这个问题。本文完整讲清楚:为什么 this 是死穴,为什么字符串替换救不了,AST 怎么救。

一、背景:作用域劫持与"内网穿透"

前置知识。Web 全量代理的目标:把任意网站在浏览器里完整代理,让 JS "以为"自己还在原站运行。

为什么需要"作用域劫持"?因为现代网站 JS 会动态读 location:

js 复制代码
const apiBase = window.location.origin
const url = apiBase + '/api/v2/user'
fetch(url)

光服务端改写 URL 不够------JS 运行时读 location.origin 拿到的是代理域名,拼出来的 URL 还是代理域名,请求能通但接口里校验 origin 会挂。

解法:劫持作用域 ,把代码包进 with:

js 复制代码
(function () {
  with (self.__context_proxy__) {
    // 原代码
    const apiBase = window.location.origin  // 实际读 __location__.origin,返回原站
  }
}).call(self.__context__.self)

__context_proxy__ 是 Proxy,has 永远返回 true,get 优先返回伪造对象。代码里所有裸标识符 location、window、self 都被劫持到代理对象,返回原站的值。

这样被代理网站的 JS 拿到的 location.origin 是原站 URL,但实际请求经 fetch 包装后打到代理域名------JS 视角透明,请求实际走代理。这是"在浏览器里实现内网穿透"的核心机制:用户在外网,但页面 JS 看到的是内网视角。

二、死穴:动态 this

上面方案看起来天衣无缝,直到遇到这段代码:

js 复制代码
function getApiBase() {
  return this.location.origin
}

element.addEventListener('click', getApiBase)

浏览器调用事件回调时,this 指向触发事件的元素。但如果是这样:

js 复制代码
function getApiBase() {
  return this.location.origin
}

// 非严格模式下直接调用
getApiBase()

非严格模式下,裸调用函数 this === window 。这时 this.location 实际访问的是真实的 window.location------绕过了 with 作用域劫持。

为什么 with 拦不住 this.location

with(obj) { ... } 的语义是:在 with 块内,裸标识符 优先从 obj 解析。但 this.location 不是裸标识符------this 是关键字,求值后得到一个对象(这里是 window),然后 .location 是属性访问,走原型链。

with 只劫持"作用域查找",不拦截"对象属性访问"。所以:

js 复制代码
with (__context_proxy__) {
  location        // ✓ 被劫持,读 __location__
  window.location // ✓ 被劫持,window 是 __self__
  this.location   // ✗ this 是真实 window,location 是真实 location
}

这种代码真的存在吗

非常常见。随便举几种:

js 复制代码
// React 类组件
class App extends Component {
  componentDidMount() {
    fetch(this.location.origin + '/api/init')  // 翻车
  }
}

// jQuery 风格
$('#btn').on('click', function() {
  window.open(this.location.href)  // 翻车
})

// 事件回调
function handler() {
  const base = this.location.origin  // 翻车
}
document.addEventListener('click', handler)

更坑的是这种:

js 复制代码
const obj = { location: window.location }
function getHref() { return this.location.href }
obj.getHref = getHref
obj.getHref()  // this 是 obj,obj.location 是真实 location(构造时就缓存了)

obj.location 在构造时被赋值,直接缓存了真实 window.location 。即便后续劫持了 window.location,obj.location 已经是旧引用。这种"快照"问题更隐蔽。

三、为什么字符串替换救不了

第一反应:把所有 this.location 替换成 __location__ 不就行了?

js 复制代码
function getApiBase() {
  return __location__.origin  // 替换后
}

问题在于 this 的指向是运行时动态的:

js 复制代码
function getHref() { return this.location.href }

getHref()                  // this 是 window
getHref.call(obj1)         // this 是 obj1
getHref.call(obj2)         // this 是 obj2
element.onclick = getHref  // this 是 element
new getHref()              // this 是新对象

同一个函数,四种调用方式,this 四种值。静态分析无法确定 this 是什么。

如果无脑替换成 __location__:

js 复制代码
function getHref() { return __location__.href }

const element = { location: 'some element location' }
getHref.call(element)  // 原本应该返回 element.location,现在返回 __location__.href ------ 业务逻辑错了

业务逻辑里 this.location 可能根本不是 window.location------是某个业务对象的 location 字段。无脑替换会破坏业务。

更糟:注释和字符串

js 复制代码
const code = 'this.location.href'  // 字符串,不是代码
const comment = '/* this.location */'  // 注释
const regex = /this\.location/  // 正则

字符串替换会把字符串内容和注释也替换掉,破坏业务逻辑。

四、商业方案的妥协:每站规则

商业 Web 代理怎么处理?答案是给特定网站写规则:

kotlin 复制代码
检测到 example.com 的代码里有 this.location
  ↓
为 example.com 单独写规则
  ↓
在通用拦截之外,针对 example.com 做替换
  ↓
上线,下个网站又坏了
  ↓
再写一条规则
  ↓
... 规则库积累到几千条

这是"打地鼠"模式。每个网站的 this 用法不一样,规则不能复用。规则库越来越臃肿,新网站适配越来越慢。这是商业 Web 代理(包括校园网 Web 代理)的核心痛点。

五、AST 解法:精确识别 this

要让"通用全量代理"成立,必须从根上解决 this 穿透,不能依赖每站规则。

关键洞察

this 在 JS 语法树里是 ThisExpression 节点------关键字,不是标识符。AST 解析器能精确区分:

js 复制代码
this.location           // ThisExpression + MemberExpression
const x = 'this.location'  // 字符串字面量,不解析为 ThisExpression
/* this.location */      // 注释,不进入 AST
const this_location = 1  // 标识符,不是 ThisExpression

AST 相比字符串替换的核心优势:知道每个 this 是关键字还是文本。

改写策略

遍历 AST,把所有 ThisExpression 替换成:

js 复制代码
(this === self ? __self__ : this)
  • this === window 时(裸调用、事件回调),返回 __self__,劫持生效
  • this 是其他对象时(call/apply/new),返回原 this,业务逻辑不变

改写后:

js 复制代码
function getApiBase() {
  return (this === self ? __self__ : this).location.origin
}

__self__ 是伪造的 window Proxy,访问 __self__.location 走 __location__,劫持生效。

完整流程

kotlin 复制代码
JS 源码
  ↓ oxc / acorn / babel 解析
AST
  ↓ 遍历,ThisExpression → (this === self ? __self__ : this)
AST(改写后)
  ↓ codegen
JS 源码(改写后)
  ↓ 注入 with(__context_proxy__) 包裹
最终代码

关于 this === self 比较的细节

细心的读者会发现:在 with 块内,self 也会被 __context_proxy__ 劫持为 __self__。所以 this === self 实际比较的是"真实 window(this)是否等于 self (劫持后的 self)"------不相等。

这里还有个隐蔽的坑:业务代码里常见的 var self = this(jQuery 风格)会污染 self,让 this === self 在不该成立时恒真。所以更稳的基准是 globalThis------Rust 版用的替换式是 (this === globalThis ? __self__ : this)。globalThis 作为内置全局引用不被业务代码覆盖,提供一个"未被劫持的真实基准"。具体实现细节见代码。

六、AST 改写的工程细节

选哪个 parser

Parser 语言 速度 适用
oxc Rust 极快 服务端(Rust 版)
acorn JS 快 浏览器端 / Node
babel JS 慢 浏览器端,生态丰富
swc Rust 极快 服务端

本项目两个版本都在服务端 做 AST 改写:Rust 版用 oxc,Node 版用 acorn。另外还有一个更细的问题:eval 和 Function 构造器在浏览器端 动态生成代码,服务端改写不到------本项目把 oxc 编译成 WASM(rewrite.wasm)随拦截脚本下发,在浏览器里对动态代码做同样的 this 重写。

遍历范围

一个自然的想法是"有的 this 不用改":

js 复制代码
// 箭头函数里的 this 继承外层,不独立求值
const fn = () => this.location  // 外层的 this 改了,这里继承的就是改过的

// class 里的 this 通常是实例
class App {
  constructor() { this.x = 1 }  // this 是实例,不是 window
}

但实际实现里所有 ThisExpression 都统一改写,不做跳过。原因:

  1. 安全侧倾向 :改写是无损的------this 不是 window 时表达式原样返回 this,业务逻辑不变;漏改一个才是真 bug
  2. 箭头函数的坑 :箭头函数的 this 继承外层,但外层改写后的值在运行时才确定,静态分析保证不了"外层一定已经改过"(比如外层是 eval 的代码);统一改写是唯一可靠策略
  3. 实现简单 :一遍遍历替换所有 ThisExpression,不需要作用域分析

代价是 this.x = 1 这种实例属性赋值多了一次 this === window 的判断,运行时开销可忽略。

性能

  • AST 解析 + 改写:单文件 1-50ms(取决于大小)
  • 大文件(webpack bundle 1MB+):约 5-10ms(oxc),acorn 稍慢
  • 快速跳过:代码不含 this 关键字时直接返回,不启动解析器;配合本地缓存开关,改写后的 JS 可落盘复用,二次访问零开销

服务端做 AST 改写,对响应延迟的影响可接受。

七、对比总结

方案 this 穿透 字符串/注释误伤 每站规则 通用性
字符串替换 this.location → __location__ ✓(部分) ✗ 会误伤 需要 低
每站规则(商业方案) ✓ ✓ 需要几千条 低
AST ThisExpression 重写 ✓ ✓ 不误伤 0 条 高

核心结论:要实现真正的"通用全量代理",AST 改写是必经之路。字符串替换和每站规则都是治标。

八、延伸:不只是 Web 代理

this 穿透问题不只出现在 Web 代理。任何"在宿主环境里注入并劫持 JS 作用域"的场景都会遇到:

  • 浏览器沙箱(micro-frontend、iframe 替代方案)
  • JS 沙盒(code execution platform、低代码运行时)
  • 注入式 SDK(埋点、监控、A/B 测试)
  • 测试 mock(jsdom 之外的浏览器环境模拟)
  • 小程序运行时(在自定义环境里跑业务代码)

只要你的方案是"劫持全局对象 + 包 with",就一定撞上 this 穿透。AST 重写是通用解。

九、代码开源

完整实现(Rust + oxc 与 Node + acorn 双服务端 AST 改写,浏览器端 WASM 处理 eval/Function,JS 客户端作用域劫持)已开源:

GitHub:github.com/Stardust100...

欢迎 Star、Issue。如果你在做 JS 沙箱 / 浏览器代理 / 注入式 SDK,强烈推荐试试 AST 这条路,比字符串替换和每站规则都靠谱。

项目仅用于学习研究,请确保在授权范围内使用,勿用于违反法律法规的用途。

相关推荐
IT_陈寒38 分钟前
React的setState真是个演员,你以为它同步其实在装睡
前端·人工智能·后端
孟健2 小时前
Claude Code 同需求写两版对比:装不装 frontend-design 差多远?
前端·ai编程·claude
梦de天空一米16884 小时前
【一个重复id让游戏卡在62%,聊聊配置表检查】
前端
ryn483984 小时前
01 一个功能模块,怎么分层才不乱
前端·ai编程·claude
Amos_Web4 小时前
通过 Rspack 源码学 Rust(五):读懂 Result 与问号运算符的错误返回
前端
杨航 AI4 小时前
JEV的用法
java·前端·数据库
Amos_Web4 小时前
Rspack 源码解析(三十五):rspack_error 如何用 miette 渲染带源码定位的错误链
前端
去伪存真5 小时前
高敏资产场景下的大模型协同设计架构
前端·agent
修炼的dance5 小时前
OCR预处理测了108组,纠斜去噪有用,坑在顺序和力度
前端