前言

今天继续 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.hashinnerHTML、前端路由

反射型 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();
}

?>

代码逻辑拆解

  1. trim() 只会清除 Name 和 Message 两端的空格、换行等空白字符,不会过滤 HTML 标签,也不会阻止 JavaScript 内容进入变量。
  2. stripslashes() 只是去掉反斜杠转义字符,不具备 XSS 防护能力。
  3. mysqli_real_escape_string() 面向的是 SQL 语句上下文,用来避免输入破坏 SQL 字符串结构,它不是 HTML 转义函数
  4. 两个字段最终都被拼接进 SQL 语句,写入 guestbook 表的 commentname 字段。

关键写入语句:

$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 级别做了三类关键防护。

  1. CSRF Token 校验
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

这一步防止外部页面伪造用户提交留言的请求。

但需要注意:CSRF 防护解决的是跨站请求伪造,不是 XSS 的根本修复方案。

  1. Name 和 Message 统一 HTML 编码
$message = htmlspecialchars( $message );
$name    = htmlspecialchars( $name );

这是阻断存储型 XSS 的关键。

htmlspecialchars() 会将 < > & " ' 等特殊字符转换成 HTML 实体。用户提交的 Payload 即使被保存,浏览器读取时也只能把它显示为普通文字,不会再创建真正的 HTML 标签。

  1. 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 最重要的一句话:

不要只看输入提交时有没有弹窗,还要看恶意内容是否被保存、刷新后是否仍在、后续访问者是否会再次触发。


免责声明

  1. 本文所有内容仅用于网络安全技术研究与教学演示,所有测试均在本地授权搭建的 DVWA 靶场环境中进行,旨在帮助读者理解存储型 XSS 漏洞原理与防御机制。

  2. 请勿将本文涉及的技术与方法用于任何未经授权的真实系统测试。任何利用本文内容进行的非法攻击行为,均与作者无关,由使用者自行承担相应法律责任。

  3. 网络安全学习与测试应严格遵守相关法律法规,仅在获得明确授权的前提下开展安全测试。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