ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

老牌CMS XSS反复出现:漏洞排查与安全加固实践

老牌CMS XSS反复出现:漏洞排查与安全加固实践

开头先说明:这次分享的主题不是教人怎么打,而是围绕一款老牌开源内容管理系统(下面统一用“老系统”代称)反复被通报的 XSS 问题,聊聊这些漏洞为什么总也修不完、它们和后台被写入文件之间到底是什么关系,以及我作为防守方在一次内部自查里具体是怎么走的整套流程。核心关键词就三个:XSS 防护、CMS 安全加固、漏洞排查。不管你是刚接手一套存量站点的运维,还是需要做代码审计的安全岗,或者只是给自己维护的小站做个体检,下面这些内容都能直接拿去用。

我不打算给你们任何可以直接复制粘贴去攻击别人站点的东西,那既没意义也不负责。真正有价值的是搞清楚机制:为什么一个看起来“只是弹个框”的脚本注入,放在后台场景里就会被放大成文件落地;为什么有些过滤函数写了等于没写;为什么同一个输入点在 Chrome 里人畜无害,换个老内核就出事。搞明白这些,你排查自己的系统时才有方向感。

1. 老牌内容管理系统为何常年出现在漏洞通报里

1.1 十几年前的架构选择,决定了今天的攻击面

这类系统的骨架大多成型于 PC 互联网时代,那时候的设计目标是“让不懂代码的人也能建站”,于是催生了一套非常激进的机制:模板标签直接写在 HTML 里,后台可以提供在线编辑模板、在线修改栏目、在线装插件。这套机制对使用者极其友好,代价是把大量本该在编译期确定的东西推迟到了运行期,而运行期意味着用户输入可以参与到模板解析、文件生成这些高权限动作里。

我见过不少二次开发过的站点,模板目录是 777,插件目录里躺着十年前没人维护的第三方代码,后台入口改了个名字就当防护了。这种环境下,任何一个输出点漏了转义,影响的都不只是“页面上多一段脚本”,而是可能顺着后台功能一路走到文件系统。这是理解后面所有内容的前提:风险不是单点的,是链式的。

1.2 从“页面执行脚本”到“服务器写入文件”中间隔着什么

很多人对 XSS 的理解停留在“弹窗”“盗 Cookie”,觉得危害有限。放在普通访客身上确实如此,但放在管理员会话上,性质就变了。老系统后台往往同时具备几类高危能力:模板文件在线编辑、数据库备份与恢复、附件上传、插件安装、缓存清理。这些功能本身是正常需求,问题在于它们的入口往往依赖“管理员已登录”这一个判断,而不校验请求来源和操作意图。

于是攻击路径就变成了这样一条逻辑链:先在某个前台输入点植入脚本,等管理员访问后台或相关页面时脚本执行,脚本以管理员的身份和权限发起后台操作请求,后台功能把数据写成文件。整条链里,XSS 是入口,后台功能是放大器,最终落点才是文件。所以加固的时候不能只盯着“过滤尖括号”,必须同时管住会话、管住后台操作的请求校验、管住写文件这条路径。这三处任何一处断了,链子就接不上。

1.3 存量站点的现实:补丁永远打不完

理论上,官方出了修复版本,升级一下就行。实际运维里我遇到的情况要复杂得多:站点做过深度二次开发,核心文件被改过几十处,直接覆盖升级会导致功能崩掉;有些站点跑在很老的语言版本上,新版本的代码根本跑不起来;还有些是外包做的,交付之后原班人马早就联系不上了。

这就导致一个结果:大量站点长期停留在“知道有风险但不敢动”的状态。我的建议是,不要指望一次性升级解决问题,而是把工作拆成两层——应急层用配置和网关兜底,工程层逐步做代码改造。后面第 6 节会给出具体的分层方案。

2. XSS 在这类系统里的三类典型落点

2.1 存储型:内容字段和富文本过滤器

存储型是最常见也最麻烦的一类,因为脚本被存进数据库,每次有人访问对应页面都会触发。老系统里最容易出问题的字段通常是这几类:文章标题、文章摘要、栏目描述、自定义表单提交内容、评论、友情链接名称。

