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。输入不会被解释为指令 → 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 保存的前一刻检查 → 所有值都必须经过的地方 → 无法绕过
于是改变做法,决定在出口(写入 DB 的前一步)守住。继承会话保存类,只替换生成保存值的那部分(概念上):保存用 IP『若非合法 IP 则为 null』,保存用 User-Agent『剔除非法字节并限制长度』。无论客户端 IP 判定在内部怎样运作,写入 DB 的前一刻都一定会检查,所以无从绕过。把恶意头沿两条攻击路线各发送十几次,错误也没有增加,正常用户的会话也一切正常。
本站的视角:最终检查应放在出口(使用的地方)
安全的基本原则是『不要相信输入,在使用的地方(出口)务必校验・转义』。入口的检查在『尽早拦截』上有效,但对于框架会在内部解析・加工的那类信息(客户端 IP 判定等),它可能绕过入口检查而再次出现。在传给 DB・HTML・命令等危险位置的前一刻,是所有值都必须经过的地方,所以最终的校验・转义应放在这里。入口与出口是两回事,两者都要做。
教训:带着明显错误被拒绝更安全
这一次,攻击不仅失败,还以大量错误通知的形式显现出来。这并不是坏结果:比起不知不觉地被放行,带着明显的错误被拒绝更安全。不要把错误通知当成噪声丢掉,而要用它来察觉异常——不止步于『实际损害已防住』,而是去修复『为什么会一路抵达内部』,这才是本站的立场。
接下来阅读
- 示例:CVE-2026-102895 GPUStack(信任请求来源的认证,让未认证的一方能在 AI 平台上冒充管理员)
- 术语:SQL 注入是什么 / XSS 是什么 / SSRF 是什么
- 入门:安全超入门:.env 与 API 密钥
- 用在哪里:速率限制与滥用防护(按哪个 IP 计数取决于你如何处理 XFF)
- 整体情况:Next.js 和 React 的漏洞多吗?(框架内置代理功能中发现的问题)