ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CPS系统后端安全实战:SQL注入与XSS攻击的防御策略

CPS系统后端安全实战:SQL注入与XSS攻击的防御策略 做饿了么CPS系统后端的那段时间我每天处理最多的不是新功能而是各种看着就很可疑的请求参数。作为一个按成交计费的分销推广平台订单号、渠道标识、用户备注几乎都是从外部进入系统的Java后端既要处理大量查询又要面对推广者上传的素材内容SQL注入和XSS攻击几乎是每个月都要被安全扫描器点名的问题。这篇文章不打算讲大而全的安全理论而是围绕饿了么CPS系统里真实出现过的攻击入口聊一聊我在Spring Boot MyBatis技术栈下的具体防御手法哪些代码写法能根治SQL注入哪些拦截器只是辅助存储型XSS为什么比想象中难防以及一次线上告警的完整排查链路。如果你负责推广联盟、电商交易、营销结算这类系统或者手上的项目正在过安全合规这篇内容应该能帮你省不少时间。1. CPS系统的攻击入口钱所在的接口总是最先被试探1.1 推广链路里哪些参数最容易被注入CPS系统的核心逻辑不复杂推广者拿到带参数的推广链接用户点击后系统记录渠道关系用户下单成功后订单回传系统再按规则给推广者结算佣金。这个链路里几乎所有节点都需要接收外部参数而且很多参数会直接拼进SQL或者被存入数据库。最容易出问题的接口集中在四个方向订单回调查询orderId、trackNo、subChannel、uid这些字段会用于查询订单表、更新渠道记录攻击者可以直接在URL里改参数。推广素材管理推广者提交的图片链接、标题、商品推荐语会被保存到数据库并渲染到管理后台或C端页面天然是存储型XSS的温床。结算报表查询时间范围、排序字段、分页参数经常会被开发人员用字符串拼接的方式拼进SQL。OpenAPI接口CPS系统的渠道方通过接入方AppKey调用接口虽然做了签名但业务参数仍然会被带入SQL查询。攻击者去扫描一个系统时不会关心你的业务多复杂只会找那些把外部输入直接带入SQL语句或者直接渲染成HTML的位置。CPS系统因为涉及订单号和佣金金额属于最容易被盯上的那类业务。1.2 攻击者视角下的CPS接口多数的自动化扫描器会先请求一遍所有GET参数在参数后面追加单引号、AND 11、AND 12通过页面返回的差异判断是否存在SQL注入。接着会尝试SLEEP(3)做时间盲注或者用UPDATEXML触发报错注入。如果后端使用的是MyBatis的${}这基本是一打一个准。XSS方向稍微隐蔽一些。很多CPS系统里推广者会填写一段推广文案管理员在后台审核时直接通过v-html或者innerHTML渲染预览。只要没有做存储时过滤攻击者在文案里写一句img srcx onerroralert(document.cookie)管理员一打开审核页面Cookie就没了。下面这张表格是我们在安全评审时梳理出来的高风险点接口场景参数示例主要风险潜在后果订单查询orderId, uidSQL注入拖库、越权查订单推广素材保存title, content存储型XSS后台管理员Cookie被盗结算报表查询sortField, orderBySQL注入佣金数据泄露渠道回调trackNo, signSQL注入伪造订单数据搜索结果回显keyword反射型XSS诱导用户执行脚本1.3 为什么只做后端过滤永远不够很多项目的第一反应是在后端把所有请求参数里的select、union、script过滤掉。这种做法不能说没用但只靠它是肯定不够的。黑名单过滤本质上是在跟攻击者比谁能绕过谁。大小写变体、URL编码、注释符、MySQL的/*!50000*/内联注释、等价函数替换随便一种方式都能让简单黑名单失效。更麻烦的是正常业务和恶意输入的边界并不清晰。用户昵称里完全可能包含sleep()这样的英文单词商品标题里也可能带HTML标签一刀切过滤会造成大量业务误伤。所以我在CPS系统里坚持的防御原则是第一层用预编译SQL解决掉绝大多数注入问题第二层用参数校验和输出编码处理XSS第三层才用统一的过滤器做兜底。这也是这篇文章后面要展开的完整思路。2. SQL注入第一道防线预编译语句和MyBatis的正确写法2.1 #{}和${}的本质区别MyBatis里最基础的防注入知识就是#{}和${}的区别但实际项目里仍然能看到大量错误的${}用法。简单说#{}会生成JDBC的PreparedStatement占位符参数值由数据库驱动预编译处理不会参与SQL语法解析${}则是在SQL拼接阶段直接字符串替换外部输入变成了SQL语句的一部分。可以这样理解#{}是去餐厅点餐你告诉服务员要吃A套餐后厨按固定流程做菜${}是你直接进厨房指着食材说我要把这些东西放到锅里炒如果食材里混了辣椒和砂子最后出来的就是一道无法预料的东西。下面是对比写法。假设CPS系统里按订单号查询订单详情!-- 安全写法使用 #{} 预编译 -- select idselectOrderById resultTypecom.cps.order.OrderDO SELECT * FROM cps_order WHERE order_id #{orderId} /select !-- 危险写法使用 ${} 拼接 -- select idselectOrderByIdUnsafe resultTypecom.cps.order.OrderDO SELECT * FROM cps_order WHERE order_id ${orderId} /select当传入的orderId是1 AND SLEEP(3) --时第二种写法执行的SQL就完全变了变成SELECT * FROM cps_order WHERE order_id 1 AND SLEEP(3) -- 数据库会真的等上三秒。如果扫描器用大量时间盲注请求数据库连接池很快就会被慢查询拖死。2.2 不得不用${}的场景动态表名和排序字段的白名单校验有经验的读者会问那动态表名怎么办CPS的订单表如果按月分表确实需要根据时间参数动态拼表名。遇到这种情况最安全的做法不是放弃预编译而是提前把表名限制在一个白名单内。我们当时的做法是这样private static final SetString ORDER_TABLE_WHITELIST new HashSet(Arrays.asList( cps_order_202501, cps_order_202502, cps_order_202503 )); public boolean isAllowedOrderTable(String tableName) { if (tableName null || !tableName.matches(cps_order_\\d{6})) { return false; } return ORDER_TABLE_WHITELIST.contains(tableName) || tableExists(tableName); }先做格式正则再查表名是否真实存在并且不允许cps_order_202501; DROP TABLE ...这种写法通过。排序字段也是同理不能直接把前端的sortField塞进ORDER BY ${sortField}而是要让前端传入排序编号后端根据编号映射到固定的数据库列名private static final MapString, String SORT_FIELD_MAP new HashMap(); static { SORT_FIELD_MAP.put(createTime, create_time); SORT_FIELD_MAP.put(commission, commission_amount); SORT_FIELD_MAP.put(status, order_status); } String column SORT_FIELD_MAP.getOrDefault(sortField, create_time); query.orderByDesc(column);这样即使用户传了drop table也只会被当成一个不存在的排序编号处理永远到不了SQL语句里。2.3 MyBatis-Plus里那些容易忽视的拼接隐患项目里用了MyBatis-Plus后很多人会觉得SQL注入已经不存在了其实不是。QueryWrapper虽然对列名做了处理但有些方法依然存在安全隐患。特别要注意的是last()方法它会把传入的内容直接拼接到SQL尾部。例如String limit request.getParameter(limit); queryWrapper.last(limit limit);如果limit是前端传进来的1; DELETE FROM cps_order --这条SQL就出问题了。我们通常是要求开发人员不能把last()和任何外部参数直接拼接如果真的需要动态分页就用Page对象或者重新检查参数后再放行。另外apply()方法里有{0}占位符时相对安全但如果直接写字符串拼接同样有风险。我的建议很简单所有MyBatis-Plus的QueryWrapper、LambdaQueryWrapper都尽量使用带方法引用的写法不要自己在里面拼SQL片段除非这段SQL完全不包含外部输入。2.4 预编译解决不了的盲区讲了这么多还得说清楚预编译不是万能药。以下几个场景即使使用了PreparedStatement仍然存在风险。第一个是存储过程中的动态SQL。如果存储过程内部使用了EXECUTE IMMEDIATE拼SQL应用层的预编译就拦不住了需要另外做参数校验。第二个是LIKE模糊查询。虽然LIKE ?本身是参数化的但通配符%和_不受参数化影响攻击者可以用%穷举数据。虽然这不算SQL注入但会造成性能问题。第三个是表名、列名这类数据库对象标识符。JDBC的PreparedStatement不支持对表名和列名使用占位符所以只能靠白名单。遇到这些情况不要把希望寄托在单一手段上。我在CPS系统里的策略是对象名一律走白名单存储过程一律重构成普通Mapper方法LIKE查询参数再单独做一次通配符转义把%和_转成普通字符。这套组合几乎堵住了所有外部输入能触及SQL的路径。3. 网关层统一拦截过滤器、拦截器和全局参数清洗3.1 选择Filter而不是Interceptor的原因项目里很多同事会把参数清洗的逻辑写到Interceptor里理由是代码更优雅。但Interceptor在Spring MVC中只拦截HandlerMapping匹配到的Controller并且执行时机在DispatcherServlet之后。如果请求在进入Controller之前发生异常或者请求的接口不属于当前Web应用上下文Interceptor可能不会生效。Filter是Servlet容器层面的组件在请求进入Spring容器之前就会执行对所有HTTP请求生效。所以CPS系统做了一个全局的XssFilter放在所有请求进Controller之前处理两件事对URL参数和表单参数做XSS编码对JSON请求体中的字段值做递归清洗。3.2 黑名单过滤的误伤和绕过问题网上的很多安全Filter范例都是准备一个危险关键字数组比如select、union、sleep、script然后遍历参数只要命中就把请求拦截。这个方案看着简单实际用起来问题很大。第一个问题是误伤。CPS的推广素材里经常出现精选睡眠好物这类标题sleep直接在字符串里出现黑名单一拦商品就提交不上去了。更常见的是update、delete这些单词字段值里包含它们太正常了。第二个问题是绕过。攻击者把PAYLOAD做一次URL编码Filter如果先拿原始参数值判断很容易漏掉大小写混合、Tab代替空格、用/**/注释代替空格同样能绕过正则匹配。我们的Filter做了几层预处理再匹配先对参数值做URL解码然后统一替换掉空白字符包括Tab、换行、注释符再统一转成小写最后才是正则检测。而且检测到风险后不是直接返回500而是返回一个JSON格式的400响应告诉调用方参数不合法避免泄露接口细节。3.3 处理JSON请求体里的注入和XSSCPS开放平台的很多接口使用application/json格式提交数据参数不在Query String里而在请求Body中。如果只处理getParameter()JSON里的字段完全漏掉。这里需要自己实现一个HttpServletRequestWrapper重写getInputStream()和getReader()在请求体被Controller读取之前先解析一次JSON对每个字符串节点做清洗再把清洗后的JSON重新写入字节流。核心代码逻辑public class XssRequestWrapper extends HttpServletRequestWrapper { private byte[] body; public XssRequestWrapper(HttpServletRequest request) throws IOException { super(request); byte[] raw IOUtils.toByteArray(request.getInputStream()); String json new String(raw, StandardCharsets.UTF_8); String cleaned cleanJsonValue(json); this.body cleaned.getBytes(StandardCharsets.UTF_8); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(body); return new ServletInputStream() { Override public int read() { return byteArrayInputStream.read(); } Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener listener) { } }; } }cleanJsonValue里用Jackson把JSON解析成JsonNode递归遍历所有字符串节点对文本内容执行HtmlUtils.htmlEscape。这样既能把script转义成lt;scriptgt;又不会破坏JSON的整体结构。这里有个很实际的小坑重写getInputStream()后HttpServletRequest的getContentLength()返回的还是原始长度如果Controller里依赖这个值做大小限制会出现请求体截断的错觉。需要在Wrapper里把contentLength一并修正。3.4 网关层过滤只是兜底不是根治写完了Filter还要强调一点这个全局清理只适合作为最后一道兜底屏障不能因为它存在就在业务代码里放心使用${}拼接SQL或者不设置输出编码。原因也很简单Filter里的正则和编码方案是通用的它不了解业务上下文所以对很多绕过手段仍然防不住。比如数据库函数BENCHMARK、EXTRACTVALUE、GTID_SUBSET等正则词表不可能覆盖所有危险函数。XSS方面的math、video等标签通用白名单也未必能过滤干净。真正的防线永远是预编译占位符解决SQL注入输出编码解决XSSFilter处理的是那些历史遗留代码和不可控的外部输入。我在团队里经常说一句话Filter是给你自己擦屁股的不是给攻击者设牢笼的。4. XSS攻击防御CPS系统里绕不开的存储型XSS4.1 三种XSS在CPS业务里的具体体现XSS可以分成反射型、存储型和DOM型在CPS系统里都有实际存在。反射型XSS最常见的地方是搜索页。CPS管理后台经常提供按订单号搜索按推广者昵称搜索的功能搜索关键字直接拼在URL上如果搜索结果页用text()渲染还有遮护但有些老页面直接用了innerHTML输入scriptalert(1)/script就被执行了。存储型XSS是CPS系统最头疼的。推广者填写的素材标题、推荐语、自定义备注都会写进数据库管理员审核、用户查看时一旦渲染方式不安全就成了持久性攻击。这类攻击的危害最大因为不需要受害者点击链接只要打开页面就中招。DOM型XSS更多出现在前后端分离的前端代码中。后端返回一个JSON字段前端在某个回调里用document.getElementById().innerHTML把它塞进页面即使后端把内容做了编码只要前端用了错误的输出点依然可以被利用。4.2 存储时用Jsoup白名单过滤而不是简单替换标签很多人处理存储型XSS的方式是正则替换script和onerror但这种方式非常脆弱。攻击者只需要换一种事件属性、换一个标签就能绕过。我们内部对富文本字段的处理是统一使用Jsoup的白名单机制。Jsoup.clean()配合Whitelist只允许白名单里出现的标签和属性存在其他全部剥离。比如推广素材描述允许商品介绍允许p、strong、em、a等标签但不允许script、iframe、object不允许onclick这类事件属性。Whitelist whitelist Whitelist.relaxed(); whitelist.addTags(p, div, span, br, strong, em, u, ul, ol, li, a, img); whitelist.addAttributes(a, href, title, target); whitelist.addAttributes(img, src, alt, width, height); whitelist.addProtocols(a, href, http, https); whitelist.addProtocols(img, src, http, https); String safeContent Jsoup.clean(originalContent, whitelist);特别注意addProtocols这一步。如果漏掉了协议限制攻击者可以塞一个a hrefjavascript:alert(1)虽然标签是允许的但链接地址本身是危险的。4.3 输出编码富文本和普通文本必须分开处理很多人混淆了存储时转义和输出时编码的关系。存储时用Jsoup白名单过滤是为了保证内容本身不包含恶意标签输出时还需要根据不同的上下文做编码。两者不是二选一而是要同时做。对于普通字符串字段比如推广者昵称、订单备注存储时直接保存用户原文本输出到HTML页面或者JSON接口时必须做上下文编码。前后端分离的项目里后端返回JSON后前端用Vue的插值{{}}或React的{text}默认就会转义安全系数高。但如果前端开发为了省事使用了v-html或者dangerouslySetInnerHTML服务端再怎么做编码都拦不住。这里有一个容易翻车的细节后端接口返回的JSON数据我会明确规定富文本字段必须用类似contentHtml这样的命名并且附上说明该字段已经过白名单过滤前端可以渲染HTML其他普通字段如果前端想当HTML渲染就要自己承担XSS风险。团队里的前端同学都被我要求写代码审查时特别注意v-html的使用位置。4.4 响应头也能挡住一部分攻击XSS的防护不仅限于业务代码HTTP响应头也能起到很好的辅助作用。我一般在CPS系统的安全响应头里配置以下几项响应头作用X-Content-Type-Options: nosniff禁止浏览器猜测响应类型减少MIME混淆攻击X-XSS-Protection: 1; modeblock启用浏览器自带的XSS过滤器虽然已废弃但能兜底Content-Security-Policy: default-src self限制脚本、图片、样式等资源的来源即使有XSS也无法加载外部恶意脚本Referrer-Policy: same-origin限制Referrer信息泄露CSP策略上线时要注意排查业务里是否有外链资源、第三方脚本否则容易把正常功能拦截。我们的做法是先开启Content-Security-Policy-Report-Only观察告警再逐步收紧。5. 线上告警排查实录一个orderId引发的注入风波5.1 事故现象半夜的数据库连接池告警事情发生在一个大促前的联调晚上监控群里突然连续弹出数据库连接池超过阈值的告警。查看慢查询日志发现大量SQL都是SELECT * FROM cps_order WHERE order_id 1 AND SLEEP(3) -- 这样的语句。第一条反应就是orderId参数被注入攻击了。因为攻击流量走的是OpenAPI接口带有签名参数前端的WAF规则没有识别出来全部打到了后端应用。数据库连接池里的连接被这些SLEEP查询占住正常的订单查询接口响应时间从20ms飙到了5秒以上。5.2 完整排查链路第一步从Nginx访问日志里筛选出异常请求。通过orderId字段找到了攻击者的完整请求URL确认是订单查询接口并且orderId参数值是一串包含AND SLEEP(3)的注入特征串。第二步在应用日志里按订单查询接口的TraceId找到对应的SQL日志。日志明确打印出SELECT * FROM cps_order WHERE order_id 1 AND SLEEP(3) -- 说明SQL语句中的orderId是以字符串拼接方式进入查询的。第三步打开Mapper XML文件发现这个接口为了实现按订单号查询并自动适配分表在代码里写成了FROM cps_order_${month}而month来自前端传入的日期参数。虽然也做了时间格式校验但校验逻辑不严谨只检查了month是否以2025开头攻击者传入202501 AND SLEEP(3) --也通过了校验。select idselectByOrderId resultTypeOrderDO SELECT * FROM cps_order_${month} WHERE order_id #{orderId} /select问题很清楚month参数使用了${}拼接且白名单校验不严。即使orderId是参数化的只要${}的源头没控制住漏洞一样存在。第四步确认漏洞等级并紧急修复。修复方案是先把month参数彻底移除前端传入逻辑改为由后端根据请求时间自动计算默认月份如果确实需要指定月份改成白名单枚举。同时把所有${}出现的地方全部过一遍代码扫描。修复后的Mapper select idselectByOrderId resultTypeOrderDO SELECT * FROM cps_order_202501 WHERE order_id #{orderId} /select第五步验证。我重新用之前的攻击payload请求接口返回的是400参数错误而不是慢SQL。数据库连接池恢复平稳。5.3 修复之后做的三件事事故修复只是开始。我当天晚上又拉着开发同学做了三件加固工作。第一件给所有OpenAPI接口的查询参数增加了统一的安全断言所有字符串参数不允许包含SLEEP、BENCHMARK、UNION SELECT、UPDATEXML等危险关键字一旦命中直接返回异常。虽然这不是根治手段但能挡住一部分自动化攻击。第二件把编译时的静态扫描工具接进了CI。使用FindSecBugs检查所有XML和Java代码里的${}拼接点发现一处就直接让构建失败强制开发人员改成白名单或预编译。第三件把这次攻击的完整payload和排查过程写成一份内部SOP。以后遇到类似的注入告警值班人员可以按照SOP快速定位漏洞点不用等第二天早上才处理。5.4 排查过程中的坑和收获这次排查中我还踩了一个很典型的坑。一开始我尝试用全局Filter拦截month参数给month加上过滤后再次调用接口却发现业务日志里SQL还是原来的值。排查后发现Filter里只处理了getParameter()方法返回的值但MyBatis执行时使用的是RequestParam绑定的参数在Filter中修改后的参数并没有重新绑定到HttpServletRequest上。后来改为直接使用Hibernate Validator对RequestParam做严格白名单校验才从根本解决问题。这个坑提醒我全局Filter适合做日志记录和简单清洗但涉及这个参数必须符合业务规则时一定要使用Bean Validation在Controller入口做校验。安全防护不能只靠一层而是要建立在参数校验、预编译、输出编码、兜底过滤器四个层次的纵深防御体系上。6. 把安全测试固化成基线一份可以抄的测试用例清单6.1 SQL注入测试用例很多项目安全测试只在上线前做一次之后代码一迭代漏洞又回来了。我的经验是把安全用例直接写到自动化回归测试里每次部署都能跑一遍。下面是一份输入到CPS订单查询接口的SQL注入用例清单直接放在src/test/java里ParameterizedTest ValueSource(strings { 1 OR 11, 1 AND SLEEP(5)-- , 1 AND UPDATEXML(1,CONCAT(0x7e,VERSION()),1)-- , 1%27%20AND%20SLEEP(3)-- , 1 /*!50000AND*/ SLEEP(3)-- , 1 AND BENCHMARK(10000000,SHA1(test))-- }) void orderQueryShouldRejectInjectionPayload(String orderId) throws Exception { mockMvc.perform(get(/api/cps/order/detail) .param(orderId, orderId) .header(X-OpenApi-Token, testToken)) .andExpect(status().isBadRequest()); }为什么这些用例要覆盖不同的编码形式和注释形式因为攻击者最常用的就是大小写绕过、URL编码绕过和MySQL内联注释绕过。测试用例里包含了原始payload、URL编码后的payload、内联注释变体以及BENCHMARK这种不是每套正则都会收录的函数能有效验证修复是否彻底。6.2 XSS测试用例XSS用例也需要覆盖不同的上下文HTML标签内、标签属性内、JavaScript事件内、URL链接内。我通常会在推广素材接口的测试里放下面这几类ParameterizedTest ValueSource(strings { scriptalert(1)/script, img srcx onerroralert(1), svg/onloadalert(1), a href\javascript:alert(1)\click/a, div style\width:expression(alert(1))\test/div, scrscriptiptalert(1)/scr/scriptipt, #60;script#62;alert(1)#60;/script#62;, ;!--\XSS{()} }) void materialTitleShouldBeCleaned(String title) throws Exception { mockMvc.perform(post(/api/cps/material) .contentType(MediaType.APPLICATION_JSON) .content({\title\:\ title \})) .andExpect(status().isOk()); // 再查询一次确认返回内容不包含可执行脚本 String responseBody mockMvc.perform(get(/api/cps/material/list)) .andReturn() .getResponse() .getContentAsString(); assertFalse(responseBody.contains(script)); }注意这个断言不是看存储时是否清洗而是看接口返回时是否还存在可执行标签。如果前端依赖后端返回HTML渲染这里就必须断言输出内容已经被编码或者剥离。6.3 把安全扫描接入CI自动化测试之外我强烈建议接入静态扫描和动态扫描工具让它们在CI流程里自动跑。静态扫描我们用的是FindSecBugs它可以扫描Java字节码和部分XML能直接识别出MyBatis Mapper里的${}拼接点并在构建时给出警告。配置很简单在pom.xml里加上对应的plugin即可Fail-on-error设置为true有漏洞就不让发版。动态扫描用的是OWASP ZAP的Baseline扫描每周跑一次对测试环境发起自动请求重点关注排名靠前的注入和XSS规则。动态扫描的结果会有一些误报但还是能发现一些开发人员忽略的接口。6.4 优先级排序和推进建议安全测试用例写好了还要有权重。我们内部按照三个等级来排优先级P0是外部用户直接可访问且参数进入SQL或HTML的接口比如OpenAPI的订单查询、推广素材保存接口必须在上线前完成修复和用例验证。P1是管理后台内部接口虽然没有外部用户但管理员权限本身价值高也要在迭代后及时更新用例。P2是日志类、统计类接口风险相对低出现误报时可以延后处理。在推动修复时我会直接把扫描工具输出的漏洞路径和对应的测试失败用例一起丢给开发让他们用用例复现而不是给一个笼统的存在SQL注入风险的报告。这样开发同学定位问题只需要几分钟而不是从头捋一遍业务代码。安全测试这件事做得越频繁新代码里的漏洞就越少。我在实际工作中体会最深的一点是不要把安全测试搞成上线前的一次性动作而是把它变成接口文档的一部分。新来的同事开发完一个接口自己就会先跑一遍安全用例确认没问题再提交合并这会比安全团队事后扫描高效得多。
返回列表