按理说这些字段都应该在输出时做 HTML 实体编码,但实际情况是,很多模板里写的是类似“直接输出原始内容”的标签,开发者为了让摘要能带格式,主动关掉了转义。还有一种更隐蔽的情况:字段在入库时做了过滤,在输出时又做了一次“反转义”或者“HTML 还原”,等于把前面的过滤全废了。

判断一个字段是不是存储型风险的落点,我的做法是看三件事:这个字段在后台编辑页是不是富文本编辑器、在模板里是用转义标签输出还是原始标签输出、数据库里存的是原文还是编码后的实体。三件事里只要“富文本 + 原始输出”同时出现,基本可以确定这个点需要重点验证。

2.2 反射型:搜索框、跳转参数和错误回显

反射型一般不需要落库,参数带在 URL 里,页面直接把参数拼进 HTML 返回。老系统里高发的几个位置:站内搜索结果页(关键词回显)、登录后的跳转提示页、404 和错误提示页、分页参数回显。

这类漏洞有个特点,很多开发者觉得“要用户点特定链接才触发,危害低”,于是长期不修。但从社工角度看,一条精心构造的站内链接发给管理员,成功率并不低,尤其是在“系统升级通知”“异常登录提醒”这类场景包装下。我个人的定级习惯是:如果反射点位于后台域或者能触达管理员会话,一律按高危处理,不按低危放过。

验证的时候不需要构造复杂载荷,我就看一个最简单的判断标准——在参数里输入带有特殊字符的普通文本,比如包含<、"、'、&的一串字符,然后查看返回的 HTML 源码,看这些字符是被原样输出,还是被编码成了实体。原样输出就是问题,编码了就没问题。这个方法不涉及任何攻击代码,纯看渲染行为,非常适合自查。

2.3 DOM 型:前端拼接与旧内核兼容

DOM 型的问题不在服务端,在浏览器里由 JavaScript 完成。老系统里常见于:搜索结果高亮、评论楼层跳转、前端模板渲染、URL 参数直接写进innerHTML。

这类点用抓包工具看不出来,因为请求和响应都是干净的,脚本是在浏览器端拼出来的。排查方法得换一套:把页面里所有读取 URL 参数、读取location、读取document.referrer的代码位置找出来,看这些值最终流向哪里。流向innerHTML、document.write、eval这类危险汇点的,就要重点看。

还有一个容易被忽略的维度:老系统往往会兼容很旧的浏览器内核,而这些内核里存在一些非常规的解析行为,导致某些在主流浏览器里被正常处理的字符序列,在老内核里会被重新解释成标签。我的做法是,这类站点一律把安全基线按“最差的那个客户端”来定,不做兼容性妥协。

类型触发方式排查入口加固重点
存储型访问含恶意内容的页面富文本字段、自定义表单、评论输出编码、富文本白名单
反射型诱导点击特定链接搜索、跳转、错误页参数编码、错误页统一处理
DOM 型前端脚本拼接URL 参数读取点、前端模板安全 API 替换、CSP

3. 一次内部自查的完整排查链路

3.1 从攻击面清单开始,而不是从载荷开始

很多人做安全测试的习惯是打开工具就开始扫,扫出一堆报告再慢慢看。我在做存量系统自查时反过来:先花半天时间把攻击面清单列出来,再逐个验证。

清单怎么列?按“数据从哪进、经过哪、最后到哪”这条线来。入口包括:URL 查询参数、表单字段、文件上传、HTTP 请求头(Referer、User-Agent 这些也会被记录并回显)、Cookie。中间处理包括:过滤函数、模板渲染、JSON 输出。出口包括:HTML 页面、JSON 接口、文件、数据库。

列完这张表你会发现,真正需要人肉看的地方其实不多,通常集中在十几到几十个关键文件。比如模板渲染相关的一批文件、表单处理的一批文件、后台功能入口的一批文件。把范围收敛到这些文件之后,不管是人工读代码还是用工具做污点分析,效率都会高很多。

