手把手系列:DVWA XSS 三种类型实战 day04(下)
手把手系列:DVWA XSS 三种类型实战 day04(下)
上篇讲的是“xss的基本原理”,这篇进入靶场。
三类各打一遍,每类都从 low 打到 impossible。排骨会先把过滤逻辑摆出来,再说怎么绕,最后看正确写法长什么样,篇幅较长,感谢大家耐心观看。
特别声明:本文所有实验操作均在本地隔离虚拟靶场环境完成,文中技术仅供网络安全学习研究使用。严禁在未获得书面授权的情况下对任何非自有系统实施扫描、渗透、测试等行为,违规操作带来的一切法律责任由行为人自行承担。
一、先把环境弄起来
Kali 重启之后 Docker 不会自己起来,靶场要手动开:
sudo systemctl start docker
docker ps -a # 看 dvwa 容器还在不在
docker start dvwa
docker ps # 确认 8080->80 的映射在
浏览器开 http://10.10.10.128:8080,账号 admin / password。
左侧菜单有三处 XSS,分别是 XSS (Reflected)、XSS (Stored)、XSS (DOM)。难度在 DVWA Security 里切。
嫌重新启动麻烦看这里:
docker update --restart unless-stopped dvwa,加上这条以后开机就自动拉起,省得每次手动敲。
二、反射型 XSS
2.1 low:什么都不防
源码:
header ("X-XSS-Protection: 0");
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
$html .= '<pre>Hello ' . $_GET[ 'name' ] . '</pre>';
}

输入直接拼进 HTML,一个字都没处理。
URL 里塞:
http://10.10.10.128:8080/vulnerabilities/xss_r/?name=<script>alert(1)</script>
或者直接在输入框输:
<script>alert(1)</script>
页面弹出 1。

那个
X-XSS-Protection: 0是 DVWA 自己加的响应头,作用是关掉老浏览器的 XSS 过滤器。不加的话,Chrome 早年那套反射型过滤可能会把请求拦下来,实验就做不顺畅。这个头只是靶场为了方便,真实站点不会这么写。
2.2 medium:只删小写的 <script>
$name = str_replace( '<script>', '', $_GET[ 'name' ] );

str_replace 是区分大小写的字符串替换。它只认那几个字符完全一致的组合,有三条路可以走:
第一条,换大小写:
<ScRiPt>alert(1)</ScRiPt>
HTML 解析标签名不区分大小写,浏览器照样执行。
第二条,嵌套一层让它自己拼出来:
<scr<script>ipt>alert(1)</script>
中间那个 <script> 被删掉之后,剩下的部分正好拼成 <script>。
第三条,不用 script:
<img src=x onerror=alert(1)>
这个最省事,因为 str_replace 的目标里根本没有 img。
而这里构造的含义是: 是图片标签;src 指定图片地址;onerror 是它的事件属性图片加载失败时触发,属性值会被当成 JS 执行。所以当图片取地址为时为空,触发onerror,执行alert(1)。
上篇有讲,这里单独解释下构成
2.3 high:正则拦 script,但只拦 script
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $_GET[ 'name' ] );

比 medium 强的地方有两个:/i 不区分大小写,而且 (.*) 允许中间插东西,所以大小写和嵌套两种绕法都被堵了。
但它拦的仍然只是“名字里含 script 的标签”。
<img src=x onerror=alert(1)>
一样会弹出。
这就是黑名单的根本问题:它列的是已知的坏东西,而浏览器认的可执行入口远不止一个。 上篇列过那张表——img、svg、body、iframe,加上几十个事件属性,永远列不完。
2.4 impossible:开始正确做事
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
$name = htmlspecialchars( $_GET[ 'name' ] );
$html .= "<pre>Hello {$name}</pre>";

htmlspecialchars 把 &、<、>、"、' 转成 HTML 实体。输入 <script> 进去,页面上显示的是字面的 <script> 文本,HTML 里实际是 <script>,浏览器不认成标签。
再试一遍 img 那个 payload,一样只显示文字。
另外还加了 CSRF token——同一个输入点,XSS 和 CSRF 的防护经常一起做。
2.5 四档小结
| 难度 | 过滤方式 | 绕过 |
|---|---|---|
| low | 无 | <script>alert(1)</script> |
| medium | str_replace('<script>','') |
大小写、嵌套、换标签 |
| high | preg_replace 拦 script |
<img src=x onerror=alert(1)> |
| impossible | htmlspecialchars |
打不进 |
三、存储型 XSS
这一类的差别不在过滤强度,在生效方式:payload 进了数据库,之后每次有人打开页面都会执行。
3.1 low:两个字段全裸
$message = stripslashes( $message );
$message = mysqli_real_escape_string(..., $message );
$name = mysqli_real_escape_string(..., $name );

