安全指南
X-Forwarded-For(XFF)伪造是什么 — 信任代理配置的陷阱与防御
X-Forwarded-For(XFF)是客户端可以随意伪造的 HTTP 头。本文以个人开发中常见的事例,从防御视角讲解:在伪造的 XFF 里夹带 SQL 注入或 XSS 探测的自动扫描,以及『信任所有代理』这一配置的陷阱(不提供攻击步骤)。多层防御为什么有效、即便如此还有什么本该修复,将分成对症处理与根本对策来总结。
X-Forwarded-For(XFF)是即便经过代理,也用来告诉应用『真正的访客 IP 是这个』的头。然而这个值客户端可以随意伪造。本文把个人开发中常见的『遭到 XFF 伪造扫描』事例隐去专有名词,转化为教训(不提供攻击的复现步骤)。
- 种类
- X-Forwarded-For 伪造 + 注入探测(自动扫描)
- 实际损害
- 无(被多层防御挡下)
- 发现方式
- 攻击请求变成 500,异常通知邮件大量飞来
- 生效的盾
- ①占位符(值的绑定)②DB 的字符编码校验
- 残留的疏漏
- 『信任所有代理』的配置(前端并无代理的架构)
- 处置
- 在边界对 IP 头做净化(对症)→ 把信任代理收敛到合适范围(根本)
先做区分:是『攻击』还是『自己的失误』
看到错误通知就立刻断定『是攻击』,是要避免的铁律。如果刚刚做过架构变更,就应以同等分量去怀疑自己的改动。
怀疑自己的改动
用值的内容作为证据
是定向攻击还是扫描
本站的视角:从症状反推原因时要『用数据来排除』
『最近动过的地方可疑』这种直觉,作为出发点是有效的。但是,如果证据(这里是值的内容)予以否定,就要放手。立多个假设,不靠成见而用数据一个个排除,误诊就会大幅减少。
攻击的真面目:在 XFF 伪造里搭载注入探测
攻击机器人在 XFF 里『以 IP 之名』塞入探测用字符串,只在末尾留一段像样的 IP。目的只有一个——
『这个应用,会不会相信 XFF 的值,把它生拼进 DB 查询、或原样输出到 HTML 里?』
如果应用做得马虎,就会经由 XFF 中招 SQL 注入 或 XSS。这是把『承载 IP 的位置』滥用为任意字符串的注入口的手法。
为什么实际损害为零:两道盾
盾① 值的绑定(占位符)
没有把 IP 值做字符串拼接,而是用占位符把它作为数据交给 DB。输入没有变成 SQL『指令』的余地 → SQLi 在结构上不成立。这是使用框架最大的好处之一。
盾② DB 的字符编码校验
载荷里的非法字节(overlong UTF-8 等),在列的字符编码下本就是无法保存的排列。DB 判定为『无效』,拒绝写入并抛出异常 → 攻击失败,而且被检测到了。
实际发生的只是『攻击者自己的请求变成 500,异常通知大量飞来』而已。泄露、篡改、非法登录一概没有。这是多层防御按预期生效的好例子。
真正的问题:把代理『全部』都信任了
如果到此为止那就是佳话,但有一点不能放过。话说回来,伪造的 XFF 为什么能抵达 IP 判定?
✗ 信任全部代理(通配符)
客户端伪造的 XFF → 被当作『真实的 IP』采用 → 日志・会话・IP 判定被污染
✓ 什么都不信任(默认值)
忽略伪造的 XFF → 采用直接连接来源的真实 IP → 伪造失效
这个架构是既无反向代理也无 CDN、直连的 Web 服务器。也就是说根本没必要信任 XFF,却设成了『全部信任』。『先全部允许再说』,正是开发时的偷懒原封不动残留到生产环境的典型。
盲点:XFF 伪造的危害不只有『注入』
所有相信客户端 IP 的处理都会被污染。rate limit 的绕过、IP allowlist 的突破、BAN 规避、审计日志的伪造——即便注入防御很牢固,这里也是另一回事。请把『相信 IP 的地方』盘点出来,确认其信任的依据(=proxy 配置)。
对策:在边界净化 → 把信任收敛到合适范围
在边界对 IP 头做净化(对症・速效)
把信任代理收敛到合适范围(根本)
http/https 判定,改动可能带来重定向循环等副作用 → 先在一个环境里先行验证。不要把『合法的 IP』与『真实的 IP』混为一谈
验证:修好后复现攻击来确认
把『自以为修好了』变成『确实止住了』。①只在一个环境应用对策 → ②自己发送同样的非法头 → ③确认错误数不增加 → ④确认正常访问也照常工作 → ⑤没问题再推到全部环境。分阶段应用能防止事故。
续篇:堵了 IP,又被戳了 User-Agent(打地鼠的陷阱)
加上净化数十分钟后,又来了同一类错误。这次坏掉的是另一个列——不是 ip_address 而是 user_agent。IP 头被挡下的攻击者,把同样的恶意载荷换装到了 User-Agent 头上。这里需要转换思路。
正确的问题不是『哪个头危险』,而是『不可信的值最终会被保存到哪个 DB 列』。如果保存目标只有 ip_address 和 user_agent 这两个,那么只要净化通向这两个的入口(IP 系头+User-Agent,保险起见连 Referer 也加上),源自会话的错误在原理上就能止住。不会被保存的其他头则无法横向扩展。正常的 User-Agent 是 ASCII/正常的 UTF-8,所以加上只剔除无效字节的处理也毫无影响。
本站的视角:从出口反推(不要一个个去堵入口)
一个个去堵头是打地鼠——攻击者能用的入口多到数不清。从出口(保存列・输出位置)反推,把通向那里的入口一次全部梳理出来才是正解。正如『堵了 IP,又被戳了 User-Agent』所学到的,做对症处理去堵某个头时,要把抵达同一保存目标的其他入口一并堵上。而且第二次同样在复现并确认止住后才推开。
更进一步的续篇:入口没能完全挡住,靠出口(保存前一刻)守住了
把 User-Agent 也堵上、心想『这下没问题』的紧接着,ip_address 列又来了同样的错误。入口的净化已经部署,用自己发送与攻击相同的非法头来测试也无法复现。可在生产上却复发——这里是这个故事的高潮。
原因在于,框架的『客户端 IP 判定』内部很复杂。多种头的写法(X-Forwarded-For・Forwarded・自定义头)、与 proxy 信任配置的组合、解析顺序与缓存……在这些内部路径的某处,污染值从与我们净化过的值不同的另一条路线作为『客户端 IP』复活了。在入口无法全部捕获。
✗ 在入口守
净化请求的头 → 客户端 IP 经内部另一条路线复活 → 被绕过
✓ 在出口守
在 DB 保存的前一刻检查 → 无论中途怎样被污染都是『必经的一点』 → 无法绕过
于是把思路掉转 180 度,决定在出口(写入 DB 的前一步)守住。继承会话保存类,只替换生成保存值的那部分(概念上):保存用 IP『若非合法 IP 则为 null』,保存用 User-Agent『剔除非法字节并限制长度』。无论客户端 IP 判定在内部怎样翻车,写入 DB 的前一刻都必经检查,所以无从绕过。把两条攻击路线各连打十几次,错误也没有增加,正常用户的会话也一切正常。
本站的视角:最后的堡垒是『出口(使用的地方)』
安全的定式是『不要相信输入,在使用的地方(出口)务必校验・转义』。入口的检查在『尽早拦截』上有效,但对于框架会在内部解析・加工的那类信息(客户端 IP 判定等),它可能绕过入口而复活。在传给 DB・HTML・命令等危险位置的前一刻=必经的一点做校验・转义,才是最后的堡垒。入口与出口是两回事,两者都要做。
最大的收获:被吵闹地拦下来更安全
这一次,攻击不仅失败,还以大量错误通知的形式被可视化了。听上去讽刺,但这其实不坏。比起被悄无声息地通过,被吵闹地拦下来更安全。不要把错误通知当成『噪声』丢掉,而要把它当作『异常检测的传感器』来用——不止步于『实际损害已防住』,而是去修复『为什么会一路抵达内部』,这才是本站的立场。
接下来阅读
- 术语:SQL 注入是什么 / XSS 是什么 / SSRF 是什么
- 入门:安全超入门:.env 与 API 密钥
FAQ
QX-Forwarded-For 可以信任吗?
原样是不能信任的。XFF 是客户端可以随意填值的头,攻击者既能伪造成看似真实的 IP,也能填入注入探测的字符串。可以信任的,只有『自己放在前端、由信任代理附加的 XFF』。
Q没有使用反向代理或 CDN 时该怎么办?
完全不信任 XFF(保持框架的默认值)。前端并没有代理却设成『信任所有代理』,谁都能把伪造的 IP 喂给应用。没有相应架构时,什么都不信任才是正解。
Q对 IP 头做净化就安全了吗?
注入探测和错误洪流会被止住,但那只是卫生处理(止血的对症疗法)。即便能整理成『合法的 IP』,也不保证它就是『真实的 IP』。真实 IP 的信任,始终要靠信任代理的配置来保证。
Q明明改好了 IP 头,怎么又冒出同样的错误?
因为同一个恶意载荷被换装到了另一个入口(例如 User-Agent 头),最终又抵达了同一个保存列。一个个去堵头会变成打地鼠。请从『不可信的值最终会抵达哪个 DB 列/输出位置』反推,把通向那里的入口(IP 系+User-Agent 等)一并净化。