浏览器脚本注入:两个世界、两种实现(JS 扩展 vs Python/CDP)

张 张站长 · 2026-09-15 18:49 · 技术-逆向 · 47 次浏览 · 0 条评论

注入的重点从来不是用什么语言,而是往哪个「世界」、在什么时机注入。这篇把 JS 扩展和 Python/CDP 两条路从零跑一遍,配本地 demo 和对比表。

浏览器脚本注入这件事,我前后用两条路子都写过:一条是纯前端、拿 JavaScript 往页面里塞代码;另一条是用 Python 在外面驱动浏览器、通过调试协议把代码打进去。很多人一上来就纠结"用哪个语言",其实语言不是重点,重点是你要往哪个"世界"里注入、在什么时机注入。这篇我把这两条路都从零讲一遍,配一个本地就能跑的 demo,最后给一张对比表,看完你自己就知道该用哪条。

全程的靶子都是我自己写的一个本地 demo.html,不碰任何线上网站——先把原理跑通,换到别的页面是一样的道理。

一、先搞清楚:一个网页里有两个 JS 世界

这是所有注入问题的根,不理解这个,后面全是坑。

一个网页里的两个 JS 世界

一个普通网页,页面自己的 <script> 跑在主世界(main world)里。你在控制台里敲 window.xxx 能看到的东西——页面挂在 window 上的全局变量、函数——都在主世界。

但浏览器扩展的 content script(内容脚本)不在主世界,它跑在一个叫隔离世界(isolated world)的地方。这两个世界:

  • 共享同一份 DOM:content script 能读写页面上的 <div>、<input>,能点按钮、填表单。
  • 不共享 JS 变量:content script 里读 window.__DATA__ 是 undefined,哪怕页面主世界里明明有这个变量。

浏览器故意这么设计,是为了安全——不想让扩展和页面互相污染全局。

结论先记住:如果你只是想读写 DOM(填个表、抓页面上已经显示出来的文字),content script 直接就够了。但只要你想拿到"页面 JS 内部的东西"(比如某个挂在 window 上的 token、某个加密函数),你就必须想办法把代码"送进主世界"。

二、准备一个本地 demo 靶子

新建一个 demo.html,它模拟"页面把数据和一个函数藏在主世界的 window 上":

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"><title>注入 Demo</title></head>
<body>
  <h1>本地注入 Demo</h1>
  <p id="msg">等待注入…</p>
  <script>
    // 页面主世界里的"内部数据"和"内部函数"
    window.__DATA__ = { user: "zhang", token: "T-" + Date.now() };
    window.calc = function (a, b) { return a * 1000 + b; };
    console.log("[页面] 我把 __DATA__ 和 calc 放在了 window 上");
  </script>
</body>
</html>

用任意静态服务器起起来,比如:

python3 -m http.server 8000
# 浏览器打开 http://127.0.0.1:8000/demo.html

我们的目标很具体:拿到 window.__DATA__.token,并调用 window.calc(2, 3) 拿到返回值。 先在控制台手动 window.__DATA__ 确认它确实存在——这是主世界。

三、路线 A:JavaScript 版(浏览器扩展 content script + 注入主世界)

这条路的核心动作只有一个:content script 造一个 <script> 标签,把要在主世界跑的代码塞进去,跑完再用 postMessage 把结果传回隔离世界。

目录结构:

inject-ext/
├── manifest.json
├── content.js      // 跑在隔离世界
└── inject.js       // 会被塞进主世界

manifest.json(Manifest V3):

{
  "manifest_version": 3,
  "name": "本地注入 Demo",
  "version": "1.0",
  "content_scripts": [
    {
      "matches": ["http://127.0.0.1:8000/*"],
      "js": ["content.js"],
      "run_at": "document_start"
    }
  ],
  "web_accessible_resources": [
    { "resources": ["inject.js"], "matches": ["http://127.0.0.1:8000/*"] }
  ]
}

两个关键点:run_at: document_start 让我们在页面自己的脚本之前就介入(时机很重要,后面讲);web_accessible_resources 是必须的,不声明的话页面加载不了你的 inject.js。

content.js(隔离世界,负责"投递"和"接收"):

