Manifest V3 实战:从补天网站逆向到 Chrome 扩展开发全记录

0 阅读15分钟

Manifest V3 实战:从补天网站逆向到 Chrome 扩展开发全记录

本文将完整记录如何从零逆向补天漏洞赏金平台的 API,并基于 Manifest V3 开发一款实用的 Chrome 扩展——补天SRC助手。涵盖接口抓包分析、MV3 架构设计、Content Script 双 World 通信、智能推荐算法等核心环节,附完整源码。


一、为什么要做这个扩展?

补天(butian.net)是国内主流的漏洞赏金平台,白帽子们每天都要在上面浏览 SRC 列表、比较奖金范围、查看新入驻企业。但原版网站存在几个痛点:没有跨维度的排序筛选,没有"性价比"维度的推荐,每次都要打开完整页面才能获取信息。我希望做一个浏览器扩展,在工具栏弹窗里一键聚合所有关键数据,同时顺带记录自己在补天上的操作轨迹,方便复盘报名流程。

这个需求本身不复杂,但在 Manifest V3 的限制下,每一步都踩了不少坑。下面从逆向分析开始,一步步还原整个开发过程。


二、逆向补天 API:抓包与接口分析

2.1 打开 DevTools,观察网络请求

打开 https://www.butian.net/Reward/plan(奖励计划页面),在 Network 面板中筛选 XHR 请求,能看到几个关键接口:

第一个是 平台统计数据接口,地址是 GET /Rank/getNumbers?ajax=1,返回结构如下:

{
  "status": 1,
  "data": {
    "loo": 358921,
    "reward": 289000000,
    "whitehat": 52130,
    "company": 1247
  }
}

字段含义一目了然:累计漏洞数、奖励总额(单位:元)、白帽子数量、入驻企业数。注意 ajax=1 这个查询参数,补天用它来区分页面请求和 AJAX 数据请求。

第二个是 奖励企业列表,地址是 POST /Reward/corps,请求体是 name=&sort=2,表示不按名称筛选、按默认排序。返回一个 data.list 数组,每个元素包含 company_idcompany_namelogomax_rewardmin_rewardservice_statuschange_timeintroduce 等字段。

第三个是 新入驻企业列表,地址是 POST /Reward/pub,请求体是 name=&p=1。返回结构类似,但字段稍有不同,logo 字段叫 avatar

2.2 请求特征分析

观察请求头,有几个关键点。所有 API 请求都带有 X-Requested-With: XMLHttpRequest 头,这是补天后端判断是否为 AJAX 请求的依据。POST 请求的 Content-Type 是 application/x-www-form-urlencoded,不是 JSON。最重要的是,这些接口依赖 Cookie 中的 Session 信息来判断登录状态——虽然上述三个接口不需要登录也能访问,但后续如果要做报名等操作,Session 是必须的。

这就引出了 MV3 架构中最核心的设计决策:如何在扩展中发起带正确 Cookie 的请求?


三、Manifest V3 的困境与架构设计

3.1 MV3 vs MV2:为什么不能直接在 Background 里 fetch?

在 Manifest V2 时代,Background Page 是一个持久化的页面环境,可以直接发起 fetch 请求,配合 webRequest API 修改请求头,甚至注入 Cookie。但在 MV3 中,Background 被替换为 Service Worker,它运行在独立的上下文中,不携带任何网站的 Cookie

这意味着在 Service Worker 里直接请求 https://www.butian.net/Rank/getNumbers?ajax=1,拿到的要么是未登录状态的数据,要么直接被后端拒绝。同时 MV3 移除了 webRequestBlocking 能力(除了企业策略部署的扩展),无法在请求级别动态注入 Cookie。

3.2 架构方案:Content Script 代理模式

最终采用的方案是让 Content Script 作为 API 代理。思路如下:

Popup (前端 UI)
    ↓ chrome.runtime.sendMessage
Service Worker (调度中心/缓存层)
    ↓ chrome.tabs.sendMessage
Content Script (运行在 butian.net 页面上下文)
    ↓ fetch (same-origin, 自动携带 Cookie)
补天 API 服务器

