开了 IPv6 后单 IP 限流几乎失效,因为地址几乎是无限的。本文用 10 个问答讲清 IPv6 CC攻击的特点、按 /64 聚合限流怎么落地、Nginx 与内核防火墙各自能做什么,附日志聚合脚本。
Q:开了 IPv6,为什么原来的单 IP 限流不管用了?
- IPv6 的一个用户通常拿到一整段 /64,地址数量是天文数字,换地址的成本几乎为零。
- 原来的
limit_req_zone $binary_remote_addr只锁一个地址,现在每个请求换一个新地址,每个键只出现一次,永远碰不到阈值。 - 还有一个容易忽略的副作用:IPv6 的键是 16 字节,同样大小的 zone 能装下的状态数大约只剩 IPv4 的一半,高并发下更早触发内存告警。
- 结论:IPv6 下限流的单位必须从「地址」换成「前缀」,否则限流形同虚设。
- 更麻烦的是,很多自建封禁脚本是按 IPv4 写的,遇到带冒号的地址直接跳过,攻击在自己的工具链里变成了隐形流量。
Q:IPv6 下的 CC 攻击有哪些不一样的表现?
- Top IP 排行榜失效:所有攻击地址的请求数都只有个位数,按 IP 排序看不出任何异常。
- 聚合后才现形:把同一个 /64 的请求加起来,一小时能到几千上万次。
- UA、URL、Referer 高度一致:地址在变,请求指纹不变,这是 IPv6 攻击最好抓的特征。
- 监控指标得跟着改:从「单 IP QPS」改成「单 /64 每分钟请求数」,否则看板上永远风平浪静。
- 攻击经常是双栈混合的,IPv4 那侧被限住了,压力会整体挪到 IPv6 上。
Q:那直接封 /48 或者整段是不是更快?
- /64 通常是分配给终端用户的最小单位,封一个 /64 大致等于封一户家宽或一台云主机,误伤面可控。
- 再往上就是 /48、/32,那可能是一整个运营商或一个机房,一封就是一片正常用户,代价远大于收益。
- 移动网络和部分云厂商的分配策略不一样,封之前要确认这个 /64 下的地址是否只服务一个客户。
- 原则:只聚合到 /64,不往更粗走;宁可多封几条,也不要一刀切一大段。
Q:Nginx 能直接按 /64 限流吗?
- 不能原生做。
limit_req_zone的键只能是一个变量的值,没有「取前缀」这种指令。 - 继续用
$binary_remote_addr,就是每个地址独立计数,等于没有聚合。 - 用
map加正则截取前四段,只对完整展开写法有效,遇到压缩写法(带双冒号那种)就漏,同一个前缀会被算成两个键,攻击者稍微变形就绕过去了。 - 所以聚合位置只有三个可选:CDN/WAF 侧、OpenResty 用 Lua 算、源站内核防火墙。前两个能做实时限流,最后一个只能做事后封禁。
Q:OpenResty 里怎么算出 /64 前缀当限流键?
- 思路:取客户端地址的二进制形式,前 8 字节就是 /64 前缀,直接当限流键用。
- 必须用
set_by_lua_block,它在 rewrite 阶段执行,早于limit_req所在的 preaccess 阶段;写成access_by_lua_block就来不及了,限流键取到的还是旧值。 - IPv4 的二进制是 4 字节,原样返回,同一套配置双栈通吃。
# http 块
limit_req_zone $cc_key zone=cc6:20m rate=10r/s;
# server 或 location 块
set $cc_key "";
set_by_lua_block $cc_key {
local bin = ngx.var.binary_remote_addr
if bin and #bin == 16 then
return string.sub(bin, 1, 8) -- 前 8 字节 = /64 前缀
end
return bin or ""
}
limit_req zone=cc6 burst=20 nodelay;
limit_req_status 429;
rate=10r/s是「单个 /64 每秒 10 个请求」的意思,按你站点的正常人均访问量定,不要照抄。- 改成前缀键之后,一个 /64 只占一个状态,zone 反而更省,20m 装几万个前缀够用,不必按 IP 数量去估算。
- 同样是 429 而不是 503,503 会被 CDN 缓存成故障页,误伤整站。
Q:源站层面怎么按 /64 直接封?
- Nginx 限流只是「减速」,真要止血还得在内核层丢包,成本最低。
- 用 nftables 建一个带自动过期的集合,封禁 30 分钟后自动失效,避免永久误伤。
- ip6tables 是老写法,能用但不推荐,规则多了性能差、维护也乱。
# 建表、建带 30 分钟过期的集合、挂到 input 链
nft add table inet ccguard
nft add set inet ccguard block6 '{ type ipv6_addr; flags interval; timeout 30m; }'
nft add chain inet ccguard input '{ type filter hook input priority -10; policy accept; }'
nft add rule inet ccguard input ip6 saddr @block6 counter drop
# 封一个 /64(只封这一段,别扩到 /48)
nft add element inet ccguard block6 '{ 2001:db8:abcd:12::/64 }'
# 老写法,效果一样
ip6tables -I INPUT -s 2001:db8:abcd:12::/64 -j DROP
- 封禁清单要能一键回滚,写进脚本执行,别在命令行里手敲,敲错一位就是一个网段。
- 封禁时长从 30 分钟起步,同一前缀反复出现再翻倍,不要一上来就封一天。
Q:日志里怎么找出正在打的 /64?
- 按 IP 排序的做法在 IPv6 下完全失效,必须先把地址归到前缀再统计。
- 脚本只处理带冒号的地址,IPv4 请求不参与,避免把正常流量混进来。
- 输出结果直接能转成 nftables 命令,省掉手抄环节。
import ipaddress, collections
c = collections.Counter()
for line in open('/var/log/nginx/access.log', errors='ignore'):
p = line.split()
if not p:
continue
ip = p[0]
if ':' not in ip:
continue # 只看 IPv6
try:
net = ipaddress.ip_network(ip + '/64', strict=False)
except ValueError:
continue
c[str(net)] += 1
for net, n in c.most_common(10):
print(n, net)
- 如果前面套了 CDN,日志第一列是节点地址,要先取
X-Forwarded-For里的第一个地址再聚合。 - 统计窗口建议 5 到 10 分钟,太长会把正常爬虫也算成攻击。
Q:CDN 后面的 real_ip 对 IPv6 有什么坑?
- 不配
set_real_ip_from的 IPv6 回源段,$binary_remote_addr全是 CDN 节点地址,所有用户合并成一个键,限流直接把全站限死。 - 这是「一开限流全站 429」最常见的原因,先查这一步再怀疑规则。
- 回源段要写全,CDN 的 IPv6 节点段经常比 IPv4 段多,漏写一个段就有一批用户被合并计数。
set_real_ip_from 2001:db8:aaaa::/48;
set_real_ip_from 2001:db8:bbbb::/48;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
- 如果回源头里的 IPv6 被加上方括号,realip 有可能解不出来,表现是日志里清一色节点地址,用
remote_addr和X-Forwarded-For对照一看就知道。 - realip 在 post-read 阶段生效,早于限流,顺序本身没问题;但限流键如果走 Lua,仍要确认 Lua 执行阶段在限流之前。
Q:按 /64 聚合会不会误伤正常用户?
- 一个 /64 通常就是一户,误伤面比按 /32 小得多;但企业专线、酒店、学校可能一个 /64 覆盖很多人。
- 先观察后封禁:先生成候选清单,人工确认再分批下发,别让脚本自动封。
- 白名单优先:搜索引擎、监控探针、合作方回源段先进白名单,再谈封禁。
- 封禁粒度配合时长:短封加复查,比长封加遗忘安全得多。
- 封之前先数一下这个前缀下有多少个不同地址在访问,几百个地址同时打,基本能确认是攻击而不是共享出口。
Q:还有什么补充建议?
- 双栈一起看。攻击者常用 IPv4 试探、IPv6 主力,只封一侧等于留了个后门。
- 监控里加一条「单 /64 每分钟请求数」,比单 IP QPS 有用得多,这是 IPv6 时代的核心指标。
- 带宽计费不分协议栈,IPv6 打进来一样算钱,别以为换了协议就不用管流量账单。
- 最后一句:IPv6 下防 CC,关键不是封得快,而是聚合的单位选对,单位错了,规则写得再狠也白费。