// 1) 把 inject.js 作为 <script src> 塞进主世界
const s = document.createElement('script');
s.src = chrome.runtime.getURL('inject.js');
s.onload = () => s.remove();            // 跑完就把标签删掉,别留痕
(document.head || document.documentElement).appendChild(s);

// 2) 监听主世界用 postMessage 发回来的结果
window.addEventListener('message', (e) => {
  if (e.source !== window) return;                 // 只收本页面发的
  if (e.data && e.data.__from === 'inject') {
    console.log('[content 隔离世界] 收到主世界结果:', e.data.payload);
    // 这里就能把结果再转给扩展后台、或写进页面
  }
});

inject.js(会跑在主世界,所以能看到 window.__DATA__):

(function () {
  // 已经在主世界了,直接读页面变量、调页面函数
  const token = window.__DATA__ && window.__DATA__.token;
  const calcResult = typeof window.calc === 'function' ? window.calc(2, 3) : null;

  // 用 postMessage 把结果送回隔离世界(两个世界唯一的"合法"通道之一)
  window.postMessage({ __from: 'inject', payload: { token, calcResult } }, '*');
})();

加载方式:Chrome 打开 chrome://extensions → 开"开发者模式" → "加载已解压的扩展程序" → 选 inject-ext/。刷新 demo 页面,看控制台,你会看到隔离世界收到了 { token: "T-...", calcResult: 2003 }。

这条路发生了什么,一句话:content script 自己够不到主世界的 window,于是它"生"了一段主世界的脚本(inject.js)去帮它拿,再通过 postMessage 把结果递回来。postMessage 是这两个世界之间少数几条能通的路之一。

Manifest V3 其实还提供了 world: "MAIN" 的声明式写法,可以直接让一段 content script 跑在主世界,省掉手动造 <script>。但我更推荐你先把上面这套"隔离世界 → 造 script → postMessage 回来"手动跑通,因为它把两个世界的边界暴露得最清楚;理解了边界,用不用语法糖都行。

四、路线 B:Python 版(CDP / DevTools 协议驱动注入)

第二条路完全不写扩展,而是用 Python 在外面驱动一个浏览器,通过 CDP(Chrome DevTools Protocol,Chrome 调试协议) 把代码打进去。原理是:Chrome 加 --remote-debugging-port 启动后,会开一个 WebSocket,你能对它下命令,包括"在页面里执行这段 JS""每次新文档一加载就先跑这段 JS"。

这里我用 Playwright(它把 CDP 包好了,最省事),先装:

pip install playwright
playwright install chromium

inject_cdp.py:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()

    # 关键 1:在"页面任何脚本之前"注入——对应 CDP 的
    # Page.addScriptToEvaluateOnNewDocument。这就是 Python 侧的 document_start。
    page.add_init_script("""
        window.__injected_early__ = true;
        console.log('[init script] 我在页面脚本之前就跑了');
    """)

    page.goto("http://127.0.0.1:8000/demo.html")

    # 关键 2:直接在主世界里求值——Playwright 的 evaluate 默认就在主世界,
    # 所以能直接读 window.__DATA__、调 window.calc,不需要"两个世界"的桥。
    result = page.evaluate("""() => {
        return {
            token: window.__DATA__ && window.__DATA__.token,
            calcResult: typeof window.calc === 'function' ? window.calc(2, 3) : null
        };
    }""")
    print("[Python/CDP] 拿到:", result)

    browser.close()

跑 python inject_cdp.py,控制台里 Python 会打印 {'token': 'T-...', 'calcResult': 2003}——和路线 A 拿到的是一样的东西。

注意这里的差别:Playwright 的 page.evaluate 默认就跑在主世界,所以它读 window.__DATA__ 一读就有,根本不需要"造 script + postMessage"那一套。因为你是从浏览器外部、以调试者的身份进来的,调试者天然就在主世界的上下文里。

add_init_script 对应 CDP 的 Page.addScriptToEvaluateOnNewDocument,作用是"每次有新文档(包括跳转、iframe)一开始加载,就先跑这段"——这正是 Python 侧的"提前注入时机"。

