ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从DVWA四级别看CSRF攻击原理与Token防御落地

从DVWA四级别看CSRF攻击原理与Token防御落地

在做Web安全测试的这几年里,CSRF一直是我觉得最“借刀杀人”的漏洞类型。它不像SQL注入那样直接拖库,也不像XSS那样在浏览器里跑一段脚本,而是利用浏览器自动携带Cookie的机制,在受害者毫不知情的情况下,用受害者的身份去完成攻击者预设的操作。DVWA(Damn Vulnerable Web Application)作为一套开源的漏洞演示靶场,把CSRF漏洞从低到高划分了四个安全级别,非常适合用来理解这类漏洞的底层机制和利用思路。

这篇文章我会以DVWA的CSRF模块为主线,逐步拆解攻击原理、四种安全级别下的源码逻辑、利用方式以及对应的工程化防御方案。适合刚接触Web安全、正准备做漏洞复现的同学,也适合负责业务系统安全设计、想搞清楚“CSRF Token到底怎么落地”的开发人员。你在看完之后,不仅能在这个靶场里完整复现一遍攻击链路,还会明白为什么现在的Web框架普遍默认加上Token校验。

1. 从一次“无感改密”说起:CSRF的核心原理

1.1 浏览器替你干了“脏活”

CSRF的全称是Cross-Site Request Forgery,中文叫跨站请求伪造。它的根子在于Web应用天然无法区分一个请求到底是不是用户本人的“真实意愿”。

平时我们登录一个网站,服务器会在浏览器里种下Session Cookie。只要浏览器还存在这个Cookie,后续发出的Http请求就会自动把它带上。服务器看到有效Cookie,就认为请求来自已登录用户本人。问题在于:这个“自动带上”的动作,是浏览器层面完成的。也就是说,无论这个请求是用户自己点击按钮发出的,还是加载了一个来自第三方站点的图片触发的,Cookie都一样会被带上。

我举个生活化的例子:你进了一家超市刷脸办了会员卡(相当于登录)。收银台认卡不认人,只要看到你的会员卡号,就自动给你记账。这时候有个不怀好意的人,拿着你的照片做了一张一模一样的卡,趁你不注意塞给收银员说你刚才买了两箱红酒。收银员一看卡号对,直接记账到你名下。CSRF就是这个拿着假卡冒充你的人,而浏览器就是那个不看“人”只看“卡”的收银员。

1.2 一次成功的CSRF攻击需要哪些前提

CSRF攻击要生效,不是简单地让受害者打开一个恶意页面就行,需要同时满足几个条件:

  • 受害者已经登录目标站点,并且Session还处于有效状态。
  • 目标站点的关键操作(比如修改密码、转账)使用的是可预测的GET或POST请求,参数完全公开可构造。
  • 请求发出后,服务器只校验Cookie或Session,没有校验Token、Referer等来源信息。
  • 浏览器允许第三方请求携带目标站点的Cookie(这里就牵扯到Cookie的SameSite属性,后面会细说)。

DVWA里Low级别的改密接口就完全符合以上条件:一个简单的GET请求,参数不加密、不加随机值、不校验来源。攻击者只需要构造一个页面,让受害者访问一下,密码就被悄悄改掉了。

1.3 CSRF和XSS、SSRF到底有什么区别

很多人会把CSRF和XSS、SSRF混在一起。我在带团队做安全评审的时候,经常需要先帮开发同学理清这三者的边界。

XSS(跨站脚本)是攻击者往正常页面里注入了一段恶意的JavaScript,本质是“篡改页面内容”;CSRF是攻击者不碰页面内容,只是借受害者的身份向服务器发请求,本质是“伪造用户操作”;SSRF(服务端请求伪造)是利用服务器作为跳板去请求内网资源,本质是“让服务器替攻击者发起请求”。

一句话总结:XSS攻击的是“浏览器-页面”这条链路,CSRF攻击的是“浏览器-服务器”的身份信任链路,SSRF攻击的是“服务器-内网”的访问控制链路。三者的防御思路也完全不同——XSS靠输出编码和过滤,CSRF靠Token和SameSite,SSRF靠限制目标地址和协议。

理解了这个底层区别,再去DVWA里看CSRF模块,你会发现很多代码逻辑一下子就有了画面感。

2. 先把靶场搭起来:DVWA环境准备与安全级别说明

2.1 环境选型:用现成集成包还是手动部署

