|
去年秋天,存储一个做二手交易平台的型x修复朋友半夜给我打电话,说他们的漏洞商品详情页被人挂了一段脚本,用户点进去就会弹出伪造的存储登录框。攻击者没做别的型x修复,就是漏洞把一段带<script>的 payload 提交到了商品描述里,前端直接 innerHTML 渲染,存储后端存库也没做任何转义。型x修复这就是漏洞最典型的存储型 XSS——恶意代码进了数据库,每个访问该页面的存储用户都会中招。 存储型 XSS 和反射型最大的型x修复区别在于“持久化”。反射型需要诱导用户点击特制链接,漏洞存储型不用,存储payload 安安静静躺在数据库里,型x修复像一颗定时炸弹。漏洞评论区、用户昵称、商品描述、私信内容,凡是“用户输入→存库→其他用户可见”的链路,都是它的温床。我见过最离谱的案例是一个后台管理系统,管理员把恶意 payload 存进了“系统公告”,结果所有登录后台的员工浏览器都被劫持了 session。 修复这件事,核心原则只有一条:永远不要信任任何用户输入,在输出时根据上下文做转义。 注意,是输出时转义,不是输入时过滤。很多开发者喜欢在入库前把 < 和 > 替换掉,这做法有两个问题:一是原始数据被污染了,以后想正常显示都难;二是不同输出场景需要的转义规则不一样,HTML 里要转义 <,JavaScript 字符串里要转义引号和换行,URL 里又要另一套。入库时一刀切,迟早出事。 具体到代码层面,如果后端是 Java,用 Spring 的 HtmlUtils.htmlEscape() 或者 OWASP 的 Encode.forHtml()。PHP 用 htmlspecialchars($str, ENT_QUOTES, 'UTF-8'),别漏了 ENT_QUOTES,否则单引号逃逸照样能构造 payload。Node.js 里如果用的是模板引擎,EJS 的 <%= %> 默认转义,但 <%- %> 不会,很多人图方便用了后者,漏洞就这么来的。 前端渲染这块坑更多。innerHTML、outerHTML、document.write() 都是危险 API,能不用就不用。React 的 dangerouslySetInnerHTML 名字已经警告你了,非要用的话,先过一遍 DOMPurify。Vue 的 v-html 同理。我自己的习惯是,凡是需要渲染富文本的地方,后端存两份:一份原始 Markdown 或纯文本,一份经过白名单过滤的 HTML。白名单用 DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['b','i','u','p','br','a']}) 这种,只放行必要的标签和属性,onerror、onload 这类事件属性一律干掉。 还有一个容易被忽略的点:Cookie 的 HttpOnly 标志。就算 XSS 防不住,攻击者拿不到 session cookie,危害也能降一个数量级。加上 Secure 和 SameSite=Lax,三重保险。Content-Security-Policy 响应头也值得配,script-src 'self' 能挡住大部分内联脚本执行。不过 CSP 配置复杂,容易误伤正常功能,建议先在报告模式(Content-Security-Policy-Report-Only)下跑一周,看看有没有漏网之鱼。 最后说个真实教训。有个团队修完漏洞后只测了商品描述,忘了用户昵称也能存 payload。上线第二天又被刷了。存储型 XSS 的修复不是改一个点,是把所有“用户可控且会回显”的入口全捋一遍。拿张纸,把数据流画出来,从请求参数到数据库字段再到前端渲染位置,一个都别漏。这活儿笨,但管用。 |