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_id、company_name、logo、max_reward、min_reward、service_status、change_time、introduce 等字段。
第三个是 新入驻企业列表,地址是 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 只申请了 storage 和 tabs。storage 用于本地缓存,tabs 用于查询和创建标签页。没有申请 cookies 或 webRequest,因为我们根本不需要——Cookie 由 Content Script 的同源 fetch 自动处理。
两个 Content Script 使用了不同的 world。api-bridge.js 运行在默认的 ISOLATED world(隔离环境),它可以与 Service Worker 通过 chrome.runtime 通信。page-tracker.js 运行在 MAIN world(页面上下文),它能拦截页面原生的 XMLHttpRequest、fetch、pushState 等方法。这是 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.fetch、XMLHttpRequest.prototype、history.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 交互的场景,希望这篇文章中的架构设计和踩坑经验能帮到你。完整源码已在文中给出,可以直接加载使用。