秒杀系统高并发优化实战(C++ / Drogon):3.11 X-Forwarded-For 取首段的陷阱:一次真实的注册频控绕过
3.10 那篇(注册安全加固:同 IP 注册频控 + 反代真实客户端 IP 解析)里,我为了拿到反代后的客户端 IP,写了一段"取 X-Forwarded-For 首段"的代码,并留下一句结论:最左首段才是最原始客户端。
这篇是勘误。
那句话不是"不够严谨",是错的——而且错得挺危险:它等于把同 IP 注册频控的 key,直接交给了调用方决定。攻击者不需要任何工具,一行请求头就能让频控失效。
更值得记的是,我当初为什么会这么写——不是没查,而是查错了地方。
配套代码:
src/controllers/UserController.cc的clientIp()(改用drogon::plugin::RealIpResolver::GetRealAddr)、config.json的plugins段(trust_ips/from_header)、src/service/RegisterGuard.{h,cc}(被绕过的同 IP 频控);验证脚本scripts/verify-311-real-ip.sh(静态检查 + 编译)、scripts/e2e-311-real-ip.sh(两相端到端,含伪造 XFF 复现)。仓库:https://github.com/Hespethorn/seckill-cpp。相关上游 issue:RealIpResolver的 IPv6 缺口(drogonframework/drogon#2596)。
一、是什么、坑在哪、本质一句话
是什么:X-Forwarded-For(XFF)是代理逐跳追加的客户端地址链,形如 A, B, C。本节要回答一个问题:这条链里,哪一段才是真的客户端地址?
坑在哪:
- 首段不是"最原始客户端",是"客户端自称的地址"。它由发起请求的人决定,服务端在解析前没有任何办法验证。
- Nginx 最常见的配置恰好把伪造值放大:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;是追加而不是覆盖。客户端自带一个 XFF 时,结果是伪造值, 真实客户端IP——真实地址被挤到了右边。 - 直连场景更彻底:没有代理追加,XFF 里从头到尾都是客户端自己写的,服务端照单全收。
- 误判"框架不提供该能力":我写
req->getClientIp()编译报错,翻HttpRequest.h也确实找不到,于是断定 Drogon 不内建从 XFF 解析真实 IP 的能力,只能自己写。这个判断是错的——答案不是HttpRequest上的方法,而是独立插件drogon::plugin::RealIpResolver。
本质一句话:X-Forwarded-For 不是"客户端地址列表",而是一串自称——只有从可信代理那一侧往左数,第一个不可信的地址才是可信的。
二、XFF 到底是谁写的
要理解坑在哪,先得看清这条链是怎么长出来的。
XFF 的语义是"每一跳把自己的上游地址记下来"。真正可信的只有一件事:离你最近的那个 TCP 对端(getPeerAddr() 的结果),因为那是内核告诉你的、伪造不了的连接来源。至于连接上带的那串 XFF——在验证之前,它全是字符串。
两条链路的请求头涨得一模一样——服务端光看 XFF 值,根本分不出哪条是真的。这就是"取首段"最致命的盲点:它假设了客户端一定是诚实的。
那么真实地址在哪?在伪造链路里,1.2.3.4 是 Nginx 亲眼看到的 TCP 对端地址,攻击者伪造不了;在正常链路里,它就是首段。唯一的区别是位置——而位置取决于"链上哪一跳开始不可信"。
三、攻击复现:一行 header 绕掉同 IP 注册频控
3.10 的频控设计是这样的:key 取 reg:ip:<ip>,窗口 1 小时内最多成功注册 5 次,超限返回 429。防线成立的前提是——<ip> 得是攻击者改不动的东西。
而当时的 clientIp() 取的是 XFF 首段。于是:
1 | # 每次换一个 X-Forwarded-For,频控计数永远落在新 key 上 |
每轮请求的 key 分别是 reg:ip:10.1.1.1、reg:ip:10.1.1.2……每个 key 都只被用了一次,max_per_ip=5 的上限永远碰不到。防线形同虚设。
说明两点,避免误导:
① 本文只针对 IP 频控这一道防线。验证码链路另有自己的限流(按发送节奏与每日总量),那条不受本问题影响——但它防的是"拿码成本",不是"同 IP 建号数量"。
② 走上 Nginx 也救不了:$proxy_add_x_forwarded_for产生的10.1.1.x, 真实IP里,首段依旧是伪造值。
这里最反直觉的一点是:这段代码在直连压测下永远是"正常"的。因为直连时没有 XFF,clientIp() 走的是回退分支 getPeerAddr()——拿到的就是真实地址,频控工作得好好的。我当时的 WSL + JMeter 压测基线全绿,一次都没暴露这个问题。它只在"真实部署 + 有人故意伪造"的组合下才会现形。
四、正确做法:先验代理,再从右往左
正确解析要两步,缺一不可。
第一步:确认直连方是可信代理。 如果 TCP 对端本身就不在可信代理名单里,那么 XFF 里的任何内容都不该采信——直接用 getPeerAddr()。这一步挡掉的是"直连伪造"。
第二步:从右往左遍历,跳过可信代理,取第一个不可信 IP。 因为右边那几段才是代理亲眼所见、攻击者够不着的。这一步挡掉的是"反代链路上的伪造"。
这套逻辑不用自己写——Drogon 官方插件 RealIpResolver 就是干这个的,v1.9.10 起内置。它的核心实现(lib/src/RealIpResolver.cc):
1 | // 第一步:直连方不是可信代理 → 压根不看 XFF |
于是在 config.json 里启用它:
1 | { |
这里 trust_ips: [] 是直连部署(前面没有反向代理)的正确值——列表为空则 matchCidr 永远不成立,X-Forwarded-For 一律被忽略,谁也没法伪造。将来真挂上 nginx,再把它的地址填进去。
业务侧那 20 行手写解析整体删掉,缩成一行:
1 | // src/controllers/UserController.cc |
顺带发现,已提上游:这个插件在 IPv6 下还有两个更隐蔽的缺口。
①trust_ips里填 IPv6 地址,拿到的是Bad ipv4 address: 2001:db8::1——把"合法但不受支持"说成了"地址格式错误"(那句Ipv6 is not supported by RealIpResolver.因为 trantor 的字符串构造不识别协议族,实际不可达)。
②X-Forwarded-For里的 IPv6 条目会被静默丢弃:parseAddress用find(':')区分 host/port,与 IPv6 语法冲突,解析出的地址isUnspecified()为真,在遍历循环里被continue跳过,最终回退成 TCP 对端——也就是代理的地址。
后果和「trust_ips填窄」同形状:反代后所有 IPv6 客户端收缩成同一个频控 key,而且日志里一切正常。dual-stack 部署下这不是边角场景。
自 2022-07 插件合并(PR #1321)至今一直如此。已提 issue:drogonframework/drogon#2596。
(内容来自读上游源码推导,未跑复现——issue 里也是这么标注的。)
五、"编译过了"不算数:两相端到端实测
修完这段代码,我先跑静态检查 + 编译,全过。但编译只能证明它编得过——那时候我还不知道 reg:ip 的 key 到底会不会写着 127.0.0.1,因为我从没在"有人伪造 XFF"的情况下真的跑过一次注册。
于是写了个脚本(scripts/e2e-311-real-ip.sh):起真服务、发真请求、看真 Redis。不过在它跑通之前,我手敲过一版 curl 循环——结论是什么都没验证到。踩了两个坑,都比修复本身值得记。
坑一:频控 key 只在"注册成功"的那一刻才存在
三个响应全是 {"code":1,"msg":"CODE_EXPIRED"},Redis 里一条 reg:ip:* 都没有。回去读 RegisterGuard 才明白:
1 | // kCheckScript:纯读比对,不修改计数 |
check() 只读不写,markSuccess() 才写——而它挂在 INSERT INTO user 的成功回调里。所以验证码开着,请求就止步于 CODE_EXPIRED,key 压根不会被创建。扫出空集看着"没问题",实际零信息量。
要验证频控,得先让注册真的成功:把 auth.require_sms_on_register 关掉。
坑二:本机 curl 的对端,恰好可能就是"可信代理"
这条是我自己的配置埋的雷:trust_ips 里写着 ["127.0.0.1"],而我的 curl 就是从 127.0.0.1 发出的——插件把我当成了可信代理,于是采信了我伪造的 XFF。(验证跑通后,我把它改成了 [],理由见上面「正确做法」那段。)
第一版脚本只跑一次、期望"key 恒为 reg:ip:127.0.0.1",照那个跑会直接误报"修复没生效"。所以脚本改成跑两相,把这个契约摆到台面上:
1 | ================================================================== |
两相都 PASS,因为它们说的是两个不同命题:
trust_ips |
伪造的 XFF | 结论 | |
|---|---|---|---|
| A 相 | [] |
被忽略,key 恒为 reg:ip:127.0.0.1,计数 3 |
直连部署下,伪造没有入口 |
| B 相 | ["127.0.0.1"] |
被采信,key 变成 reg:ip:10.1.1.{1,2,3},各记 1 |
信任列表里的来源,其 XFF 说了算 |
B 相看着像"洞还在",其实不是——它只是把 trust_ips 的契约演示了一遍:你声明谁是代理,就等于让那个来源决定频控 key。这套配置本身没错,错的是把它用在"根本没有反代"的场景里;那种场景该填 [](也就是 A 相)。
为什么值得写脚本,而不是手敲几条 curl
因为上面两个坑手敲必踩:第一个让测试静默失效(看着绿,其实没测到),第二个让结论直接反掉(把配置契约当成了漏洞)。而且验证本身需要状态管理——起服务、关验证码、清 key、保证手机号唯一、收尾还原配置:
1 | ==> 收尾 |
本质一句话:验证一个安全修复,不是"跑一遍看它没报错",而是设计一个它本该失败的反例,然后看它有没有真的失败。
六、对比表
| 方案 | 直连伪造 XFF | 反代后的真实 IP | 多级代理链 | 代码量 |
|---|---|---|---|---|
getPeerAddr() 直接用 |
安全(伪造无效) | ✗ 拿到代理 IP,频控全员共享一个 key | ✗ 同上 | 1 行 |
| 手取 XFF 首段(原实现) | ✗ 完全绕过 | ✗ 拿到伪造值($proxy_add_x_forwarded_for 追加语义) |
✗ 拿到伪造值 | 约 20 行 |
| 手取 XFF 末段 | 安全 | ✗ 多级代理时拿到的是倒数第二跳代理 | ✗ 同上 | 数行 |
RealIpResolver(现实现) |
安全(先验 peer) | ✓ 正确 | ✓ 从右往左逐个跳过 | 1 行 + 配置 |
只有最后一行同时满足"防伪造"和"反代后正确"。
功能抉择(本篇核心权衡)
① 为什么直接用官方插件,而不是把自己写的那版改对?
手改一版其实不难——核心就是"验 peer + 从右往左"两段逻辑。但我选插件,理由是安全代码的维护成本不对称:
- 手写版要自己实现 CIDR 匹配(
trust_ips通常是一整个网段,不是单个 IP)。我最初那版只做了单 IP 相等比较,看着能用,实际部署里网段一改就漏。 - 手写版还要处理边界:XFF 里混入非法 IP、端口后缀(
1.2.3.4:5678)、多余空格、超长链。官方的XForwardedForParser+parseAddress已经把这些都覆盖了。 - 最关键的是:这属于"我错了会挨打"的代码。放在上游,有更多人 review、更多场景压过;放在自己项目里,只有我一个人的盲区。
顺带一提,我也确认过它的降级行为是安全的:插件未注册、或 pre-routing 阶段没跑到时,GetRealAddr 内部会回退到 getPeerAddr()——最坏情况退化成"不用 XFF",而不是"乱用 XFF"。这个方向对了。
② 为什么 trust_ips 必须按真实部署精确配置?
填错的后果常被误解,而且不止一个方向。要理解它,得先接受一件事:trust_ips 的语义不是"白名单",而是**"我信任谁的 X-Forwarded-For"**——你声明谁是代理,就等于把"由谁来决定频控 key"交了出去。上游实现把这件事写得很直白:
1 | if (ipHeaderFind == headers.end() || !matchCidr(peerAddr, trustCIDRs_)) { |
所以填错有两个方向,代价完全不同:
- 填窄了(对端不在列表内):走上面第一个分支,插件忽略 XFF、退回 TCP 对端地址。伪造依然无效——这一侧确实是安全的。代价全在可用性:反代与 Drogon 同机时
127.0.0.1是对的;一旦反代跑在 Docker 里(比如172.17.0.2),而trust_ips还留着127.0.0.1,那么所有请求都会退化成同一个 Docker 网关 IP——同 IP 频控从"防刷号"变成了"一小时内全站只能注册 5 次"。这是典型的安全策略配置错误导致的全量误伤,比漏洞本身更难排查,因为日志里一切正常。 - 填宽了(把客户端也能连到的地址、甚至
0.0.0.0/0放进列表):对端命中信任列表,插件就会采信这个来源的 XFF、从右往左取第一个不可信 IP 当客户端地址。伪造当场生效——花半小时修好的洞,一行配置就还回去了。
我最初只想到第一个方向,甚至写过"填错不会放大安全风险"这种话。那句话只在"填窄"时成立,我把一半结论当成了全部。
至于"直连、前面根本没有反代"该怎么填:trust_ips: []——本项目当前就是这个值。列表为空 → matchCidr 永远不成立 → XFF 一律忽略,彻底没有伪造空间。一个不存在的代理,不该出现在信任列表里。
③ 既然不能取首段,为什么还要让 Nginx 配 XFF?
因为**"服务端怎么解析"和"链路怎么记录"是两件事**。XFF 仍然是链路可回溯性的唯一来源——排查问题时你需要看到完整的代理链;只是这条链必须从可信的一端开始读,而不是从不可信的一端。
小结
X-Forwarded-For是逐跳自称的地址链,首段由调用方控制、可任意伪造;取首段做频控 key,等于把 key 交给攻击者。- Nginx 的
$proxy_add_x_forwarded_for是追加语义,会把伪造值留在最左——所以"配了反代"并不能让首段变可信。 - 正确解析需两步:① 先验直连方是否可信代理(不是就完全不信 XFF);② 从右往左跳过可信代理,取第一个不可信 IP。
- Drogon 官方
RealIpResolver插件(v1.9.10 内置)已实现该逻辑;trust_ips的语义是**「我信任谁的 XFF」**——填窄了只是频控全量误伤(可用性问题),填宽了伪造会当场生效(安全问题);直连无前置反代时填[](本项目即如此)。 - 顺带记一个上游缺口:这个插件只支持 IPv4——
trust_ips里填 IPv6 会拿到误导性的Bad ipv4 address,而 XFF 里的 IPv6 条目会被静默丢弃、回退成代理地址;反代后 IPv6 客户端会收缩成同一个 key,与"填窄trust_ips"同形状。已提 drogonframework/drogon#2596。 - "能编过"不等于"验证过":
reg:ip:<ip>只在注册成功那一刻才写入,所以验证脚本必须让注册真的成功(临时关掉注册验证码),并且跑两相——A 相证明不可信来源的伪造无效,B 相把trust_ips的契约边界摆出来。只跑单向、或扫出空集,照样是"绿的"却什么都没测到。 - 最后一个教训跟框架无关:这段错误代码在直连压测下永远是绿的。压测能验证性能,验证不了"有人故意不按规矩发请求"——安全假设必须单独想一遍;而且**"想一遍"不够,得设计一个它本该失败的反例,真的跑一遍**。🐾
配套源码
本文对应代码已开源:Hespethorn/seckill-cpp
C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:
- 阶段一(直打数据库):QPS≈440,0 超卖
- 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS
如果对你有帮助,欢迎 star ⭐

