☰
Web安全实战:从注入到SSRF的防御与自查清单
2026/10/17 8:08:26 网站建设 项目流程

1. 写在第二篇:从原理到防御的实战视角

这个系列写到第二篇,上一篇我们把web安全的基础概念过了一遍,包括HTTP协议特性、同源策略、Cookie机制、认证与授权这些地基。这篇不打算继续堆理论,而是直接切入“怎么防”这个大家更关心的问题。毕竟作为开发者和运维,判断力最终要落到一条配置、一行代码、一个依赖升级上,这是web安全最实在的部分。

这篇文章我会按攻击入口拆开讲几类高频高危漏洞:注入类、XSS与CSRF、文件上传、SSRF。每一类都沿着“它为什么存在、攻击面长什么样、防御侧该在哪些层堵截”展开,而不是只列个漏洞号概念解释。适合刚入门但想往实战靠的同学,也适合做内部自检的测试和运维在遇到同类问题时对照排查。

内容里所有的案例都来自我平时做过的模拟项目、以及和同行交流时复盘过的场景,公司和项目信息一律脱敏处理。你可以在读的时候把注意力放到“这个错误我是不是也犯过”“这条自查命令能不能跑一遍”上,如果有一两个点能让你拍一下大腿,这篇就没白写。

1.1 这篇要解决什么问题

很多人学过web安全之后会有一种“什么都听过,但不知道从哪下手”的空虚感。知道SQL注入要把引号闭合,知道XSS要转义,可真要让自己写一个接口,该在哪个环节加防护、加的防护会不会被绕,心里没底。这篇文章解决的就是这个断层。

所以我不会花大篇幅去讲攻击者用什么工具、怎么绕过,重心放在防御方视角。每个漏洞类型最后都尽量落成可操作的动作:要么是一段参数化查询代码,要么是一组Nginx响应头配置,要么是一份自查清单。你可以把这些直接抄进自己的项目里,再根据业务形态调整。

1.2 为什么从这几类漏洞切入

选这五类不是因为它们最“出名”,而是因为它们出现频率极高、危害大,而且恰好覆盖了后端、前端、文件链路、服务器交互四个关键入口。SQL注入和命令注入是典型的服务端处理边界问题,XSS和CSRF是浏览器信任边界问题,文件上传和SSRF则经常被当成“功能需求”忽略安全设计。

另外,这几类漏洞有一个共同特点:修复方案并不玄乎,靠的是常识和纪律。真正导致漏洞的,往往是“当时图方便”的代码习惯。所以这篇读起来会有点像“安全代码审查笔记”,而不是高深的白皮书。

2. 注入类漏洞:SQL注入与命令注入的应对

2.1 SQL注入的本质与常见误解

先聊SQL注入。它的根源只有一个:把用户输入当作代码的一部分拼进SQL语句。比如某后台系统有个按订单号查询的接口,代码是这样写的:

sql = "SELECT * FROM orders WHERE id = '" + order_id + "'"

这里的order_id如果来自URL参数,攻击者传一个带特殊字符的值,就能改变SQL语句本身的结构。很多老教程喜欢让你“过滤单引号、过滤关键字”,这个思路在演练环境里能挡一下,放到真实业务里基本是给自己找麻烦。原因很简单:不同数据库的字符串处理、注释符号、大小写绕过、Unicode编码,变种太多了,黑名单永远跟不上。

正确的做法是参数化查询。参数化查询的核心是“数据”与“结构”分离,用户输入只作为参数值传进去,数据库引擎不会把它解析成SQL语法。比如用Python的SQLite接口:

cur.execute("SELECT * FROM orders WHERE id = ?", (order_id,))

Java里对应PreparedStatement setString,Node.js里的mysql2占位符也是一个道理。只要所有的SQL语句都走参数化,绝大多数SQL注入会被直接掐断源头。这是所有工作里回报率最高的那一件。

2.2 参数化查询不是万能的

但这里有个非常容易被忽略的角落:参数化查询只能处理“值”,不能处理“结构”。什么意思?比如表名、列名、排序字段,这些属于SQL语句的骨架,不能放在占位符里。很多系统做了排序功能,前端传一个order=create_time desc直接拼进ORDER BY,这个地方参数化无能为力,因为占位符只能占一个值,不能占一段语法。

