Stored Cross Site Scripting(XSS)存储型跨站脚本漏洞:恶意数据持久化缺陷与 DVWA 分级绕过实战
Stored Cross Site Scripting(XSS)存储型跨站脚本漏洞:恶意数据持久化缺陷与 DVWA 分级绕过实战
前言
今天继续 DVWA Web 漏洞靶场系列,来学习 XSS 漏洞中危害更大、影响更持久的一类经典漏洞——Stored Cross Site Scripting(存储型跨站脚本,简称 Stored XSS)。
如果说 SQL 注入是让攻击者影响数据库查询逻辑,命令注入是让攻击者影响服务器操作系统,那么 XSS 的目标则是——让攻击者控制其他用户浏览器中的页面行为。
反射型 XSS 的恶意内容通常跟着一次请求走:攻击者构造链接,受害者访问链接,服务器把输入反射到当前页面,脚本随即执行。
但存储型 XSS 不一样。
攻击者只需要把恶意内容提交一次,应用程序如果把它保存进数据库、留言板、评论区或用户资料中,之后任何访问相关页面的用户,都可能在浏览器中自动触发这段内容。
轻则弹窗、篡改页面内容,重则可能造成钓鱼、敏感信息读取、账户操作诱导,甚至影响访问后台页面的管理员。
存储型 XSS 的核心原理同样很简单:
Web 应用将用户可控输入保存到持久化存储中,后续又没有进行正确的上下文编码就输出到 HTML 页面,导致浏览器把输入内容当成 HTML 标签或 JavaScript 代码解析执行。
DVWA 的 Stored XSS 靶场正好通过 Low、Medium、High、Impossible 四个安全等级,完整展示了“无过滤保存 → 部分字段过滤 → 正则黑名单 → HTML 实体编码”的防护演进过程。
本文前半部分严格按照黑盒渗透测试思路进行:先看页面、先提交、先验证持久化、先确认哪个字段存在问题;源码为什么会这样,放到每个等级的最后再讲。
1. Stored XSS 核心理论基础
1.1 什么是存储型 XSS?
存储型跨站脚本漏洞(Stored Cross Site Scripting),又称持久型 XSS,对应常见漏洞编号:
CWE-79:Improper Neutralization of Input During Web Page Generation(Web 页面生成过程中对输入未进行正确处理)
存储型 XSS 的典型特征是:
- 攻击者向网站提交恶意内容;
- 恶意内容被保存到数据库、文件或其他持久化位置;
- 页面后续读取这条数据并展示给访问者;
- 浏览器将恶意内容解析为 HTML 或 JavaScript;
- 访问该页面的用户可能触发脚本执行。
以留言板为例,用户输入一条普通留言:
Name:Alice
Message:Hello DVWA
服务端将内容保存,页面随后显示:
Alice:Hello DVWA
这本来是正常功能。
但如果用户提交的不是普通文字,而是一段浏览器可以解析的 HTML 内容,网站又没有在展示时做安全编码,那么这段内容就会跟着留言一起留在系统里,等待后续访问者触发。
1.2 存储型 XSS 的本质
存储型 XSS 的漏洞链路可以概括为:
攻击者可控输入
↓
Name / Message / 评论 / 昵称等字段
↓
服务端未正确过滤或未进行输出编码
↓
恶意内容保存到数据库
↓
页面读取并回显已保存的数据
↓
浏览器将输入当作 HTML / JavaScript 解析
↓
恶意脚本在后续访问者浏览器中执行
最关键的问题不在于“数据库里存了一段 <script>”,而在于:
服务器没有区分“数据库中的普通文本”和“浏览器可执行的 HTML 内容”。
开发者原本只是想展示一条留言,但浏览器最终收到的却可能是一个真正的标签、事件属性或脚本片段。
1.3 存储型 XSS 成立的必要条件
并不是所有留言板都会产生存储型 XSS。通常需要同时满足以下几个条件。
条件一:攻击者能够控制某个可保存字段
常见输入位置包括:
- 留言板的姓名和留言内容;
- 评论区;
- 用户昵称、签名、头像说明;
- 文章标题、工单标题;
- 私信、通知、审核备注;
- 后台日志中可控的字段。
条件二:输入内容会被保存下来
提交后刷新页面、重新访问页面,内容仍然存在,说明数据已经进入数据库或其他持久化存储。
条件三:保存的数据被回显时没有正确编码
数据能保存并不等于一定有 XSS。
真正决定漏洞是否成立的是:页面从数据库读取数据后,是否直接拼接到 HTML 中;如果输出时使用了正确的 HTML 实体编码,浏览器只会将它显示为普通文字。
1.4 Stored XSS、Reflected XSS 与 DOM XSS 的区别
| 类型 | 恶意内容是否保存 | 典型触发方式 | 常见场景 |
|---|---|---|---|
| Reflected XSS(反射型) | 通常不保存 | 用户访问恶意链接或提交恶意请求 | 搜索页、报错页、参数回显页 |
| Stored XSS(存储型) | 保存到数据库或文件 | 用户访问已被污染的页面 | 评论区、留言板、用户资料 |
| DOM XSS | 不一定经过后端 | 前端 JavaScript 直接操作 DOM | location.hash、innerHTML、前端路由 |
反射型 XSS 的流程更像:
恶意请求 → 当前页面回显 → 当前访问者触发
存储型 XSS 的流程则是:
恶意提交 → 数据持久化 → 后续页面回显 → 多个访问者可能触发
这也是为什么存储型 XSS 常常比反射型 XSS 更值得重点关注。
1.5 XSS 可能造成什么危害?
XSS 并不只是一个“弹窗漏洞”。
弹窗只是最直观的验证方式,用于确认浏览器是否已经执行了攻击者控制的 JavaScript。真正的风险取决于页面权限、站点功能、Cookie 属性和访问者身份。
存储型 XSS 可能造成:
- 篡改页面内容,伪造登录框、客服窗口或支付提示;
- 诱导用户输入账号、密码、验证码等敏感信息;
- 读取当前页面中本来只有访问者可见的数据;
- 诱导或以用户身份触发页面内可执行操作;
- 影响管理员访问的后台管理页面;
- 配合钓鱼、CSRF、点击劫持等攻击形成组合攻击链。
需要注意的是,HttpOnly Cookie 可以降低 Cookie 被 JavaScript 直接读取的风险,但它不能修复 XSS 漏洞本身。脚本依旧可以篡改页面、读取 DOM 内容或诱导用户执行操作。
2. DVWA Stored XSS(Low)
2.1 页面黑盒探测
进入 DVWA:
Vulnerabilities → XSS (Stored)

