存储型与 DOM 型 XSS:一个内容进了数据库,一个服务器从没看见
网络安全实战 90 天 · 第 23 篇 | Week 4 客户端与服务端两条线 | 入门 | 约 2.5 小时
上一篇的反射型有个明显限制:必须骗人点那条链接。今天补上另外两种——存储型把内容写进数据库,谁来看谁中招;DOM 型则连服务器都没参与,前端脚本自己把 URL 里的内容拼进了页面。三种类型的脚本长一个样,处理方式却完全不同,本篇把它们放在一起对比。
一、实验环境与本次目标
| 项目 | 内容 |
|---|---|
| 靶场 | 本地搭建的测试靶场 |
| 测试模块 | XSS (Stored) 与 XSS (DOM) |
| 安全级别 | Low |
| 本次目标 | 分别确认两种类型的触发条件与差异 |
| 边界 | 仅在本机靶场验证,不做任何数据外带 |
两种类型都用同一个验证动作:让页面上出现一个可见的标记。区别在于「这个标记是怎么留下来的」。
二、存储型:内容一旦进去,就留在了那里
该靶场的存储型模块是一个留言板,名字和内容两个输入框,提交后会一直显示在页面上,谁来访都能看到。先正常提交一条:
先正常留一条
Name:Hacker
Message:这是一条普通留言
提交后,页面上多出一行留言,刷新还在,换浏览器打开还在。这说明内容被写进了数据库,而不是只停留在这一次请求的响应里。
接着把内容换成一段脚本再提交一次:
把留言内容换成一个标签
Name:Hacker
Message:<script>alert('XSS')</script>
提交的瞬间页面就弹窗了——它甚至不需要我再刷新,因为服务端把刚落库的内容立刻渲染回来了。更值得注意的是接下来的三步验证:
-
刷新页面,依旧弹窗——内容还在数据库里。
-
换一个浏览器打开同一个地址,依旧弹窗——这不是缓存在作怪。
-
清掉缓存重进,依旧弹窗——结论只能是「脚本已经在数据库里,谁看谁执行」。
存储型和反射型的本质差别 反射型是「我点它才响」,存储型是「它一直在这儿响」。前者需要一次诱导点击,后者只需要让一个人打开页面。利用成本从「说服一个人点链接」降到「等人来访问」,这就是它危害更高的原因。
三、存储型更危险的三个原因
同为弹窗,存储型在真实场景里的影响要大得多,理由只有三条,但每条都很硬。
| 原因 | 说明 |
|---|---|
| 无需投递 | 内容已经在站点里,不用发邮件、不用发短链,等访问自然发生 |
| 影响所有访客 | 同一页面被多少人看,就有多少次执行,包括管理员 |
| 持久存在 | 不清理就会一直在,修复前每一次访问都触发一次 |
第二行在实战中最要命:管理员往往有一个「审核留言」的后台页面,如果这个后台把留言内容直接渲染出来,那么一条留言就能让管理员的会话被影响。这也是为什么表单、评论、昵称、签名这些「用户可写、别人会看」的字段,一直是这类问题的重灾区。
四、DOM 型:服务器完全没有参与
进入 DOM 型模块,先正常访问:
正常访问
http://[靶场地址]:8082/vulnerabilities/xss_d/?default=English
页面会显示一个下拉框,默认选中的是 English。把参数换掉再访问:
把参数换成标签
http://[靶场地址]:8082/vulnerabilities/xss_d/?default=<script>alert(1)</script>
页面同样弹窗了。但如果这时候去看服务器返回的 HTML 源码,会发现里面根本没有这段脚本——服务器返回的页面是干净的。
原因在前端脚本里:页面加载后,JavaScript 从地址栏读取 default 参数,然后把它写进某个元素的内容里。整个过程里,服务器只是一个「送快递的」,它送来的包裹是正常的,是收件人自己在家里把包裹拆开点燃了。
DOM 型的判定特征 服务器响应里看不到脚本,但页面执行了;脚本从 URL 或页面属性进入,由前端脚本拼接进 DOM。这决定了防护位置:服务端过滤对它无效,必须在拼接的那一行做处理。
五、三种类型的七个维度对照
把这三种放在一起比一遍,比单独记每一种都清楚。这张表是我自己整理后反复回看的一张。
| 维度 | 反射型 | 存储型 | DOM 型 |
|---|---|---|---|
| 脚本位置 | URL 参数 | 数据库 | URL 或页面属性 |
| 持久性 | 一次性 | 持久 | 一次性 |
| 触发方式 | 诱导点击 | 打开页面 | 诱导点击 |
| 影响范围 | 点的那个人 | 所有访客 | 点的那个人 |
| 服务器是否参与 | 参与(回显) | 参与(存取) | 不参与 |
| 后端日志 | 常见 payload | 常见 payload | 通常无痕迹 |
| 内容过滤能否拦截 | 能 | 能 | 不能 |
最后一行是我认为最有价值的一行。前两种类型里,服务端确实能看到那段输入,有机会拦;DOM 型里服务端什么都没看见,任何服务端过滤都形同虚设。
记法:反射型靠诱导,存储型靠等待,DOM 型靠前端。判断类型时,先问「这段内容服务器看见了吗」,再问「它留在哪儿、活多久」。
六、前端最危险的几处拼接
DOM 型的问题最终都落在一行代码上:把不可信的内容拼进页面。常见的几类写法如下,做代码自查时优先搜这几个名字。
| 写法 | 风险来自哪里 |
|---|---|
| innerHTML / outerHTML | 把字符串当 HTML 解析,标签会被执行 |
| document.write | 直接向文档流写入,等同解析 HTML |
| eval / new Function | 把字符串当脚本运行,最直接的一类 |
| location.href / location.replace | 跳转到可控地址,常用于跳转到伪装页 |
| setTimeout / setInterval 的字符串参数 | 字符串形式会被当脚本执行 |
| v-html、dangerouslySetInnerHTML 之类的框架语法 | 框架里同样存在,只是换了个名字 |
对应的正确写法,核心只有一句:把它当文本,而不是当 HTML。设文本用 textContent 或框架的插值,确实需要富文本时走白名单清洗,而不是自己写替换规则。
七、自查清单:三类一起查
-
把所有「用户写、别人看」的字段列出来:留言、评论、昵称、签名、工单描述、文件名。这些是存储型的首选位置。
-
在每个字段里输入一个特殊字符,提交后查看页面源码,确认是实体编码还是原样输出。
-
对每个页面做一次「换浏览器是否复现」的验证,这一步能直接区分反射型和存储型。
-
在前端代码里搜上面那张表的六个名字,逐个确认拼进去的内容是否可信。
-
检查 Cookie 的 HttpOnly 标记与响应头的 CSP,这两项决定「即使执行了,能拿走多少」。
经验值:存储型的排查成本远低于它的危害。一个字段一个字段试一遍,通常十几分钟就能把重灾区找出来。
八、踩坑记录
| 现象 | 原因 | 处理 |
|---|---|---|
| 留言内容太短提交不上 | 字段长度限制 | 只留最精简的验证标记,或改用事件属性写法 |
| 输入被过滤掉了 | 服务端做了黑名单 | 换一种事件触发写法验证是否真被拦住 |
| DOM 模块没反应 | 浏览器对某些写法的处理不同 | 换浏览器复测,或用页面上的可见变化作为信号 |
| 以为是存储型其实是反射型 | 没有做换浏览器/清缓存的对照 | 固定做三步验证:刷新、换浏览器、清缓存 |
九、小结
-
存储型的关键动作是「换浏览器仍复现」,这一步排除了缓存干扰,证明内容已落库。
-
DOM 型的关键证据是「服务器响应里没有脚本,页面却执行了」。
-
服务端过滤只对前两种有效,DOM 型只能在前端拼接处解决。
-
同一个弹窗,三种类型的处理位置完全不同,判断类型比复现更重要。
下一篇是这一周的转折点:当过滤开始出现,为什么大多数过滤都能被绕过去,而白名单又为什么难绕。我们不讲具体写法,讲的是「过滤判断依据在哪一层」。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2401_88349202/article/details/170129621