Content Script 注入到 https://www.butian.net/* 页面中,它执行的 fetch 请求天然属于同源请求,浏览器会自动附加该域名下的所有 Cookie。Service Worker 负责调度和缓存,Popup 只负责 UI 渲染。这种三层架构在 MV3 中非常常见,也是目前绕过 Cookie 限制的标准做法。

但这里有一个前提条件:必须有一个打开着的补天标签页。如果用户没有打开补天网站怎么办?我在 Service Worker 中加入了自动创建后台标签页的逻辑,这个后面详细展开。

3.3 manifest.json 配置详解

{
  "manifest_version": 3,
  "name": "补天SRC助手",
  "version": "1.0.0",
  "permissions": ["storage", "tabs"],
  "host_permissions": [
    "https://www.butian.net/*",
    "https://oss-yg-cztt.yun.qianxin.com/*"
  ],
  "content_scripts": [
    {
      "matches": ["https://www.butian.net/*"],
      "js": ["content/api-bridge.js"],
      "run_at": "document_start"
    },
    {
      "matches": ["https://www.butian.net/*"],
      "js": ["content/page-tracker.js"],
      "run_at": "document_start",
      "world": "MAIN"
    }
  ],
  "background": {
    "service_worker": "background/service-worker.js"
  },
  "action": {
    "default_popup": "popup/popup.html"
  }
}

几个值得注意的配置:

permissions 只申请了 storagetabsstorage 用于本地缓存,tabs 用于查询和创建标签页。没有申请 cookieswebRequest,因为我们根本不需要——Cookie 由 Content Script 的同源 fetch 自动处理。

两个 Content Script 使用了不同的 worldapi-bridge.js 运行在默认的 ISOLATED world(隔离环境),它可以与 Service Worker 通过 chrome.runtime 通信。page-tracker.js 运行在 MAIN world(页面上下文),它能拦截页面原生的 XMLHttpRequestfetchpushState 等方法。这是 MV3 引入的一个非常强大的特性,后面会详细讲解。

host_permissions 包含了补天的 CDN 域名。企业 logo 图片托管在 oss-yg-cztt.yun.qianxin.com 上,需要这个权限才能在扩展中正常加载。


四、Service Worker:调度中心与缓存层

4.1 缓存设计

Service Worker 在 MV3 中有一个重要特性:它不是持久化运行的。空闲一段时间后会被浏览器终止,下次收到消息时重新启动。这意味着内存中的变量不可靠,必须用持久化存储来做缓存。

我选择了 chrome.storage.local,配合一个简单的 TTL 机制:

const CACHE_TTL = 5 * 60 * 1000; // 5分钟

async function getCachedData(key) {
  return new Promise((resolve) => {
    chrome.storage.local.get([key, `${key}_ts`], (result) => {
      const data = result[key];
      const ts = result[`${key}_ts`];
      if (data && ts && Date.now() - ts < CACHE_TTL) {
        resolve(data);
      } else {
        resolve(null);
      }
    });
  });
}

async function setCachedData(key, data) {
  return new Promise((resolve) => {
    chrome.storage.local.set(
      { [key]: data, [`${key}_ts`]: Date.now() },
      resolve
    );
  });
}

每次读取时检查时间戳,超过 5 分钟就视为过期。这比使用 chrome.alarms 定时清理要简单得多,而且对于这种信息聚合类场景,5 分钟的缓存足够了。

4.2 自动寻找或创建补天标签页

这是 Service Worker 中最关键的一段逻辑。当 Popup 请求数据时,我们需要找到一个运行着补天页面的标签页来执行 API 请求:

async function findButianTab() {
  const tabs = await chrome.tabs.query({ url: 'https://www.butian.net/*' });
  return tabs.length > 0 ? tabs[0] : null;
}