这一步的另一个价值是:它帮你建立“哪些点是别人也可能踩过的”这种直觉。同一个开源项目被曝过的位置,往往和它的架构强相关,同类位置大概率还有没被公开的类似问题。

3.2 用“编码前后对比”判断真实风险

确认一个点是否存在风险,我用的是最朴素的对比法:同一个输入,在页面上原样输出,还是在源码里被编码。

具体操作是:在输入框里填一段包含特殊字符的文本,包含尖括号、双引号、单引号、反斜杠、&符号,提交后查看页面渲染结果和页面源码。如果渲染结果里这些字符被当成 HTML 结构的一部分解析了(比如尖括号变成了实际标签),说明没编码;如果源码里看到的是&lt;、&quot;这类实体,说明编码是生效的。

这个方法的妙处在于它完全不需要构造攻击载荷,也不会触发任何告警,非常适合在授权的自查场景里使用。我在给团队做培训时一直强调:安全验证的目的是确认“数据被当成了代码还是被当成了数据”,只要搞清楚这一点,不需要真的去执行什么。

顺带说一个判断技巧:很多系统会对输入做“黑名单替换”,把尖括号删掉。这种情况下你输入特殊字符,输出里会发现字符凭空消失了。这看起来像“修好了”,其实是坏味道——黑名单永远有绕法,尤其在不同字符集、不同编码转换的场景下。看到“字符消失”这种现象,我反而会把风险等级往上提,因为它说明防护思路是错的。

3.3 复现、定级、修复、回归的闭环

确认问题之后,流程要闭环,否则修完还是心里没底。

复现环节:把触发条件写成最小步骤,包括需要什么权限、访问哪个地址、参数怎么带。这一步是为了让开发和运维能独立理解和验证,而不是只看到一句“这里有 XSS”。

定级环节:我一般看三个维度——需要什么权限触发、能影响到哪些角色、能不能和其他问题组合。如果触发需要管理员权限,看起来低,但如果触点是“管理员查看某列表”这种高频动作,实际风险就高;如果单纯是一个普通访客自己给自己弹窗,那确实可以降级。

修复环节:优先在输出层修,而不是在输入层修。原因很简单:输入层的黑名单思路迟早被绕过,而输出层的编码是数学上确定的。如果确实需要在输入层清洗( 比如富文本),那也必须配合输出层白名单。

回归环节:修完必须重新走一遍 3.2 的对比法,并且要测边界情况——同一个字段在列表页、详情页、搜索页、RSS 输出、移动端接口,可能走的是不同的输出路径,只修一处等于没修。我踩过的坑就在这儿:某次只在主模板里加了编码,结果站点的 WAP 版模板用的是另一套渲染逻辑,问题原封不动地留着。

4. 转义防线怎么落到代码里

4.1 上下文决定编码方式,这是铁律

XSS 防护最核心的一句话是:编码方式由输出上下文决定,不是由输入内容决定。同一个用户输入,输出到 HTML 文本里、输出到 HTML 属性里、输出到 JavaScript 字符串里、输出到 CSS 里、输出到 URL 参数里,需要的处理方式完全不同。

举个具体的:输出到 HTML 文本节点,需要把&、<、>、"、'编码成实体;输出到属性值里,除了上面的还要保证属性值整体被引号包裹;输出到<script>块内的字符串变量里,需要用 JSON 序列化而不是 HTML 实体编码,因为 HTML 实体在 JS 字符串里不会被解码,反而可能因为</script>提前闭合标签而出问题;输出到href、src这类 URL 属性里,还要校验协议,只允许 http、https、相对路径。

我在评审代码时最常看到的错误就是“一招鲜”:全站所有输出统一调一个htmlspecialchars,然后觉得万事大吉。在大多数场景下这么做确实能挡住大部分问题,但在 JS 上下文里它是无效的,在 URL 上下文里也不够。所以正确做法是先按上下文给输出点分类,再给每一类配一个固定的编码函数。

4.2 模板引擎的自动转义,以及关掉它的代价