DVWA本身是PHP项目,依赖MySQL/MariaDB。我最早是手动在本地搭LNMP,后来换了方式直接用某开源集成环境,五分钟就完事。两者各有取舍:

  • 手动部署的优势是更贴近真实生产环境,适合想顺带练一遍环境搭建的人;劣势是PHP版本、MySQL驱动配置可能踩坑。
  • 集成包的优势是省心,PHP版本、扩展、Web服务全部配好;劣势是目录结构跟自己搭的不一样,代码审计时找路径要适应一下。

我个人建议初学者先用集成包跑通,后续有精力再手动搭一遍。需要注意的是,DVWA目前对PHP 8.x兼容性不算好,很多老版本在PHP 8环境下会直接报错。建议使用PHP 7.4或8.0,配合MySQL 5.7,这个组合我实测下来最稳。

2.2 DVWA安装与初始化

集成包部署完之后,把DVWA源码放到Web根目录,然后进入config/目录,把config.inc.php.dist复制一份改成config.inc.php。打开配置文件,确认数据库的连接信息:

$_DVWA[ 'db_database' ] = 'dvwa'; $_DVWA[ 'db_user' ] = 'root'; $_DVWA[ 'db_password' ] = '你的数据库密码'; $_DVWA[ 'db_port' ] = '3306';

保存之后,浏览器访问http://127.0.0.1/dvwa/,首页有一个Create/Reset Database按钮,点一下就会自动建库、建表、写入初始数据。初始化完成后再登录,默认账号是admin,密码是password。

登录成功后,进入DVWA Security页面,把安全级别设置为Low,点击提交。DVWA还支持通过URL直接指定级别:http://127.0.0.1/dvwa/security.php?security=low,这个机制在切换测试场景时好用。

2.3 快速摸清CSRF模块的业务场景

DVWA的CSRF模块模拟的是一个典型的“修改密码”业务。页面会展示当前登录用户的密码哈希,并要求输入新密码、确认密码,再点提交。正常情况下,用户需要登录才能修改。但漏洞点在于“修改”这个行为本身没有做足够的来源校验——在Low级别下,任何知道接口格式的人都可以直接构造URL,让受害者在不知情时完成密码修改。

这个场景选得非常典型。因为修改密码的危害是灾难性的:攻击者一旦把受害者密码改成自己知道的,就等于完全接管了账号。拿到账号后可以查看隐私、执行更多操作,甚至横向提权。这也是为什么现在各大平台修改密码都需要“先验证旧密码”或“短信验证码”——本质上就是在CSRF之上又加了一道业务侧的防线。

3. 四个安全级别的源码分析与利用实录

3.1 Low级别:没有一丝防范的GET型CSRF

先看Low级别的服务端核心源码:

<?php if( isset( $_GET[ 'Change' ] ) ) { $pass_new = $_GET[ 'password_new' ]; $pass_conf = $_GET[ 'password_conf' ]; if( $pass_new == $pass_conf ) { $pass_new = mysqli_real_escape_string( $conn, $pass_new ); $pass_new = md5( $pass_new ); $sql = "UPDATE users SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';"; // 执行SQL } } ?>

注意几个信息点。第一,整个接口是GET请求,参数直接暴露在URL里,没有任何随机Token;第二,服务端只校验了两个密码字段是否一致,至于“是谁发的请求”完全不加甄别;第三,密码只做了MD5哈希,没有加盐——这在真实业务中也是禁忌,但作为靶场教学可以理解。

基于这个接口,攻击者可以构造出一个恶意页面。最简单的POC如下:

<html> <body> <img src="http://127.0.0.1/dvwa/vulnerabilities/csrf/?password_new=admin123&password_conf=admin123&Change=Change"> </body> </html>

把这个页面放到攻击者自己的站点上,诱导已登录DVWA的受害者访问。浏览器加载页面时,会默默向127.0.0.1请求这张“图片”,Cookie自动携带。服务端收到请求后,发现Session有效,参数齐全,直接执行UPDATE语句。受害者再次登录时,会发现密码已经变成admin123。

这里有一个容易被忽略的细节:<img src>触发的请求是一次子资源请求。如果目标站点的Cookie设置了SameSite=Lax,现代浏览器在跨站子资源请求场景下不会携带Cookie,POC就会失效。但DVWA默认环境中Cookie是传统会话Cookie,没有设置SameSite属性,所以老版本浏览器和部分配置下都能复现成功。这也是我在近两年的测试中调整POC时最常碰到的变量。

3.2 Medium级别:Referer校验与域名欺骗绕过

Medium级别的源码加了一个Referer校验:

<?php if( stripos( $_SERVER[ 'HTTP_REFERER' ] , $_SERVER[ 'SERVER_NAME' ] ) !== false ) { // 执行业务逻辑,修改密码 } ?>

这段代码的逻辑是:检查HTTP请求头里的Referer字段是否包含服务器名(比如127.0.0.1)。如果包含,认为是同源请求,允许执行;否则拒绝。从设计思路上讲,这是在尝试做“来源校验”,但实现方式存在明显漏洞。

仔细看stripos函数:它做的是“子串包含”判断,而不是“域名精确相等”判断。这意味着只要Referer字符串里包含127.0.0.1这一段,就能通过校验,并不要求Referer真的是目标站点的页面。

攻击者的绕过思路是:找一个域名,让它的前缀里带上目标服务器的名字。比如攻击者控制了evil.com这个域名,并且通过通配DNS解析,让127.0.0.1.evil.com也指向自己的服务器。那么攻击者把恶意页面放在http://127.0.0.1.evil.com/poc.html,受害者在浏览这个页面时,浏览器发出的自动请求头里Referer的值就是:

http://127.0.0.1.evil.com/poc.html

这段字符串中包含了127.0.0.1,于是stripos返回非false,校验通过。代码被绕过了。

另外一个实操中常见的变通方案是临时把POC页面丢到目标主机上。假如攻击者通过其他途径(比如文件上传漏洞)在某主机上放置了一个HTML文件,访问路径是http://127.0.0.1/upload/poc.html,那么从该页面发出的请求Referer天然就是http://127.0.0.1/upload/poc.html,包含服务器名,自然通过校验。这一类“Referer包含”的判断思路在真实系统中很常见,也是我评审代码时看到就想提醒对方换掉的重点模式。

3.3 High级别:Token校验打破“无脑请求”

High级别的源码引入了Anti-CSRF Token机制。页面在渲染表单时,后端会生成一个随机的Token,存储到Session里,同时作为隐藏字段输出到HTML:

<?php $token = $_SESSION[ 'token' ]; // 表单里输出 <input type='hidden' name='token' value='$token'> // 提交时校验 if( $_POST[ 'token' ] != $_SESSION[ 'token' ] ) { // 校验失败,拒绝执行 } ?>

攻击者的POC面临一个核心难题:请求里必须携带一个有效的、和Session中一致的Token。而由于浏览器的同源策略(Same-Origin Policy),攻击者读取不到目标站点页面HTML里的Token值,天然无法在跨域场景下预知Token。如果不能提前获取Token,就算把密码改写的请求发送过去,服务端也会因为Token不匹配而拒绝。

所以High级别下,单纯的CSRF攻击基本失效。想要绕过,攻击者必须找到一种方式先拿到Token,常见场景包括:

  • 目标页面存在XSS漏洞,可以借助XSS在目标站点的同源环境下读取Token,再构造带Token的请求。
  • 目标接口存在CORS配置错误,允许指定来源跨域读取响应。
  • Token强度不足,比如使用固定值、可预测的时间戳序列、或者复用同一Token,导致攻击者可以猜测。

这里也引出一个很关键的观点:CSRF Token并不是绝对安全,它的强度取决于随机数质量、是否与Session唯一绑定、是否一次性消费。真实的Web框架(比如某PHP框架的CSRF组件)默认会在每次请求后轮换Token,同时结合Cookie指纹,安全性会强很多。

3.4 Impossible级别:把业务校验变成“多重保险”

最后看DVWA里定义的Impossible级别:

<?php // 1. 先校验旧密码 if( $pass_old != $current_password ) { // 错误:旧密码不正确 } // 2. 再校验Token if( $_POST[ 'token' ] != $_SESSION[ 'token' ] ) { // 错误:Token不匹配 } // 3. 校验通过后,更新密码并重新生成Token ?>

这个级别的设计思路值得每一位开发者学习:它不依赖单一防线,而是把“安全”拆成多层校验。第一层,要求用户输入当前密码——这一信息只有用户自己知道,攻击者无法在受害者的浏览器环境中替他盲猜;第二层,校验CSRF Token——阻止跨站伪造请求;第三层,更新密码成功后立即轮换会话Token,缩小Token泄漏的窗口期。

从这四档安全级别里,可以提炼出一条清晰的安全设计演进路径:从“完全不设防”到“只认来源不认真伪”再到“用随机数标识用户意愿”,最后到“多个独立校验因子叠加”。这也是我在给团队做安全培训时,会专门拿出来的案例——安全不是某一个“银弹”特性,而是层层防御。

4. 从靶场走到生产:CSRF防御的工程化方案

4.1 从“加一个Token”到“正确使用Token”

很多团队也知道要加CSRF Token,但在落地时容易犯两个错误。

第一个错误:Token被全局复用。如果同一个Token在整段会话内保持不变,攻击者只要通过某一种方式拿到一次Token(比如抓包、日志泄露),就能在有效期内反复构造攻击请求。正确的做法是让CSRF Token绑定当前Session ID,并在每次敏感操作成功后强制轮换。

第二个错误:把Token放在URL里。部分早期框架会把Token作为?csrf_token=xxx追加在链接上,这会导致Token出现在浏览器历史、服务器访问日志、代理日志中,形成二次泄露风险。正确做法是放在请求头(比如自定义头)或POST表单体里,避免在URL中传递。

一个工程化的Token校验流程通常长这样:

  • 用户登录成功后,服务端生成随机Token存入Session。
  • 渲染表单时,把Token同时写进隐藏字段和Response Header。
  • 提交请求时,框架拦截器取出Session里的Token,与请求携带的Token做hash_equals恒定时间比较。
  • 校验成功后立即刷新Session里的Token,旧的立即失效。

4.2 SameSite Cookie:一道被低估的前置防线

前文提到,Cookie的SameSite属性直接影响CSRF攻击能否生效。这个属性在Chrome 80之后默认值变成了Lax,从浏览器层面就给跨站请求上了一道保险。

Lax模式下,跨站场景中只有“顶层导航”类请求(比如用户点击链接跳转、window.open打开新标签)会携带Cookie;而<img>、<iframe>、fetch这些子资源或异步请求不会携带。这就直接砍掉了大多数传统CSRF POC的生效路径。

Strict模式更严格,所有跨站场景都不带Cookie,副作用是用户体验会略有下降——比如从第三方站点点链接跳回来,登录态可能短暂丢失;None模式配合Secure属性则完全放开跨站携带,适用于确实需要跨站传Cookie的场景。在业务上,建议优先用Lax,高安全场景用Strict。这是成本最低、效果明显的一层防御,也是我在所有新项目中会默认加上的配置。

4.3 业务侧增加“用户意愿”校验

CSRF的本质是“用户无意中发起了请求”,所以业务侧如果能增加一个“用户确实想做这件事”的校验,效果会非常好。

典型的做法是:修改密码必须输入旧密码;手机换绑必须输入短信验证码;大额转账必须二次确认并输入支付密码;敏感操作通过WebAuthn或App扫码确认。这些措施在交互上增加了一步,但相当于把“请求发出者”从“浏览器自动行为”变为“用户主动参与的行为”。这一点,任何纯粹的技术方案都无法替代。

我在审计现有系统时,判断CSRF风险级别会先看两点:第一,请求是否依赖Cookie/Session做身份识别;第二,操作是否包含一个“用户主动输入的、动态的、不可预测的信息”。如果两点都不满足,哪怕套了Token,我也会标注为中高风险。

4.4 安全测试时重点关注清单

每次做CSRF专项测试,我都会按这个清单过一遍:

检查项关注点
敏感接口是否校验Token重点看新增、修改、删除、授权类接口
Token是否与Session绑定两个用户会话的Token能否互换使用
Token是否一次性消费同一个Token能否重复提交多次
Token比较是否安全是否使用恒定时间比较函数
Cookie SameSite属性是否显式配置,策略是否与业务匹配
是否存在JSONP/CORS接口可能被用于跨域读取Token或利用信任关系
是否校验Referer/Origin若校验,是否严格、能否被字符串包含绕过

这个清单不复杂,但每次都能帮我在集成测试阶段发现漏网之鱼。

5. 实操中常见的“翻车”场景与排查心得

5.1 问题一:DVWA页面白屏或报错

新手在搭建DVWA时最常见的报错有两个。第一个是数据库连不上,通常是config.inc.php里的数据库密码没改成实际配置,或者MySQL端口不对。第二个是PHP版本不兼容,老版本DVWA遇到PHP 8.2会直接抛出一堆Warning甚至Fatal Error。

解决思路:优先选用集成的PHP 7.4运行环境;如果坚持用更高版本,需要开启兼容模式并安装对应扩展;查看报错日志时,重点关注config目录下文件是否加载成功。我通常的做法是先确认php -m能看到mysqli扩展,再确认config.inc.php里的连接串字段没有拼写错误。

5.2 问题二:CSRF POC页面打不开

如果你已经写好了<img src>型POC,放到本地Web服务器里打开,却发现目标密码没被修改,先别急着怀疑漏洞不存在。绝大多数情况是这三类原因:

  • 当前浏览器对跨站子资源请求不携带Cookie(SameSite或第三方Cookie阻止)。
  • 受害者的Session已经过期或被应用刷新。
  • DVWA配置了登录后跳转,未登录状态下的请求被拦截到登录页。

排查方法:打开浏览器开发者工具,切到Network面板,观察POC页面触发的那条请求的状态码和请求头。如果Sec-Fetch-Site显示cross-site,并且没有Cookie头,基本就是SameSite的问题。这时候可以临时降低测试环境的浏览器Cookie策略,或者改用window.open、<a>链接跳转等顶层导航方式触发请求。

5.3 问题三:Medium级别绕过方案失败

很多人在Medium级别绕Referer校验时,第一反应是把恶意页面放到本地别名路径下,或者直接在浏览器地址栏输入POC链接。前者不一定生效,因为双方是不同端口,Referer字段可能不包含目标服务器名;后者则会让请求走到全新的导航流程,不一定满足模拟作案场景。

从我的经验看,最稳妥的做法是准备一个自定义域名(比如evil.com),利用通配子域名或路径前缀构造一个含有目标服务器名称的访问地址。在测试DVWA时也可以用另一种方式:直接修改本地hosts文件,把某个别名解析到攻击者页面所在的IP,再让这个别名里带上目标机的域名片段。思路正确之后,再去看Referer头是否符合预期即可。

5.4 问题四:手动测试和自动化扫描结果不一致

扫描器报CSRF,手动复现却不生效;或者反过来,扫描器没报,手工构造却成功了。这类不一致在安全测试里太常见了。原因是扫描器通常只做“静态逻辑判断”和“简单回放”,而手工测试可以组合多种条件(比如域名欺骗、同站子域名、Cookie属性、业务前置条件)。

遇到这种情况,我会以手工链路为准,但会把扫描器报的每一项都记录下来,筛选排查。CSRF这类漏洞,测试的重心不应该是“能不能打”,而是“业务设计上允不允许不打”。多从业务需求和边界场景去推演,比闷头构造POC更能发现深层问题。

6. 用DVWA验证之后,顺手做一次“安全加固复盘”

最后再说说我在实际使用DVWA做CSRF测试之后的一些体会,希望对你有实际帮助。

我第一次完整跑完这四档级别的CSRF利用,最大的感受是:一个漏洞的“危害程度”,不完全取决于代码写得好不好,也取决于攻击者能控制的场景有多少。Low级别里,攻击者只需要一个<img>标签;Medium级别里,攻击者必须拥有一个可操控的域名或一个可写文件的位置;High级别里,攻击者必须找到一个能获取Token的同源漏洞。安全等级每提升一档,攻击链的复杂度就会翻几倍。这也是我们在做防御时反复强调纵深和成本控制的原因——只要让攻击者觉得“不划算”,漏洞的价值就降了一半。

如果你刚入门,建议在DVWA里把Low和Medium两个级别完完整整地走一遍,务必自己手动构造POC、自己抓包看Referer和Cookie,别直接抄现成脚本。这个过程能帮你把“浏览器自动携带Cookie”“同源策略”“Referer头”这几个基础概念彻底串起来。等你对攻击链路有了肌肉记忆,再去看High级别的Token校验,就会理解Web框架里那些默认配置到底在防谁。

如果是在真实业务系统里做防御,我个人的建议是:优先确保每个敏感接口都有强随机且一次性消费的Token,同时把SameSite设为Lax,再叠加一层业务侧的“用户主动确认”校验。三层都做齐了,CSRF基本可以从你的风险清单里划掉了。剩下的精力,可以投入到XSS和访问控制这类更容易被利用的问题上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进