页面是一个典型的 Guestbook 留言板,包含两个输入框:
Name
Message
首先提交正常文本:
Name:Alice
Message:Hello DVWA

提交后,页面会展示刚刚写入的留言。

接下来刷新页面。
如果刷新后刚才的留言仍然存在,说明这条数据已经不只是当前请求的临时回显,而是被服务端保存到了数据库中。
这一步很重要。
反射型 XSS 只需要判断“输入有没有被当前页面反射”;存储型 XSS 还必须确认“输入有没有被保存,并在后续访问中继续出现”。
下一步,需要判断 Name 和 Message 中的内容到底是被当成普通文本显示,还是被浏览器当成 HTML 解析。
2.2 黑盒漏洞利用实操
在本地 DVWA 授权靶场中,先在 Message 中提交一个无害验证 Payload:
<script>alert(document.domain)</script>
Name 中输入普通文本:
Alice
点击提交。
如果浏览器弹出当前站点域名,说明 Message 中的 <script> 标签没有被安全编码,浏览器已经将其当成真正脚本执行。
此时不要只看到一次弹窗就结束。
关闭弹窗后刷新当前页面:
- 刷新后再次弹窗,说明恶意内容已经被保存;
- 重新进入 XSS (Stored) 页面后仍然触发,说明数据具有持久性;
- 换一个浏览器或换一个用户访问留言板,若仍然触发,说明问题可能影响其他访问者。
这就是存储型 XSS 最核心的黑盒特征:
提交一次
↓
数据被保存
↓
每次访问页面都可能再次触发
接着测试 Name 字段。
在 Name 中提交同样的 Payload,Message 中填写普通文本:
<script>alert(document.domain)</script>