async function ensureButianTab() {
  let tab = await findButianTab();
  if (tab) return tab;

  // 没有打开的补天标签页,在后台创建一个
  tab = await chrome.tabs.create({
    url: 'https://www.butian.net/Reward/plan',
    active: false,
  });

  // 等待页面加载完成,Content Script 注入后才能通信
  await new Promise((resolve) => {
    const listener = (tabId, info) => {
      if (tabId === tab.id && info.status === 'complete') {
        chrome.tabs.onUpdated.removeListener(listener);
        resolve();
      }
    };
    chrome.tabs.onUpdated.addListener(listener);
    setTimeout(() => {
      chrome.tabs.onUpdated.removeListener(listener);
      resolve();
    }, 10000);
  });

  return tab;
}

active: false 确保新标签页在后台打开,不会打断用户当前的工作。10 秒的超时保护避免因网络问题导致 Promise 永远挂起。

4.3 失败重试机制

Content Script 有一个容易被忽略的问题:当扩展重新加载(开发时常见)或更新后,已打开页面中注入的 Content Script 会失效,因为它们绑定的是旧的扩展实例。此时 chrome.tabs.sendMessage 会报错 “Could not establish connection”。

解决方案是在首次通信失败时,刷新标签页后重试:

async function fetchViaContentScript(endpoint, method, data, retry = true) {
  const tab = await ensureButianTab();
  const url = endpoint.startsWith('http') ? endpoint : `${API_BASE}${endpoint}`;

  try {
    return await sendToTab(tab.id, {
      type: 'API_REQUEST', url, method: method || 'GET', data
    });
  } catch (err) {
    if (retry) {
      await chrome.tabs.reload(tab.id);
      await waitForTabLoad(tab.id);
      return fetchViaContentScript(endpoint, method, data, false);
    }
    throw err;
  }
}

retry 参数确保最多重试一次,避免无限循环。


五、Content Script 双 World 架构

这是整个项目中最有意思的部分。我使用了两个 Content Script,分别运行在不同的 World 中,各司其职。

5.1 ISOLATED World:api-bridge.js

这个脚本运行在 Chrome 为 Content Script 分配的隔离环境中。它可以访问 chrome.runtime API 与 Service Worker 通信,同时可以执行 fetch 发起同源请求。它是整个数据链路的桥梁:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'API_REQUEST') {
    const { url, method, data } = message;
    const opts = {
      method: method || 'GET',
      headers: { 'X-Requested-With': 'XMLHttpRequest' },
      credentials: 'same-origin',
    };
    if (data && method === 'POST') {
      opts.headers['Content-Type'] = 'application/x-www-form-urlencoded';
      opts.body = new URLSearchParams(data).toString();
    }
    fetch(url, opts)
      .then((r) => r.json())
      .then((json) => sendResponse({ data: json }))
      .catch((err) => sendResponse({ error: err.message }));
    return true; // 保持消息通道开放,等待异步 sendResponse
  }
});

注意几个细节。credentials: 'same-origin' 确保 fetch 携带 Cookie。X-Requested-With: XMLHttpRequest 是补天后端识别 AJAX 请求的标志。return true 是 Chrome 扩展消息系统的约定——如果 sendResponse 会异步调用,必须返回 true,否则消息通道会在函数返回时立即关闭。

5.2 MAIN World:page-tracker.js

这个脚本运行在页面自身的 JavaScript 上下文中,与页面代码共享全局对象。这意味着它可以直接修改 window.fetchXMLHttpRequest.prototypehistory.pushState 等原生 API。

为什么需要这个能力?因为我想记录用户在补天网站上的所有操作轨迹——包括页面跳转、SPA 路由变化、AJAX 请求等。在 ISOLATED World 中,你无法拦截页面代码发起的 XHR,因为两个 World 的全局对象是隔离的。