我在实际项目里见过这个坑:核心查询都参数化了,但动态排序字段是拼进去的。攻击者拿这类入口试了几个关键字没弄进去,反而在排序字段上发现了注入点。修复方案也比想象中简单,做一个白名单映射,前端传的是内部枚举名,后端只拼接映射后的固定字符串:

SORT_COLUMNS = { "create_time": "create_time", "update_time": "update_time", "amount": "amount", } order_column = SORT_COLUMNS.get(sort_key, "create_time") direction = "DESC" if desc else "ASC" sql = f"SELECT ... ORDER BY {order_column} {direction}"

白名单外面传进来的值根本进不了SQL语句,天然安全。这类经验总结起来就一句:SQL里凡是不能参数化的地方,就交给白名单枚举,绝不直接接收用户输入的字符串。

2.3 ORM不是安全保障,要关注原生查询

很多团队用了ORM,觉得SQL注入跟自己的团队没关系了。这个想法太乐观了。ORM默认的链式查询通常都有参数化保护,但问题出在“逃生口”上。为了让代码更灵活,框架们都留了原生查询能力,比如Hibernate的createNativeQuery、MyBatis里手写的SQL映射、Python Django的raw()。

这类原生SQL一旦出现,前面ORM给你的保护就全部失效。我见过一个项目,查询大部分用ORM没问题,但有一个报表功能需要用复杂JOIN,开发图省事直接写了原生SQL拼接,上报参数原封不动放进去,等于把这个系统重新暴露在注入风险中。

所以自检的时候不要只看ORM代码,要全局搜这些关键词:execute、raw、createNativeQuery、sqlmap忽略掉,搜String sql = "SELECT"、text(``)这类拼接痕迹,再看看这些语句里有没有直接把请求参数拼进去的。大部分注入漏洞就是这么被发现的。

命令注入和SQL注入思路相通,常见场景是代码里调系统命令处理上传文件、压缩包、PDF转换,然后把文件名直接拼进命令字符串:

os.system("unzip " + user_filename)

这样写的问题很直观,文件名里的特殊字符会被shell解释。修复有两个层次,第一层是用参数数组传参而不是拼字符串:

subprocess.run(["unzip", "-o", dest_path], check=True)

参数数组能规避大部分shell解释问题,但要注意一个细节:别让用户可控的参数以-开头,否则可能被命令解析成选项。比如文件名前面加--这种终止选项标记不起作用的话,就容易变成选项注入。第二层是文件名白名单校验,只允许字母、数字、下划线、点、横线,服务端重新生成随机文件名,不在路径里使用用户原始文件名。

3. 前端侧攻击:XSS与CSRF的一体化防护

3.1 XSS的三种形态都绕不开输出编码

XSS的根源是“不可信的数据被当成HTML、JavaScript或URL的一部分插到了页面上”。它分存储型、反射型、DOM型,很多文章喜欢强调类型差异,但防御时我更愿意把所有类型统成一种思维:用户数据进入页面时,必须按它所在的位置做上下文感知编码。

比如一个评论功能,用户提交的内容被存进数据库,其它用户看到页面时,服务端模板把这段内容直接输出到HTML里。如果输出时不做HTML实体编码,评论里写一段<script>就能在所有人浏览器里执行。这就是存储型XSS的路径。

防御动作非常明确:如果数据出现在HTML标签内容里,就转义<、>、&、"等字符;如果出现在JavaScript字符串里,就要做JS上下文编码。大部分现代前端框架默认都带转义,比如React的{text}和Vue的双花括号插值,但框架都留了通向原生HTML的口子:React的dangerouslySetInnerHTML、Vue的v-html。这两个地方一旦使用,就相当于你亲手关掉了框架的安全防护。

真实项目中用v-html渲染富文本内容很常见,一定要搞清楚数据来源和清洗策略。富文本本身就需要允许部分HTML标签,所以不能简单全部转义,一般要配合白名单过滤库,比如只允许p、strong、a等基础标签,去掉script、iframe、带on*事件的属性。

3.2 CSP和HttpOnly是把保险锁

编码是防线,但总有漏网之鱼。CSP(Content-Security-Policy)是第二道保险,它的思路是“就算脚本被注入进来了,浏览器也不允许它执行”。你只需要在响应头里声明脚本只允许从哪些来源加载:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'

