第112题:IP 白名单 CIDR 匹配掩码取「低位」而非「网络位」—— 名单里的调用方一个都进不来,名单外的却进了门
日期:2026-10-09 分支:
2026-10-09-0000Issue:#129 技术栈 / 关键词:IPv4 CIDR 匹配、子网判断、netmask / 网络掩码、位运算、 低位掩码(1 << plen) - 1、IP 白名单失效 / ACL 越权放行、掩码方向错、 自述字段全绿、空判据表BAD_FIELDS = ()、退出码恒 0、 Pythonipaddress参考实现、Docker Compose、fail-closed 健康检查。
一、问题背景
aclgw 是 API 网关的调用方白名单(ACL)判定服务:每条进站请求带 client_ip,
网关查白名单 conf/allowlist.txt(每行一条 CIDR,如 10.20.0.0/16),
命中任意一条即 allow,一条都不命中且 ACL_DENY_BY_DEFAULT=1 则 deny,
判定结果落进账本 state/acl-ledger.ndjson。
上线后同时收到两个方向的投诉,方向恰好相反:
- 名单里的调用方一个都进不来:办公网
10.20.5.7、风控内网10.30.7.44、 核心区172.16.9.9(以及10.20.199.3、172.31.0.7)全部 403; - 名单外的客户端却进了门:
192.168.0.0压根不在白名单里,却被判成allow。
而网关自述一切正常,四盏灯全亮:
{"ok": true, "self_consistent": true, "mode": "network_prefix",
"evaluated": 10, "allowed": 3, "denied": 7, "errors": []}
demo.py 退出码 0、reports/report.json 里 ok: true、self_consistent: true、
health 输出 {"degraded": false, "issues": []} 且退出码 0。
但没有一件指向真相。 唯一说真话的是外部独立探针(只读契约 + 白名单 + 账本,
用 ipaddress 按契约口径独立重算,不读任何自述字段):
漏放行(名单内的进不来): 5 ['r-001', 'r-002', 'r-003', 'r-005', 'r-102']
越权放行(名单外的进了门): 1 ['r-101']
RESULT: BAD ← rc=1
二、复现步骤
cd scenario/2026-10-09-0000
docker compose up -d --build
docker compose exec -T aclgw bash ops/repro.sh
问题态关键输出:
=== 2) 跑一轮判定(写入方自述) ===
{"ok": true, "self_consistent": true, "evaluated": 10, "allowed": 3, "denied": 7, "errors": []}
demo.py rc=0
=== 3) 外部独立探针 ===
漏放行(名单内的进不来): 5 ['r-001', 'r-002', 'r-003', 'r-005', 'r-102']
r-001 10.20.5.7 应在 10.20.0.0/16 内 → 实际 deny
r-003 10.30.7.44 应在 10.30.7.0/24 内 → 实际 deny
r-005 172.16.9.9 应在 172.16.0.0/12 内 → 实际 deny
越权放行(名单外的进了门): 1 ['r-101']
RESULT: BAD ← acl_probe.py rc=1
=== 4) 健康检查 ===
{"degraded": false, "issues": [], "self_consistent": true, ...}
health rc=0 ← 判据表为空,闸门形同虚设
验收基线:ops/verify.sh → PASS=5 FAIL=10 / RESULT: BAD。
一行命令直达本质(同一网段、两种掩码取法、结论相反):
python3 - <<'PY'
import ipaddress
ip = int(ipaddress.IPv4Address("10.20.5.7"))
net = int(ipaddress.IPv4Address("10.20.0.0"))
plen = 16
host_mask = (1 << plen) - 1 # 0x0000FFFF ← 代码在用的
net_mask = 0xFFFFFFFF - ((1 << (32 - plen)) - 1) # 0xFFFF0000 ← 正确的
print("低位掩码命中:", (ip & host_mask) == (net & host_mask)) # False ← 名单内被拒
print("网络掩码命中:", (ip & net_mask) == (net & net_mask)) # True ← 正确
print("越权样本:", (int(ipaddress.IPv4Address("192.168.0.0")) & host_mask) == (net & host_mask)) # True
PY
三、根本原因
两处缺陷叠加 —— 一个判错,一个瞒错。
3.1 题眼:app/acl.py::_in_cidr() 掩码取的是「低位」
# 按前缀长度取掩码:前缀 16 就取 16 位
mask = (1 << prefixlen) - 1 # ← 低位连续 1(主机位掩码),0x0000FFFF
return (client_ip_int & mask) == (network_int & mask)
- 「落在这个网段内」的正确判据 = 双方按该前缀的网络掩码抹掉主机位后逐位相同;
- 网络掩码是高位连续 1(前缀 16 →
0xFFFF0000,前缀 32 →0xFFFFFFFF); - 而
(1 << plen) - 1是低位连续 1(前缀 16 →0x0000FFFF), 等于把主机位当成了网络位,方向完全反了。
一个错、两个方向同时中招:
| 客户端 | 低 16 位 | 10.20.0.0 低 16 位 | 低位掩码判 | 正确判 |
|---|---|---|---|---|
10.20.5.7 | 0x0507 | 0x0000 | deny ❌ | allow(名单内被拒) |
10.20.199.3 | 0xC703 | 0x0000 | deny ❌ | allow |
192.168.0.0 | 0x0000 | 0x0000 | allow ❌ | deny(名单外进门) |
192.168.0.0 与 10.20.0.0 低 16 位恰好都是 0x0000 → 被判「在同一网段」,
而它实际属于完全不同的网段。前缀 24(10.30.7.0/24)同理:只比低 24 位。
3.2 为什么四个绿灯全亮:app/health.py 判据表是空的
BAD_FIELDS = () # ← 空判据表,for 循环一次都不进
for field, expect in BAD_FIELDS: # 空元组 → 永不执行
...
return 0 # ← 无条件返回 0,退出码恒绿
健康检查只读自述 reports/report.json,从不读事实(账本 / 契约 / 白名单)。
再加上 Engine.self_consistent() 拿「自己写的计数器」比「自己写的账本」,同源必相等,
ok 又是手写常量 True —— 自述字段自证,永远为真。
「自述字段自证 + 空判据表 + 退出码恒 0」比没有检查更危险,因为它静默地骗人。
四、解决办法
只改 app/acl.py 与 app/health.py(conf/、client/、requests/、ops/*.sh 是验收口径,一字未动)。
4.1 app/acl.py —— 掩码取高位连续 1
# 掩码必须取「高位连续 1」:前缀 16 → 0xFFFF0000,前缀 32 → 0xFFFFFFFF。
# 取低位 (1 << prefixlen) - 1 是把主机位当网络位,掩码方向反了。
if not 0 <= prefixlen <= 32:
raise AclError("白名单条目前缀长度越界:%r" % prefixlen)
mask = 0xFFFFFFFF - ((1 << (32 - prefixlen)) - 1)
return (client_ip_int & mask) == (network_int & mask)
也可直接用标准库 ipaddress.ip_address(x) in ipaddress.ip_network(cidr) 消除位运算手写风险。
4.2 app/health.py —— 判据落到事实层,且 fail-closed
BAD_FIELDS = (("ok", True), ("self_consistent", True))
- 契约不可用(缺失 / 口径开关非法)→ 打印 issues,退出码 3;
- 白名单 / 账本拿不到 → 记 issue,退出码 2(「判不了」≠「没问题」);
- 逐条用
ipaddress按契约口径独立重算 vs 账本实际判定,统计mismatch,非 0 → 退出码 2; - 自述
evaluated/allowed/denied≠ 账本真实条数 / 计数 → 退出码 2; - 契约口径开关 ≠
network_prefix→ 退出码 2。
退出码分层:0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用。
4.3 清字节码
rm -rf app/__pycache__ client/__pycache__
旧 __pycache__ 会把改动掩盖成「没改」。
五、验证
docker compose exec -T aclgw bash ops/repro.sh # → RESULT: OK, rc=0
docker compose exec -T aclgw bash ops/verify.sh # → PASS=15 FAIL=0 / RESULT: OK
docker compose exec -T aclgw bash ops/negative.sh # → PASS=10 FAIL=0 / RESULT: OK
修复后对照:
| 指标 | 问题态 | 修复后 |
|---|---|---|
| 探针 | RESULT: BAD(漏 5 / 越权 1),rc=1 | RESULT: OK(漏 0 / 越权 0),rc=0 |
allowed / denied | 3 / 7 | 7 / 3 |
health(账本被写坏) | rc=0(绿,漏报) | rc=2(红,抓到 mismatch 1 条) |
health(契约缺失) | rc=0(绿,漏报) | rc=3 |
verify.sh | PASS=5 FAIL=10 / BAD | PASS=15 FAIL=0 / OK |
negative.sh | — | PASS=10 FAIL=0 / OK |
负向验证证明闸门真的有牙(不是「恰好绿」):
[PASS] A 探针转红(rc=1) / A health 转红(rc=2) ← 掩码改回低位即被抓
[PASS] B 空判据表+不读事实导致 health 失去判别力(rc=0,实测复现)
[PASS] C health 转红(rc=2) ← 只改账本不动代码也被抓
[PASS] D/E 口径非法 / 删契约 → rc=3
[PASS] F 白名单缺失 → demo rc=2(非 0,不当「空名单全放行」)
六、经验与避坑
- 「掩码」必须问一句方向 ——
(1 << n) - 1是低位(主机位)掩码, 网络掩码是 高位连续 1:0xFFFFFFFF - ((1 << (32 - n)) - 1)。 两个表达式都叫「mask」、都不抛异常、类型也正确,只差方向。 能不用手写位运算就不用,交给ipaddress。 - 方向错会同时产生两个相反方向的症状(该进的进不来 + 不该进的进了门)。 只看到一个方向时容易怀疑数据,看到两个方向同时出现就该怀疑判据本身。
- 先看事实,再看自述:
state/acl-ledger.ndjson(实际判定) → 用ipaddress按契约口径自己重算 → 最后才看reports/report.json。 - 空判据表
BAD_FIELDS = ()+ 无条件return 0= 关掉检查。 健康检查的判据必须落在能被外部按契约独立重算的事实上,不能拿自己的计数器自证。 - 「判不了」≠「没问题」:契约 / 白名单 / 账本拿不到时必须非 0(fail-closed), 否则「取不到数据」会被当成「一切正常」。
- 口径开关型契约读不到或写错必须大声失败(rc=3),不许静默按某一种认。
__pycache__会骗人:改完app/*.py先rm -rf app/__pycache__。- 脚本退出码会串味:
ops/repro.sh以探针退出码为结尾exit, 问题态下docker compose exec返回 1 —— 这是预期的复现信号,; echo rc=$?接住即可。 - 别改验收口径:
conf/、client/、requests/、ops/*.sh是考卷;state/、reports/是运行期产物(已 gitignore),手改账本让闸门变绿等于造假。
一句话自查:你的掩码抹掉的是高位还是低位?判据表是不是空的?「判不了」的时候是放行还是拒绝?
影响面:一切 IP / 网段准入(ACL 白名单、安全组、iptables -s、K8s NetworkPolicy、
WAF 地域/内网限制)、一切 CIDR 归属判断(10.0.0.0/8 内网识别、GeoIP 段匹配、
限流按网段聚合)、ipaddress / netaddr / inet_pton / inet_aton / s_addr 与位序、
以及一切「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查。