(function () {
  var CHANNEL = 'BT_TRACKER';

  function send(type, data) {
    window.postMessage({ channel: CHANNEL, type: type, data: data }, '*');
  }

  // 拦截 pushState
  var origPush = history.pushState;
  history.pushState = function () {
    origPush.apply(this, arguments);
    var newUrl = location.href;
    if (newUrl !== lastUrl) {
      lastUrl = newUrl;
      send('url', { url: newUrl, trigger: 'pushState' });
    }
  };

  // 拦截 XMLHttpRequest
  var origOpen = XMLHttpRequest.prototype.open;
  var origSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method, url) {
    this._btMethod = method;
    this._btUrl = url;
    return origOpen.apply(this, arguments);
  };

  XMLHttpRequest.prototype.send = function (body) {
    var self = this;
    this.addEventListener('load', function () {
      // 过滤静态资源,只记录 API 请求
      if (isPOST || isApiUrl(fullUrl)) {
        send('api', {
          url: fullUrl,
          trigger: 'XHR-' + method,
          body: body ? String(body).substring(0, 300) : '',
          response: self.responseText.substring(0, 300),
        });
      }
    });
    return origSend.apply(this, arguments);
  };

  // 拦截 fetch(类似逻辑,略)
})();

5.3 两个 World 如何通信?

MAIN World 的脚本无法访问 chrome.runtime,ISOLATED World 的脚本无法直接读取 MAIN World 的数据。解决方案是使用 window.postMessage 作为桥梁:

MAIN World (page-tracker.js)
    ↓ window.postMessage({ channel: 'BT_TRACKER', data: ... })
ISOLATED World (api-bridge.js)
    ↑ window.addEventListener('message', handler)
    ↓ chrome.storage.local.set(...)
Service Worker / Popup

api-bridge.js 监听 message 事件,通过 channel 字段过滤出来自 page-tracker.js 的消息,然后将数据存入 chrome.storage.local。这样 Popup 就能读取完整的操作历史了。

这里有一个安全考虑:postMessage 是全局的,页面上任何脚本都可以发送消息。因此在接收端做了 event.source !== window 的检查,只处理同一窗口发来的消息,并通过 channel 字段做了二次验证。


六、智能推荐算法

奖励列表本身只是原始数据的展示,我想做一个更实用的"智能推荐"功能,帮白帽子快速找到性价比最高的 SRC。

6.1 评分模型

综合评分由四个维度加权构成:

function calcScore(item, allItems) {
  // 最高奖金得分 (权重 40%) — 归一化到 0-100
  var maxScore = (maxReward / globalMaxReward) * 100;

  // 最低奖金得分 (权重 20%) — 体现企业诚意
  var minScore = (minReward / globalMaxMin) * 100;

  // 服务状态得分 (权重 20%) — 开放=100, 暂停=0
  var statusScore = isOpen ? 100 : 0;

  // 活跃度得分 (权重 20%) — 90天内满分,线性衰减至365天归零
  var activityScore = daysSince <= 90 ? 100 :
    Math.max(0, 100 - ((daysSince - 90) / 275) * 100);

  return maxScore * 0.4 + minScore * 0.2 + statusScore * 0.2 + activityScore * 0.2;
}

为什么最低奖金也纳入评分?因为有些 SRC 的最高奖金看起来很诱人(比如 50 万),但最低奖金只有 100 元,说明大部分漏洞类型的奖金其实很低。最低奖金高的 SRC 通常意味着"保底收入"更可观。

活跃度使用了分段线性衰减:90 天内的企业被认为是活跃的(满分),90-365 天线性下降,超过 1 年直接归零。这反映了一个直觉——长期不更新的 SRC 可能响应慢、审核周期长。

6.2 难度分级与类型分类

为了让筛选更直观,我对企业做了两个维度的标签化处理。

难度分级基于最高奖金额度:入门(≤5000)、中等(≤5万)、困难(≤20万)、专家(>20万)。这不完全等同于技术难度,但从经验来看,高奖金的 SRC 通常资产复杂度更高、安全能力更强。

类型分类基于关键词匹配,对企业名称和简介进行扫描:

var TYPE_RULES = [  { key: 'finance',  label: '金融', keywords: ['银行','金融','保险','证券','支付',...] },
  { key: 'ecommerce',label: '电商', keywords: ['电商','商城','购物','零售',...] },
  { key: 'gaming',   label: '游戏', keywords: ['游戏','电竞','米哈游',...] },
  // ...
];

这种简单的规则引擎在数据量不大时效果不错,匹配顺序也隐含了优先级——如果一个企业同时匹配"金融"和"互联网",它会被归类为"金融",因为金融规则排在前面。