Low name field test
如果页面前端限制 Name 的输入长度,导致无法直接输入完整内容,不代表服务端一定限制了长度。

可以通过 Burp Suite 拦截提交请求,在请求体中修改 txtName 参数,再放行请求。例如:
txtName=%3Cscript%3Ealert%28document.domain%29%3C%2Fscript%3E&mtxMessage=%3Cscript%3Ealert%28document.domain%29%3C%2Fscript%3E&btnSign=Sign+Guestbook
前端的 maxlength 只是浏览器界面限制,不能当作服务端安全校验。
如果修改后的 Name 仍能被保存并触发,说明 Name 字段同样存在存储型 XSS 风险。

这里使用burpsuite改完之后发现成功绕过了
2.3 源码审计:无过滤保存到数据库
DVWA Low 级别后端 PHP 源码:
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );
// Sanitize message input
$message = stripslashes( $message );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
// Sanitize name input
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
// Update database
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
//mysql_close();
}
?>
代码逻辑拆解
trim()只会清除 Name 和 Message 两端的空格、换行等空白字符,不会过滤 HTML 标签,也不会阻止 JavaScript 内容进入变量。stripslashes()只是去掉反斜杠转义字符,不具备 XSS 防护能力。mysqli_real_escape_string()面向的是 SQL 语句上下文,用来避免输入破坏 SQL 字符串结构,它不是 HTML 转义函数。- 两个字段最终都被拼接进 SQL 语句,写入
guestbook表的comment和name字段。
关键写入语句:
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
这就是为什么黑盒测试中刷新页面后内容仍然存在:用户输入已经被持久化保存。
漏洞根源概括
Low 级别只进行了 SQL 层面的转义,没有对 Message 和 Name 做 HTML 实体编码。恶意内容写入数据库后,后续页面读取并回显时仍可能以 HTML 语法进入浏览器,最终形成存储型 XSS。
标准修复方式
如果业务只需要显示普通文本,应对输出内容使用 htmlspecialchars():
$message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
这样 < > & " ' 等特殊字符会被转为 HTML 实体,浏览器只会把它们当成普通文字显示。
3. DVWA Stored XSS(Medium)
3.1 页面黑盒探测
切换 DVWA Security 为 Medium 后,先重复 Low 级别的 Message 测试:
<script>alert(document.domain)</script>
提交后可以观察到,Message 中的经典脚本标签通常不再弹窗,而是被删除或以普通文本形式显示。

这说明应用程序已经加入了一层防护。
但这里必须明确:
“一个字段中的某一种 Payload 没有执行”不等于“整个留言板已经修复 XSS”。
因为留言板不只有 Message 一个输入点,Name 字段同样会被页面展示。
所以接下来必须把测试重点转到 Name 字段。
3.2 黑名单绕过思路
先在 Name 中提交最经典的:
<script>alert(document.domain)</script>
直接提交后发现 <script> 标签被删除或无法触发,说明页面很可能对这一种固定脚本标签做了处理。
但黑盒测试不能到这里就停下。
页面前端若限制了 Name 长度,先用 Burp Suite 拦截 POST 请求,将 txtName 参数改为完整 Payload;然后尝试大小写混合写法:
<ScRiPt>alert(document.domain)</ScRiPt>
如果浏览器弹出当前站点域名,说明页面只对完全匹配的小写 <script> 做了防护,大小写变化后过滤就失效。