现代模板引擎基本都提供自动转义,问题往往出在“为了显示格式而关掉转义”这个动作上。老系统里这种关闭转义的写法非常普遍,而且经常是分散在各个模板文件里,没有任何标记。

我的处理原则是:默认全部开启转义,任何一处关闭都必须有明确注释和对应字段的白名单说明。具体落地可以这样做——把“原始输出”这个动作集中封装成一个函数,函数内部做三件事:检查传入字段是否在允许的白名单里、如果不在就记一条日志、对白名单内内容执行一遍富文本清洗后再输出。这样一来,所有“危险动作”都有统一的出入口,审计的时候只需要 grep 一个函数名。

<?php // 统一的安全输出封装(示意) function safe_html($value) { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } // 富文本输出:必须走白名单清洗,不接受裸输出 function safe_rich($value) { $allowed = '<p><br><strong><em><ul><ol><li><a><img>'; $clean = strip_tags((string)$value, $allowed); // 仍需处理属性层面的危险值,例如事件属性、危险协议 return $clean; }

上面这段只是示意,真实项目里富文本清洗建议直接用成熟的白名单库,不要自己写正则。我见过太多自研过滤器,写了几百行正则,最后被一个属性里的特殊写法绕过去。

4.3 富文本白名单:拦什么、留什么

富文本是这块最难的地方,因为业务要求“能带格式”,而格式本身就是 HTML。我的思路是把白名单拆成三层分别控制。

第一层是标签白名单,只允许排版类标签,把表单、脚本、框架、样式块全部排除。第二层是属性白名单,每个标签只允许它该有的属性,比如链接只允许href和title,图片只允许src、alt、width、height。凡是on开头的事件属性,一律不进入白名单。第三层是值校验,href和src这类要检查协议,style属性如果允许,必须再走一次 CSS 值过滤,因为 CSS 里也存在能执行脚本的历史写法。

还有一个容易被忽略的点:富文本内容在“编辑回填”时也要按同样的规则处理。有些系统只做了保存时的清洗,编辑页把内容回填到 textarea 里时直接原始输出,结果编辑页本身就成了反射点。

4.4 CSP 和 Cookie 属性作为兜底

代码改造是个长期活,改造期间需要有兜底手段。两个最有效的兜底是内容安全策略和 Cookie 属性。

内容安全策略的作用是告诉浏览器“只允许加载哪些来源的脚本”。配置得当的话,即使某处漏了转义,注入的脚本也执行不了。落地时要注意不要一上来就用最严格的模式,老系统里内联脚本、内联事件处理器到处都是,直接上严格模式会把站点搞挂。我的建议是先用“报告模式”跑一两周,收集违规日志,看清楚站点到底依赖哪些内联脚本,再逐步收敛。收敛的顺序可以是:先禁掉外部未知域,再处理内联脚本(改成 nonce 或 hash),最后处理内联事件。

Cookie 属性这块,后台会话 Cookie 一定要加HttpOnly,这样即使脚本执行了,也读不到会话凭证。同时加Secure(如果全站 https)和SameSite=Lax或Strict。SameSite这个属性对前面提到的那条“脚本以管理员身份发起后台请求”的链路有直接的阻断作用,成本极低,性价比很高。

# 响应头示意 Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-随机值'; object-src 'none'; base-uri 'self' Set-Cookie: SESSIONID=xxx; Path=/; HttpOnly; Secure; SameSite=Lax

5. 后台侧的文件写入路径必须单独收口

5.1 后台功能点本身就是最高危攻击面

前面反复提到,XSS 本身只是入口,真正造成严重后果的是后台那些“能写文件”的功能。所以加固的重心必须往后台移。

我梳理过这类系统的后台功能,高危程度从高到低大致是:模板在线编辑、数据库备份与恢复、插件与应用安装、附件上传、缓存与静态页生成、站点配置修改。这些功能的共同点是最终都会在服务器上产生或修改文件。只要其中任何一个的入口校验不严,前面的 XSS 就有了实际落点。

5.2 三处最该改的地方