七、Popup UI:暗色主题与交互细节

7.1 暗色主题设计

考虑到安全从业者普遍偏好暗色界面(长时间使用低光照环境),整个 UI 采用了类似 GitHub Dark 的配色方案:

body {
  width: 420px;
  height: 560px;
  background: #0d1117;
  color: #e6edf3;
}

主背景 #0d1117,卡片背景 #161b22,边框 #21262d,高亮蓝 #1890ff,成功绿 #3fb950,警告橙 #f0883e。这套配色经过验证,对比度满足 WCAG AA 标准,长时间阅读不会造成视觉疲劳。

7.2 Tab 切换与懒渲染

四个 Tab(奖励排行、智能推荐、新入驻、报名记录)采用了纯 CSS 的显示/隐藏控制,配合 JavaScript 的按需渲染:

function renderCurrentTab() {
  switch (state.currentTab) {
    case 'reward':     renderRewardList();     break;
    case 'recommend':  renderRecommendList();  break;
    case 'newCompany': renderNewCompanyList();  break;
    case 'urlHistory': renderUrlHistory();      break;
  }
}

切换 Tab 时只渲染当前面板的内容,避免一次性渲染所有列表导致的性能问题。搜索框是全局共享的,输入时会根据当前活动的 Tab 来决定过滤哪个列表。

7.3 搜索防抖

搜索输入使用了 200ms 的防抖,避免每按一个键就重新渲染列表:

var timer = null;
dom.searchInput.addEventListener('input', function () {
  clearTimeout(timer);
  timer = setTimeout(function () {
    state.searchQuery = dom.searchInput.value.trim();
    renderCurrentTab();
  }, 200);
});

200ms 是一个经验值——足够短以保证即时反馈感,足够长以过滤掉连续快速输入。


八、图标生成:纯代码绘制 PNG

为了让项目完全自包含(不依赖任何外部图片资源),我用两种方式实现了图标生成。

8.1 浏览器端:Canvas 绘制

icons/generate-icons.html 使用 Canvas 2D API 绘制一个蓝色圆角矩形背景 + 白色盾牌 + 蓝色对勾的图标,然后通过 canvas.toDataURL('image/png') 导出:

function drawIcon(canvas) {
  const s = canvas.width;
  const ctx = canvas.getContext('2d');

  // 圆角矩形背景
  ctx.beginPath();
  ctx.moveTo(r, 0);
  ctx.lineTo(s - r, 0);
  ctx.quadraticCurveTo(s, 0, s, r);
  // ...
  ctx.fillStyle = '#1890ff';
  ctx.fill();

  // 盾牌路径
  ctx.beginPath();
  ctx.moveTo(cx, top);
  ctx.lineTo(cx - w, top + s * 0.12);
  // ...(贝塞尔曲线构建盾牌轮廓)
  ctx.fillStyle = '#ffffff';
  ctx.fill();

  // 对勾
  ctx.beginPath();
  ctx.strokeStyle = '#1890ff';
  ctx.lineWidth = Math.max(s * 0.08, 1.5);
  ctx.moveTo(cx - s * 0.12, s * 0.45);
  ctx.lineTo(cx - s * 0.02, s * 0.57);
  ctx.lineTo(cx + s * 0.14, s * 0.37);
  ctx.stroke();
}

所有坐标都使用相对比例(如 s * 0.15),确保 16px、48px、128px 三种尺寸下图标都清晰锐利。

8.2 Node.js 端:手工构建 PNG 二进制

icons/gen.js 是一个更极客的方案——直接用 Node.js 逐像素计算颜色,然后手工拼接 PNG 文件的二进制格式(Signature → IHDR → IDAT → IEND),连 canvas 库都不需要安装:

function createPNG(size) {
  const pixels = Buffer.alloc(size * size * 4);

  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      // 判断像素是否在圆角矩形内
      // 判断是否在盾牌区域内
      // 判断是否在对勾线段上(点到线段距离计算)
      // 根据结果设置 RGBA 颜色
    }
  }

  // 手工构建 PNG:签名 + IHDR + 压缩数据(IDAT) + IEND
  const compressed = zlib.deflateSync(rawData);
  return Buffer.concat([signature, chunk('IHDR', ihdr), chunk('IDAT', compressed), chunk('IEND', empty)]);
}