如果你想更底层、不用 Playwright,直接对 CDP 说话也行(用 websocket-client 连 ws://127.0.0.1:9222/...,发 {"method":"Runtime.evaluate","params":{"expression":"..."}}),但那套样板代码很多,日常没必要,原理和上面一模一样。

五、时机:为什么"在页面脚本之前"这么重要

两条路我都强调了"提前"(A 的 document_start、B 的 add_init_script)。原因是:很多东西你只有在页面自己的脚本运行之前动手,才动得了。

举个能自己验证的例子。把 demo 里的脚本改成"页面一开始就把某个原生函数存一份私有引用":

<script>
  // 页面把原始的 JSON.stringify 存了个私有副本
  const _origStringify = JSON.stringify;
  window.report = function (o) { return _origStringify(o); };
</script>

如果你想 hook JSON.stringify(比如想看看页面都往里传了什么),你必须赶在这段脚本之前替换掉 JSON.stringify,页面存的那份私有副本才会是你的版本。等页面脚本跑完了你再替换,已经晚了——它手里攥着的是原始副本,你怎么改 window.JSON.stringify 都影响不到它。

hook 一个函数、看它的入参出参,通用写法长这样(放在最早的注入点里):

const _origCalc = window.calc;
window.calc = function (...args) {
  console.log('[hook] calc 入参:', args);
  const ret = _origCalc.apply(this, args);
  console.log('[hook] calc 返回:', ret);
  return ret;          // 原样返回,不破坏页面逻辑
};

这段"存原函数 → 换成包一层的新函数 → 里面打印参数再调原函数返回"的套路,是所有函数 hook 的通用骨架,跟你用 A 还是 B 无关。

六、两条路怎么选:对比表

维度 路线 A:JS 扩展注入 路线 B:Python/CDP 注入
写什么 manifest + content.js + inject.js 一个 Python 脚本
落点世界 content 在隔离世界,要手动进主世界 evaluate 默认就在主世界
跨世界拿变量 需要"造 script + postMessage"桥 不需要,直接 evaluate
提前时机 run_at: document_start add_init_script
要不要真人点开浏览器 要(扩展装在你日常浏览器里) 不用,可无头/后台批量跑
适合场景 给自己天天用的页面加长期功能、随手用 自动化测试、批量流程、CI
依赖 只要浏览器 要 Python + Playwright/CDP
调试手感 就在浏览器 DevTools 里调 在 Python 侧看日志

一句话选型:你是想"给一个页面常驻加个功能、自己天天用",选 A;你是想"写个脚本自动化跑一套流程、能无人值守",选 B。 两者拿到的东西是一样的,区别只在"你人在里面(扩展)还是在外面(调试器)"。

七、几个必踩的坑

  • web_accessible_resources 忘了写:路线 A 里 inject.js 加载不出来、控制台报 net::ERR_BLOCKED,八成是这个没声明。
  • postMessage 收到别人的消息:一定要判 e.source !== window 和自己约定的 __from 标记,否则页面里别的 postMessage(很多页面自己也用)会串进来。
  • 注入太晚:想 hook 的目标已经被页面"存副本"存走了。解决办法永远是把注入点提到最前面(document_start / add_init_script)。
  • 改了返回值破坏页面:hook 时如果你不 return 原返回值、或返回了个结构不对的东西,页面逻辑会崩。hook 的默认姿态应该是"只看不改",确实要改再小心改。
  • iframe 里的世界是独立的:一个页面里每个 iframe 都有自己的两个世界,跨 frame 拿不到,要单独对那个 frame 注入。

这就是浏览器脚本注入的全部地基:两个世界 + 注入时机 + hook 骨架。语言只是壳,JS 和 Python 两条路我都给了能跑的最小 demo,你换个页面、把 window.__DATA__/window.calc 换成你真正关心的目标,套路一模一样。先在自己的 demo.html 上把每一步都跑出预期结果,再谈别的。

评论 0

还没有评论
← 全部文章

找站长

站长微信二维码 扫码加微信
售后群二维码 扫码进售后群
站长收款码 建站不易,感谢打赏
微信 18581469390
本站不支持自助充值;需要充值或开通额度,请直接联系站长。
加微信可以找我重置密码、充值、问工具怎么用