mysqli_real_escape_string 是防 SQL 注入的,它处理的是引号、反斜杠这些会影响 SQL 语法的字符。它跟 HTML 一点关系没有。
所以留言内容里写 <script>alert(1)</script>,会被原样存进数据库,页面上再原样输出。
在 Message 框里填:
<script>alert(1)</script>
提交之后立刻弹一次。刷新页面,还会再弹。 这就已经不是“点链接才中招”的模式了——任何打开这个留言页的人都会中。
3.2 medium:堵了一个字段,漏了另一个
// message
$message = strip_tags( addslashes( $message ) );
$message = mysqli_real_escape_string(..., $message );
$message = htmlspecialchars( $message );
// name
$name = str_replace( '<script>', '', $name );
$name = mysqli_real_escape_string(..., $name );

对比一下就看出来了:
| 字段 | 处理 | 效果 |
|---|---|---|
| Message | strip_tags + htmlspecialchars |
标签被剥掉,实体化了,打不进 |
| Name | 只 str_replace('<script>','') |
和反射型 medium 一个水平,能打 |
strip_tags 把 HTML 标签整个删掉,htmlspecialchars 再转一次,这一栏基本没戏。
那就从 name 打。Name 框里填:
<img src=x onerror=alert(1)>
提交后弹窗,刷新再弹。
这里有个很典型的开发心态:页面上“主要的那一栏”被重点保护了,“顺手带的小字段”就随便处理一下。 而 XSS 不挑字段大小——只要能落到页面上,就有用。
真实业务里这类“小字段”特别多:昵称、签名、备注、标签、工单标题、头像 alt等等这些。审的时候优先看这些。
3.3 high:同一个正则,同样的漏
// message:还是 strip_tags + htmlspecialchars
// name:
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );

message 那栏依旧堵死,name 那栏换成了和反射型 high 一模一样的正则。
绕法也一样,Name 框填:
<img src=x onerror=alert(1)>
3.4 impossible:两个字段都编码了
checkToken( ... );
$message = stripslashes( $message );
$message = mysqli_real_escape_string(..., $message );
$message = htmlspecialchars( $message );
$name = stripslashes( $name );
$name = mysqli_real_escape_string(..., $name );
$name = htmlspecialchars( $name );
$data = $db->prepare( 'INSERT INTO guestbook ( comment, name ) VALUES ( :message, :name );' );
$data->bindParam( ':message', $message, PDO::PARAM_STR );
$data->bindParam( ':name', $name, PDO::PARAM_STR );
$data->execute();

两个字段都做了 htmlspecialchars,SQL 那边换成了参数化。两层各管一层,互不替代。
3.5 这一类为什么最危险
反射型要受害者点一个链接。存储型不用。
| 反射型 | 存储型 | |
|---|---|---|
| 谁会被影响 | 点了链接的那一个人 | 所有打开这个页面的人 |
| 攻击者需要做什么 | 发链接、等人点 | 留一次言,剩下不用管 |
| 能不能自我传播 | 不能 | 能,配合“自动提交”就是蠕虫 |
上篇提过 MySpace 的 Samy 蠕虫,原理就是这个组合:存储型 XSS 让每个访问者执行攻击者的 JS,JS 再替访问者发一条新留言。
四、DOM 型 XSS
这一关的服务端代码短得离谱:
// low.php
<?php
# No protections, anything goes
?>

因为漏洞压根不在服务端。真正的代码在 index.php 的前端脚本里:
if (document.location.href.indexOf("default=") >= 0) {
var lang = document.location.href.substring(document.location.href.indexOf("default=")+8);
document.write("<option value='" + lang + "'>" + decodeURI(lang) + "</option>");
document.write("<option value='' disabled='disabled'>----</option>");
}

拆开看两件事:
- 数据源(source):
document.location.href,也就是地址栏 - 危险函数(sink):
document.write,把字符串当 HTML 写进文档
lang 是从 URL 里 default= 之后截出来的,没做任何检查就进了 document.write。
4.1 low:地址栏里直接写
http://10.10.10.128:8080/vulnerabilities/xss_d/?default=<script>alert(1)</script>
弹窗。
注意这段 payload 从头到尾没经过服务端处理。服务端只是把一段固定不变的 JS 返回给浏览器,内容由浏览器自己从地址栏取。
4.2 medium:只拦 script 字面量
if (stripos ($default, "<script") !== false) {
header ("location: ?default=English");
exit;
}

stripos 不区分大小写,所以 <ScRiPt 也被拦。但它只找 <script 这个前缀。
换标签:
http://10.10.10.128:8080/vulnerabilities/xss_d/?default=<img src=x onerror=alert(1)>
弹。
这里有个细节值得注意:payload 里的 < 和 > 在地址栏里会被浏览器编码成 %3C、%3E。 而服务端拿到的 $_GET['default'] 是解码后的原文,所以 stripos 能看见 <img。等到前端 decodeURI(lang) 再解一次,它又变回真标签,浏览器执行。
编码在哪一层解,决定了过滤能不能看见内容。这是绕 XSS 过滤时最常被忽略的一层。
4.3 high:服务端白名单
switch ($_GET['default']) {
case "French":
case "English":
case "German":
case "Spanish":
# ok
break;
default:
header ("location: ?default=English");
exit;
}