除了 <script> 标签外,浏览器中还存在带事件属性的 HTML 元素。继续在 Name 中测试:
<img src=1 onerror=alert(document.domain)>
这段内容不依赖 <script> 标签。当图片加载失败时,浏览器会触发 onerror 中的 JavaScript。
它也能够执行,说明当前防护只关注了某个关键词,却没有真正阻止用户输入进入浏览器的 HTML 解析上下文。
这也说明了黑名单方案的根本问题:
需要拦截的不是一个单词,而是浏览器中大量可能进入脚本执行上下文的语法组合。
黑名单很难覆盖完整,漏掉一个入口就可能被绕过。
3.3 源码审计:字段防护不一致与简单字符串替换缺陷
Medium 级别后端 PHP 源码:
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );
// Sanitize message input
$message = strip_tags( addslashes( $message ) );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message );
// Sanitize name input
$name = str_replace( '<script>', '', $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
// Update database
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
//mysql_close();
}
?>
代码逻辑拆解
Medium 级别最大的特点是:Message 和 Name 使用了两套完全不同的防护逻辑。
Message 字段经过:
$message = strip_tags( addslashes( $message ) );
$message = htmlspecialchars( $message );
strip_tags() 会移除 HTML / PHP 标签,htmlspecialchars() 则会把 HTML 特殊字符转换为实体。因此,Message 中的常见 HTML 内容通常只会以普通文本形式保存或显示。
Name 字段却只新增了:
$name = str_replace( '<script>', '', $name );
它只是一个区分大小写的普通字符串替换函数,只会机械删除完整的小写 <script>,不会解析 HTML 标签语义,也不会处理其他标签、事件属性或大小写变化。
防护存在的致命缺陷
str_replace 属于纯文本字面替换,不理解浏览器如何解析 HTML。它的防护思路是“发现一个已知关键词就删除”,而不是“把所有不可信输入变成普通文本”。
所以黑盒阶段出现的大小写混合、事件属性等现象就有了答案:Name 字段没有进行 htmlspecialchars(),依旧可能作为 HTML 内容进入浏览器。
正确修复方案
不要只对 <script> 做替换。Name 与 Message 都应统一进行 HTML 实体编码:
$name = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
$message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4. DVWA Stored XSS(High)
4.1 页面黑盒探测
High 级别通常会使用更严格的规则检测 <script> 标签的变形,防护比 Medium 更复杂。
首先在 Name 中测试:
<script>alert(document.domain)</script>
如果无法执行,再测试大小写混合:
<ScRiPt>alert(document.domain)</ScRiPt>
这两种写法都无法触发,说明当前防护不再只是简单匹配固定的小写字符串,而是开始识别更多 script 标签变形。
但 High 级别依旧有一个核心问题:
它的防护目标仍然是“识别并删除危险标签”,而不是“让用户输入以普通文本方式安全显示”。
如果开发者只是把字符串替换升级成正则表达式,依然无法真正覆盖全部 HTML 解析场景。
4.2 黑名单与正则过滤为什么仍然不可靠?
从黑盒现象看,High 级别对 script 标签的识别比 Medium 更严格。
但浏览器并不是只支持一种脚本执行方式。
继续使用 Burp Suite 修改 txtName 参数,提交:
<img src=1 onerror=alert(document.domain)>
如果页面仍然弹出当前域名,说明过滤规则主要集中在 script 关键字,却没有处理其他带事件属性的 HTML 标签。

这就是正则黑名单的局限性。

