朕喜技术(ZhenxiJS)
2026.10.09
1 分钟阅读

第112题:IP 白名单 CIDR 匹配掩码取「低位」而非「网络位」—— 名单里的调用方一个都进不来,名单外的却进了门

日期:2026-10-09 分支:2026-10-09-0000 Issue:#129 技术栈 / 关键词:IPv4 CIDR 匹配、子网判断、netmask / 网络掩码、位运算、 低位掩码 (1 << plen) - 1、IP 白名单失效 / ACL 越权放行、掩码方向错、 自述字段全绿、空判据表 BAD_FIELDS = ()、退出码恒 0、 Python ipaddress 参考实现、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.70x05070x0000deny ❌allow(名单内被拒)
10.20.199.30xC7030x0000deny ❌allow
192.168.0.00x00000x0000allow ❌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=1RESULT: OK(漏 0 / 越权 0),rc=0
allowed / denied3 / 77 / 3
health(账本被写坏)rc=0(绿,漏报)rc=2(红,抓到 mismatch 1 条)
health(契约缺失)rc=0(绿,漏报)rc=3
verify.shPASS=5 FAIL=10 / BADPASS=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. 「掩码」必须问一句方向 —— (1 << n) - 1 是低位(主机位)掩码, 网络掩码是 高位连续 1:0xFFFFFFFF - ((1 << (32 - n)) - 1)。 两个表达式都叫「mask」、都不抛异常、类型也正确,只差方向。 能不用手写位运算就不用,交给 ipaddress。
  2. 方向错会同时产生两个相反方向的症状(该进的进不来 + 不该进的进了门)。 只看到一个方向时容易怀疑数据,看到两个方向同时出现就该怀疑判据本身。
  3. 先看事实,再看自述:state/acl-ledger.ndjson(实际判定) → 用 ipaddress 按契约口径自己重算 → 最后才看 reports/report.json。
  4. 空判据表 BAD_FIELDS = () + 无条件 return 0 = 关掉检查。 健康检查的判据必须落在能被外部按契约独立重算的事实上,不能拿自己的计数器自证。
  5. 「判不了」≠「没问题」:契约 / 白名单 / 账本拿不到时必须非 0(fail-closed), 否则「取不到数据」会被当成「一切正常」。
  6. 口径开关型契约读不到或写错必须大声失败(rc=3),不许静默按某一种认。
  7. __pycache__ 会骗人:改完 app/*.py 先 rm -rf app/__pycache__。
  8. 脚本退出码会串味:ops/repro.sh 以探针退出码为结尾 exit, 问题态下 docker compose exec 返回 1 —— 这是预期的复现信号,; echo rc=$? 接住即可。
  9. 别改验收口径: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」的假绿健康检查。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-09-0000-cidr-0

分享这篇文章