只放行四种语言,其他一律重定向回 English。
这一档打不进去。 白名单的作用就在这——它不判断“你输入的东西坏不坏”,它只判断“是不是我允许的那几个”。
4.4 impossible:不解码了
这一档最容易看走眼。impossible.php 里只有一行注释:
# Don't need to do anything, protection handled on the client side

服务端确实什么都没做。防护在 index.php 里:
# For the impossible level, don't decode the querystring
$decodeURI = "decodeURI";
if ($vulnerabilityFile == 'impossible.php') {
$decodeURI = "";
}
也就是把 decodeURI(lang) 换成 (lang),不再对 URL 里的内容做解码。
结果就是:你塞进去的 %3Cscript%3E 会以编码形态留在页面上,显示成字面的字符,浏览器不会把它当标签。low 那条路能成,靠的正是 decodeURI 把 %3C 还原成 <。
这一档的说服力在于它换了个思路:不去“过滤危险字符”,而是不让数据被还原成代码。上篇讲的“输出处编码”,本质是同一件事。
4.5 这一类的排查要点
DOM 型的 payload 可能完全不出现在 HTTP 响应里。所以:
| 做法 | 为什么 |
|---|---|
| 先看前端源码 | 漏洞在 JS 里,不在 PHP 里 |
| 找 source 和 sink | location.*、document.referrer 是常见 source;innerHTML、document.write、eval 是常见 sink |
关注 # 后面的部分 |
fragment 不发服务器,日志和 WAF 都看不到 |
| 抓包没看到 payload 别急着否定 | 它可能在浏览器里才拼出来 |
五、三处对照
| 反射型 | 存储型 | DOM 型 | |
|---|---|---|---|
| low 怎么打 | <script>alert(1)</script> |
message 或 name 都行 | 地址栏 ?default=<script>... |
| medium 怎么打 | 大小写 / 嵌套 / 换标签 | 只能打 name(message 被 htmlspecialchars) |
?default=<img src=x onerror=...> |
| high 怎么打 | <img src=x onerror=...> |
同样只能打 name | 打不进(服务端白名单) |
| impossible | htmlspecialchars |
双字段都编码 + 参数化 | 不解码 |
| 漏洞位置 | 服务端拼接 | 服务端存储 + 拼接 | 前端 DOM 操作 |
六、回头再看防御
四档打下来,正确的做法其实就两条主线:
第一条,输出时按上下文编码。
// 输出到 HTML 文本
echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
落到属性里的时候,还要给属性加引号:
<!-- 有引号,payload 得先闭合引号才能跑 -->
<input value="<?php echo htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); ?>">
<!-- 没引号,一个空格就能断出属性,防御弱一截 -->
<input value=<?php echo htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); ?>>
第二条,不确定的地方不要自己净化,用白名单。
DVWA 的 high 已经演示了黑名单的极限:正则写得再复杂,也只是把“已知的坏标签”列出来。真正可靠的是只允许已知的好东西——只放行四种语言值,或者富文本场景只放行指定标签和属性(用 DOMPurify 这类成熟库)。
配套的几层,跟上篇一致:
| 措施 | 在这一关里对应什么 |
|---|---|
| 输出编码 | impossible 的 htmlspecialchars |
| 参数化 | 存储型 impossible 的 PDO::prepare |
| CSRF token | impossible 每个页面都加了 checkToken |
| 不还原不可信数据 | DOM 型 impossible 的去掉 decodeURI |
| CSP | 靶场没配,真实站点该配 script-src |
七、这一轮遇到的问题
| 现象 | 原因 |
|---|---|
| 反射型 low 的 script 没执行 | 浏览器/插件拦了。DVWA 已经发 X-XSS-Protection: 0,还是不行就换 <img onerror> 或看控制台报错 |
medium 的 <ScRiPt> 没弹 |
str_replace 只认小写,<ScRiPt> 能过;浏览器认标签名不区分大小写,本该执行。没弹就先看 <> 有没有被上游吞掉,或者看控制台报错 |
| DOM 型抓包看不到 payload | fragment 和前端截取都不经过服务器,属正常 |
| 存储型刷新后重复弹窗 | 这是它和反射型最大的区别,不是 bug |
?default= 后面带 & 就断 |
地址栏里 & 是参数分隔符,payload 要 URL 编码 |
八、小结
三类 XSS 打下来,共同的规律只有一句:数据在哪个位置被当成代码,那里就是注入点。
- 服务端拼 HTML → 反射型、存储型
- 前端拼 DOM → DOM 型
过滤的位置决定了绕过的方式,而黑名单永远差一个标签。真正管用的还是那两条:输出处按上下文编码,拿不准就用白名单。
下篇见。
浙公网安备 33010602011771号