这条策略要求所有脚本都从同源地址加载,内联脚本和eval会被直接拦下,攻击者的注入脚本没有执行环境。加了CSP之后,有些旧系统可能因为内联脚本太多被误伤,但这种“误伤”反过来会逼着你把脚本外置,长期对可维护性也是好事。

HttpOnly这个属性则用于保护Cookie,加了之后JavaScript读不到它。即使XSS执行成功,攻击者也拿不到Session Cookie。配置方法就是服务端设置Cookie时加一个HttpOnly标记,常见的Web框架都有对应选项,不需要写代码,改几行配置即可。

三个动作配合起来就是分层防御:编码挡住大部分注入,CSP拦下漏网的,HttpOnly把被窃取的Cookie价值降到最低。

3.3 CSRF的Token机制与SameSite配合

CSRF的成因和XSS正好相反:XSS是页面接受了不该接受的数据,CSRF是浏览器对“已认证用户”的过度信任。用户在登录状态下访问了一个恶意页面,恶意页面构造一个表单请求发到目标站点,浏览器会自动带上目标站点的Cookie,服务端看到合法Cookie就认为是本人操作,于是接口被悄悄执行了。

防御CSRF的关键是“让伪造请求无法通过验证”。最经典的是CSRF Token:页面渲染时服务端生成一个随机Token,存在Session里,同时下发给页面,提交请求时必须带上这个Token,服务端校验两边是否一致。攻击者无法读取目标站点的Token,所以构造不出合法请求。这个机制同时要做“绑定Session”而不是只做“双同样式”,否则某些场景可以被绕过。

更现代的方案是给Cookie加SameSite属性。SameSite=Lax基本是默认选择,它规定跨站请求不带Cookie,CSRF的根基就没了。加上它之后,大部分CSRF攻击都会被拦在门外。我自己在项目里通常是Token和SameSite一起上,Token管关键业务接口,SameSite做全局兜底,双保险比单靠哪一个都稳。

4. 文件上传与SSRF:两类“入口型”高危项

4.1 文件上传不是改个后缀名那么简单

文件上传是一个做了就难拆的功能,风险点密集。很多人以为做扩展名白名单就够了,实际上攻击者可以换着法子绕过:改MIME Content-Type、双扩展名、大小写、在图片里嵌入脚本、上传SVG这类本身就能执行脚本的格式。我建议按清单逐项自查。

第一,扩展名白名单要按类型收束。只允许业务真正需要的扩展名,比如图片功能就限定jpg、png、gif、webp,同时校验Content-Type、文件头魔数和文件大小。第二,上传目录不能放在可执行脚本的路径下。很多老系统用的是Tomcat部署,上传目录放在webapps里面,攻击者传一个JSP文件上去,理论上可以解析执行。理想做法是把上传文件放到独立静态目录或对象存储服务里,与应用执行路径彻底隔离。第三,服务端要生成随机文件名,用UUID重命名,原始文件名只存到数据库元数据字段,不回显到路径里。

还有一个小细节:设置下载响应时,Content-Disposition尽量带上filename和nosniff,防止老浏览器把纯文本响应当成HTML渲染,造成文件型XSS。

4.2 SSRF的协议与网络边界要一起管

SSRF全称是服务端请求伪造,发生场景是“服务端接收了用户提供的URL,然后代替用户发起请求”。常见入口有:图片转存功能、URL预览功能、Webhook配置、PDF生成器。攻击者的核心诉求是让服务端去访问内网地址,比如http://127.0.0.1/、http://192.168.1.1/,然后利用内网应用的弱点。

只做URL格式校验远远不够,因为IP可以写成十进制、八进制、十六进制,域名也可能解析到内网。标准的防法分三层:第一层协议白名单,只允许http和https;第二层引用发起前先解析域名,拿到IP后校验是不是内网网段,命中私网地址就直接拒绝;第三层禁止跟随重定向,因为第一次校验过了,重定向之后可能跳到内网地址,必须逐跳校验或直接关闭重定向。

网络层ACL是最后的兜底。哪怕应用被绕过,生产环境的服务器和数据库、内部管理系统之间也要有访问控制,应用容器只放行它真正需要的出网方向,从网络层面让“内网扫描”无从谈起。