这段代码本身就是一个迷你的 PNG 编码器教程。对勾的绘制使用了点到线段距离公式,当距离小于线宽时就判定为对勾区域——这种逐像素判断的方式在低分辨率(16px)下比矢量绘制更可控。


九、踩坑记录

坑 1:Service Worker 被终止后状态丢失

最初我把缓存数据存在 Service Worker 的全局变量中,结果发现有时候 Popup 拿到的是空数据。原因是 Chrome 会在 Service Worker 空闲约 30 秒后将其终止,全局变量全部丢失。解决方案就是前面提到的 chrome.storage.local 持久化缓存。

坑 2:Content Script 在扩展重载后失效

开发阶段频繁修改代码并重新加载扩展,但已打开的补天页面中的 Content Script 仍然是旧版本。chrome.tabs.sendMessage 会抛出连接错误。解决方案是在首次通信失败时刷新标签页,让新版 Content Script 重新注入。

坑 3:MAIN World 脚本无法使用 chrome API

刚开始我把所有逻辑写在一个 Content Script 里,设置了 "world": "MAIN"。结果发现 chrome.runtime.onMessage 是 undefined——MAIN World 的脚本运行在页面上下文中,没有扩展 API 的访问权限。必须拆成两个脚本,MAIN World 负责拦截,ISOLATED World 负责通信,通过 postMessage 桥接。

坑 4:sendResponse 的异步陷阱

chrome.runtime.onMessage 的回调中,如果要异步调用 sendResponse(比如等待 fetch 完成),必须 return true。否则消息通道会在回调函数同步执行完毕后立即关闭,之后的 sendResponse 调用不会生效,Popup 端会收到 undefined。这是 Chrome 扩展开发中最经典的坑之一。

坑 5:Popup 关闭后 Promise 中断

Popup 的生命周期很短——用户点击其他地方就会关闭。如果此时有正在进行的 API 请求,Promise 会被中断。因此数据加载使用了 Promise.allSettled 而非 Promise.all,确保即使部分请求失败也能展示已获取的数据。


十、项目结构总结

butian-helper/
├── manifest.json                  # MV3 配置,双 Content Script + Service Worker
├── background/
│   └── service-worker.js          # 调度中心:缓存管理 + 标签页管理 + 消息路由
├── content/
│   ├── api-bridge.js              # ISOLATED World:API 代理 + 追踪数据收集
│   └── page-tracker.js            # MAIN World:XHR/fetch/pushState 拦截
├── popup/
│   ├── popup.html                 # 弹窗骨架
│   ├── popup.css                  # 暗色主题样式
│   └── popup.js                   # UI 渲染 + 评分算法 + 交互逻辑
├── icons/
│   ├── gen.js                     # Node.js PNG 生成器
│   └── generate-icons.html        # 浏览器端图标生成
└── README.md

整个项目零外部依赖,纯原生 JavaScript 实现,总代码量约 1200 行。在 MV3 的约束下,通过 Content Script 代理请求、双 World 协作、postMessage 桥接等技巧,完整实现了数据抓取、智能排序、操作追踪等功能。


十一、后续优化方向

当前版本还有几个可以优化的地方。分页加载——目前奖励列表只获取了第一页,如果企业数量超过单页上限,需要实现滚动加载或分页请求。离线模式——当用户没有登录补天或网络不可用时,可以展示上次缓存的数据并标注"可能不是最新"。通知推送——利用 chrome.alarms 定时检查是否有新入驻企业或奖金变动,通过 chrome.notifications 推送桌面通知。导出功能——将筛选后的企业列表导出为 CSV 或 Markdown,方便整理到个人的 SRC 清单中。


如果你也在做 Chrome 扩展开发,特别是需要与目标网站的 Session/Cookie 交互的场景,希望这篇文章中的架构设计和踩坑经验能帮到你。完整源码已在文中给出,可以直接加载使用。

联想截图_20260227221721.jpg