第一处是模板编辑。这个功能的设计初衷是方便,但它的本质是“通过浏览器写任意文件”。加固思路:默认关闭,需要时临时开启;编辑范围限制在指定目录;保存时校验文件扩展名和路径,禁止..穿越;对写入内容做基本的语法检查,避免写入可执行代码。

第二处是上传。老系统上传点通常只校验扩展名,而扩展名校验又有各种绕过方式。我的做法是三重校验:服务端读取文件真实类型、白名单限定允许的类型、上传目录禁止脚本执行(通过 Web 服务器配置实现,这是最有效的一层)。

第三处是备份恢复。备份文件如果落在 Web 可访问目录且扩展名可控,风险极高。加固方式是备份目录放在站点根目录之外,文件名使用随机串,恢复操作要求二次确认并记录操作日志。

5.3 权限分层与最小化

技术手段之外,权限设计是最容易被低估的一环。很多站点所有管理员共用一个超级账号,一旦这个会话被利用,全线失守。

我建议至少做三层:内容编辑(只能写文章,不能碰模板和插件)、站点管理员(能改配置,不能碰服务器文件)、系统管理员(能装插件、改模板,且这个账号平时不登录,需要时临时启用)。配合操作日志,任何一次模板修改、插件安装都能追溯到人和时间。这套东西落地之后,即使某天真的出现了会话被利用的情况,损失范围也是可控的。

另外提醒一句运行环境层面的事:Web 进程的运行账号不要有整站目录的写权限,只给必要的几个目录(上传目录、缓存目录、日志目录)写权限。这一条配置下去,很多“写入文件”的路径直接就断了,成本几乎为零,收益极大。

6. 存量系统的加固路线与可执行清单

6.1 一周内能做完的应急动作

不要一上来就排几个月的改造计划,先把能立刻降低风险的做了。

第一,后台入口做访问控制,限制来源 IP 或者加一层额外的认证,至少不要让后台登录页暴露在公网随便访问。第二,给所有会话 Cookie 加HttpOnly、Secure、SameSite。第三,关闭后台的模板在线编辑和插件在线安装功能,需要时再临时开。第四,上传目录加上禁止脚本执行的配置。第五,把内容安全策略以报告模式上线,先收集数据。

这五件事基本都是一两个小时的配置量,但对前面那条攻击链的阻断效果非常明显。

6.2 一个月内推进的工程改造

应急动作做完,开始动代码。优先级我一般这样排:

先做输出层的统一封装,把所有模板里的原始输出改成统一函数,这一步工作量最大但收益最高。再做富文本白名单替换,把自研过滤器换成成熟库。然后补后台操作的请求来源校验,所有写操作必须是 POST 且带一次性令牌。最后清理无用的插件和旧文件,很多老站点根目录下躺着一堆十年前的上传木马和测试文件,这些是最容易被忽视的后门。

6.3 一份可以直接照着做的检查表

检查项判断标准不达标的处理
输出是否统一编码模板中不存在裸输出替换为安全输出函数
富文本是否白名单标签、属性、协议三层控制换成熟白名单库
会话 Cookie 属性HttpOnly + Secure + SameSite改配置立即生效
上传目录执行权限目录内脚本不可执行改 Web 服务器配置
后台写操作校验POST + 一次性令牌补校验逻辑
模板编辑功能默认关闭关闭或加二次确认
数据库账号权限仅业务库必要权限收权限
目录写权限仅上传/缓存/日志可写调整属主与权限
插件与遗留文件无未知来源文件清理并记录

这份表我一般会打印出来贴在工位上,逐项打勾。它的好处是不依赖任何工具,任何人拿着都能查。

最后分享一点我自己的体会。这类老系统的安全问题,本质上不是某一个函数写错了,而是“方便”和“安全”在设计阶段就被放在了天平的两端,而当年的天平是偏向方便的。你今天去补,不可能把天平整个翻过来,只能一处一处地加约束。所以我做这类项目时从不追求“彻底解决”,而是追求“把攻击链上最便宜的那几刀砍断”。把会话保护好、把写文件的路堵上、把输出统一编码,这三件事做完,绝大部分公开的利用手法就失效了。剩下的,交给持续的巡检和日志告警去兜。

返回列表