4.3 两类漏洞的共同自检思路

文件上传和SSRF看似不同,实际上都是“处理外部输入时超出了业务预期范围”。文件上传超预期在于“格式的宽度”,SSRF超预期在于“地址的宽度”。自检思路是问自己三句话:这个功能接收了什么外部输入?它会把输入传给谁?接收方是否对输入做了白名单约束?

这三句话问完,大部分问题都能暴露出来。比如一个头像上传功能,接收的是图片文件,传给文件系统或云存储,那么约束就应该是“后缀白名单+文件内容校验+文件名UUID”。再比如一个图片代理功能,接收的是URL,传给HTTP客户端,那么约束就应该是“协议白名单+IP地址校验+重定向限制”。把每个功能的安全面想清楚,比机械地套用某种防护更靠谱。

5. 我的自查工具链与一次排查实录

5.1 从依赖扫描到动态验证的检查顺序

日常给项目做安全自检,我固定走一套顺序,不会上来就扫。第一步看依赖清单,用工具对比已知漏洞库,把高危漏洞的版本升掉。这一步解决的是“基础材料不合格”的问题,有过期的第三方库就别谈后面了。第二步做代码静态扫描,重点关注原始SQL拼接、HTML拼接、文件路径拼接、URL请求这几个高频点。

第三步是动态验证,把上面静态扫描发现的“可疑点”逐个手工验证,而不是全套自动化扫描完就交差。这一步我现在喜欢写一些小脚本去构造请求,观察响应里有没有数据库报错信息、页面有没有渲染异常,看返回结果判断漏洞是否真实存在。自动化扫描器给的报告只做指引,因为漏报和误报都很常见。

5.2 误报与漏报的经验总结

先说误报。工具看到某框架版本旧就直接报高危漏洞,但实际这个漏洞已经被安全组通过热修复或补丁方式修掉了,版本号只是没变而已。这种情况要结合线上部署情况人工确认,别让开发团队白跑一趟。

再说漏报。逻辑漏洞是重灾区,比如查看订单接口只校验了“是否登录”,没校验“是不是本人的订单”,这种越权访问自动化扫描器基本发现不了。所以我做自检时,会重点把“带ID参数的接口”全部拉出来看一遍,确认每个接口都做了归属校验和权限校验。这才是靠人的地方,也是最有价值的部分。

5.3 某后台管理系统SQL注入的排查与修复实录

最后拿一个真实发生过的案例串一遍。某内部后台管理系统,有一段时间运维观察到异常请求告警:某个IP对订单查询接口发起大量请求,部分请求返回500错误。查到应用日志时,发现请求参数里带着单引号和注释符,报错信息里直接出现了SQL片段,基本可以确定是注入尝试。

顺着代码往下查,定位到一个报表导出功能。业务上需要动态拼接列来生成SQL,结果开发者把前端传的排序列名直接拼进了语句,跳过了白名单。正常使用不受影响,但遇到特殊参数就报错,工具扫描时这里确实被标记过高风险,当时没人当回事,这次被尝试攻击才暴露出来。

修复分了两步。第一,查询改成参数化写法,排序字段接入白名单映射,用户参数和SQL执行彻底分开。第二,顺手把这个接口的“订单归属权限”校验补上,确认当前登录用户只能查到自己的订单数据。上线后我用原来的异常参数重新请求,SQL语句不再变构,返回了正常参数校验错误,之前的500不再出现。之后跑了一遍回归用例,确认导出功能没受影响。

这个案例给我最大的感受是:安全漏洞往往藏在一个看似不起眼的代笔里,真正的问题不是工具没发现,而是人没有重视。事后复盘时加了接口审计制度,凡是新建接口必须过权限和参数化两关,才允许合并主线代码。这比再多扫描报告都管用。

我自己这几年做安全相关的事情,最大的体会是别把安全当成一个独立阶段。它更像是代码里的一个基础习惯,就像你会顺手做空指针判断一样。参数化查询写得顺手了,响应头配置变成模板了,权限校验变成肌肉记忆了,很多高危漏洞压根不会出现在你的项目里。开始可能觉得麻烦,但固定下来之后,你会发现它其实并不比别的规范更占用时间。你可以先从自己最近写的接口过一遍这套自查清单,大概率能翻出几个吓一跳的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询