开发者可以把 script 的大小写、空格、插入字符等变形考虑进去,但仍然很难覆盖:
- 其他 HTML 标签;
- 各类事件属性;
- 不同输出位置的上下文差异;
- HTML 实体与编码差异;
- 浏览器容错解析行为。
只要最终没有统一进行 HTML 实体编码,攻击面就没有从根本上消失。
4.3 黑盒绕过测试与 Payload
High 级别的黑盒测试重点不再是不断更换 <script> 的大小写,而是确认:
页面是否只过滤了某个关键词,还是已经让所有用户输入失去 HTML 语义。
可以对 Name 字段依次观察以下输入的页面表现:
<script>alert(document.domain)</script>
<ScRiPt>alert(document.domain)</ScRiPt>
<img src=1 onerror=alert(document.domain)>
如果前两种被删除,第三种却可以执行,说明 High 级别只是扩大了脚本标签黑名单的范围,根本问题仍然存在。
而如果三种内容都只是以普通文字显示,例如:
<img src=1 onerror=alert(document.domain)>
并且刷新后也不会执行,才说明输出环节可能已经进行了真正的 HTML 编码。
4.4 源码审计:正则过滤不等于安全编码
High 级别后端 PHP 源码:
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );
// Sanitize message input
$message = strip_tags( addslashes( $message ) );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message );
// Sanitize name input
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
// Update database
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
//mysql_close();
}
?>
代码逻辑拆解
Message 字段的处理与 Medium 一样,最终调用了:
$message = htmlspecialchars( $message );
因此 Message 字段不再是主要的 XSS 入口。
Name 字段则从 Medium 的字符串替换升级为:
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );
正则末尾的 i 表示不区分大小写,它尝试匹配从 < 开始、并按顺序包含 s c r i p t 的内容。
所以像 <script>、<ScRiPt> 这类包含 script 字符序列的标签,都会更容易被识别并删除。
防护存在的致命缺陷
这条正则的核心目标仍然只是 script 关键字。
它并没有把 Name 安全编码为普通文本:
htmlspecialchars( $name ); // High 级别没有这一句
因此,只要输入不依赖 script 这几个字母,却仍能被浏览器解析为可执行 HTML,黑名单就可能失效。
漏洞本质
High 级别从简单黑名单升级成正则黑名单,但仍然没有改变“过滤一部分危险写法,再原样输出”的错误思路。
正则过滤不等于 HTML 安全编码。
5. DVWA Stored XSS(Impossible)
5.1 页面黑盒验证
切换到 Impossible 后,继续在 Message 和 Name 中分别测试:
<script>alert(document.domain)</script>
<img src=1 onerror=alert(document.domain)>
如果页面只是将它们显示为普通文字,没有弹窗,也没有页面结构变化,说明浏览器已经不再把用户输入当成 HTML 标签解析。
刷新页面、重新进入留言板后继续观察。
如果恶意内容依旧存在,但始终只是普通文本,说明数据仍然能保存,真正被阻断的是最后的浏览器解析步骤。
这才是正确的防护效果。
5.2 源码审计
Impossible 级别后端 PHP 源码:
<?php
if( isset( $_POST[ 'btnSign' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );
// Sanitize message input
$message = stripslashes( $message );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message );
// Sanitize name input
$name = stripslashes( $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$name = htmlspecialchars( $name );
// Update database
$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();
}
// Generate Anti-CSRF token
generateSessionToken();
?>
代码逻辑拆解
Impossible 级别做了三类关键防护。
- CSRF Token 校验
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
这一步防止外部页面伪造用户提交留言的请求。
但需要注意:CSRF 防护解决的是跨站请求伪造,不是 XSS 的根本修复方案。
- Name 和 Message 统一 HTML 编码
$message = htmlspecialchars( $message );
$name = htmlspecialchars( $name );
这是阻断存储型 XSS 的关键。
htmlspecialchars() 会将 < > & " ' 等特殊字符转换成 HTML 实体。用户提交的 Payload 即使被保存,浏览器读取时也只能把它显示为普通文字,不会再创建真正的 HTML 标签。
- PDO 预处理语句写入数据库
$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();
PDO 预处理语句用于将 SQL 结构与用户参数分离,主要防御 SQL 注入。
这里要分清:
htmlspecialchars()解决 XSS;- PDO 预处理语句解决 SQL 注入;
- CSRF Token 解决请求伪造。
它们分别对应不同攻击面,不能互相替代。
5.3 Impossible 级别安全总结
Impossible 级别不再尝试猜测攻击者会输入哪个标签、哪个关键字或哪个事件属性,而是直接将所有不可信输入转换为普通文本。
这就是 Low、Medium、High 与 Impossible 最本质的区别:
Low / Medium / High:尝试删除某些危险输入
Impossible:让所有用户输入都失去 HTML 语义
只要输出编码正确,攻击者无论输入 <script>、混合大小写标签,还是事件属性标签,浏览器都只会显示文本,而不会执行。
6. XSS 防护的正确姿势
6.1 第一原则:根据输出上下文进行编码
不同输出位置需要不同的编码策略。
| 输出位置 | 推荐防护方式 |
|---|---|
| HTML 文本节点 | HTML 实体编码,如 htmlspecialchars() |
| HTML 属性值 | 属性编码,并始终使用引号包裹属性值 |
| JavaScript 字符串 | JavaScript 上下文编码,避免直接字符串拼接 |
| URL 参数 | URL 编码,如 urlencode() |
| CSS 上下文 | CSS 编码,尽量避免拼接用户输入 |
必须牢记:
SQL 转义不能替代 HTML 编码,输入过滤也不能替代输出编码。
用户输入可能进入数据库、缓存、模板、日志和多个业务页面。真正靠近风险点的位置,是“输出到浏览器之前”。
6.2 不要使用黑名单修复 XSS
以下写法都不推荐作为 XSS 的修复手段:
str_replace('<script>', '', $input);
preg_replace('/script/i', '', $input);
str_replace('onerror', '', $input);
原因是黑名单无法穷尽浏览器可以解析的所有危险语法。
开发者越试图维护“危险关键字列表”,越容易陷入规则膨胀、漏拦截和误拦截的问题。
6.3 必须支持富文本时,使用白名单净化方案
有些业务确实需要用户提交富文本,例如文章发布、评论编辑器、知识库系统。
此时不能简单使用 htmlspecialchars() 把所有标签都转义,否则格式会全部丢失。
正确思路是:
明确允许哪些标签、哪些属性、哪些协议
↓
删除其他所有内容
↓
使用经过安全验证的 HTML 净化库处理
不要自己用正则表达式“过滤完整 HTML”。
6.4 CSP 可以降低风险,但不能代替修复漏洞
内容安全策略(Content Security Policy,CSP)可以帮助浏览器限制脚本加载和执行来源。
例如,可以限制页面只允许加载本站脚本,禁止内联脚本等。
但必须注意:
CSP 是纵深防御措施,不是 XSS 漏洞的替代修复方案。
正确顺序应该是:
优先修复输出编码问题
↓
再通过 CSP 降低残余风险
↓
配合 HttpOnly、Secure、SameSite Cookie 等安全配置
7. DVWA Stored XSS 四个等级对比
| 安全等级 | Message 字段处理 | Name 字段处理 | 核心问题 |
|---|---|---|---|
| Low | 无 HTML 编码 | 无 HTML 编码 | 恶意内容可保存并在后续页面触发 |
| Medium | strip_tags() + htmlspecialchars() |
仅替换固定 <script> |
字段防护不一致,黑名单可绕过 |
| High | strip_tags() + htmlspecialchars() |
正则匹配 script 关键字 |
更复杂黑名单,仍不等于安全编码 |
| Impossible | htmlspecialchars() |
htmlspecialchars() |
统一输出编码,阻断 HTML 解析 |
从 DVWA 的四个等级可以非常直观地看出防护演进:
无防护
↓
部分字段简单黑名单
↓
部分字段复杂正则黑名单
↓
统一 HTML 实体编码
真正可靠的安全方案,不是把黑名单写得越来越长,而是:
不要让不可信输入以浏览器可执行语法的形式进入页面。
8. 总结
存储型 XSS 的漏洞原理并不复杂:
用户输入被保存到服务端,后续又被回显到页面中,且没有经过正确的输出编码,浏览器就可能把它当成 HTML 或 JavaScript 执行。
DVWA 的不同安全等级分别展示了:
- Low:Name 和 Message 都缺少有效的 HTML 安全处理,恶意内容可以保存并在后续访问中触发;
- Medium:Message 字段进行了处理,但 Name 字段只使用固定字符串黑名单;
- High:Name 字段升级为正则黑名单,仍然无法覆盖全部 HTML 解析场景;
- Impossible:Name 和 Message 都使用
htmlspecialchars()进行 HTML 实体编码,从根源阻断标签解析。
最后记住存储型 XSS 最重要的一句话:
不要只看输入提交时有没有弹窗,还要看恶意内容是否被保存、刷新后是否仍在、后续访问者是否会再次触发。
免责声明
本文所有内容仅用于网络安全技术研究与教学演示,所有测试均在本地授权搭建的 DVWA 靶场环境中进行,旨在帮助读者理解存储型 XSS 漏洞原理与防御机制。
请勿将本文涉及的技术与方法用于任何未经授权的真实系统测试。任何利用本文内容进行的非法攻击行为,均与作者无关,由使用者自行承担相应法律责任。
网络安全学习与测试应严格遵守相关法律法规,仅在获得明确授权的前提下开展安全测试。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)