Unix 域套接字作业代理授权判定被套接字文件权限绑架:拿 os.stat(sock).st_gid 当依据 + 采信客户端自述 groups,非 ops 主组的调用方全被受理而四面绿灯
日期:2026-10-09 | 环境:Docker(
python:3.11-slim-bookworm+ 纯标准库)+ Linux Unix 域套接字 | 技术栈:AF_UNIX / SOCK_STREAM、SO_PEERCRED、struct ucred {pid,uid,gid}、/proc/<pid>/status的Gid:/Groups:、主组(primary gid)vs 补充组(supplementary groups)、 套接字文件权限位(0660/chmod 0666)、os.stat(sock).st_gid、fail-closed、 空判据表BAD_FIELDS = ()、退出码分层(0/2/3)、自述字段自证 关键词:unix domain socket 权限绕过、SO_PEERCRED 主组、getpeereid、ucred gid、 补充组不代表主组、套接字文件属组、客户端伪造 groups、越权放行、 健康检查 fail-open、退出码恒 0、授权判定独立于文件权限
一、问题背景
brokerd 是内网构建机上的本地作业代理:客户端通过 Unix 域套接字
(AF_UNIX + SOCK_STREAM)连上来提交编译/打包作业。
谁能用它,由平台安全组与运维联合下发的只读契约 conf/brokerd.env 规定:
BROKER_SOCKET=/srv/state/broker.sock
BROKER_ALLOWED_GROUP=ops
BROKER_SOCKET_MODE=0660
BROKER_PEER_ID_SOURCE=kernel
BROKER_ALLOWED_UIDS_DB=/srv/conf/allowed_uids.txt
BROKER_ACCEPT_CRITERION=kernel_peercred_and_group_and_uid
契约页 docs/broker-contract.md 第 2 节写明「一次合规受理」的判据是
三条件与关系:
- 凭据来源必须是内核(
peer_id_source == kernel); - 内核给出的主组(primary gid,即
ucred.gid)必须等于契约允许组ops; - 内核给出的 uid 必须在白名单里。
第 3 节还特意提醒:
0660 +
BROKER_ALLOWED_GROUP不是访问控制的唯一手段,它只是「第一道门」。 权限位随时能被改(chmod);判据第 2/3 条才是真正的授权判定, 必须独立于套接字权限生效。
现场现象
安全审计抽查发现:一个主组是 builders、只是碰巧还在 ops 补充组里的服务账号
(jobbuilder,uid 3002),提交的作业被代理全盘受理了。而代理侧:
python3 app/report.py退出码 0,日志正常,reports/broker_report.json是合法 JSON;- 自述报告写着
accepted: 3、rejected: 0、ok: true、self_consistent: true; - 健康检查输出
{"accepted": 3, "degraded": false, "issues": [], "rejected": 0}、退出码 0。
唯一说真话的是外部独立探针 client/broker_probe.py(只读契约 + 审计日志真实行 +
套接字文件属性,不读代理任何自述字段):
套接字 /srv/state/broker.sock 存在:mode=0660(契约 0660)gid=2000
审计日志真实行数 = 3
受理判定与契约口径不一致:uid=3002(jobbuilder) 期望=reject 实际=accept
漏放行 / 误放行 = 1 条
RESULT: BAD(rc=1)
四个绿灯里没有一件指向真相:「一轮跑完了」只证明进程没崩;
self_consistent: true 是写死常量;degraded: false 来自空判据表;
退出码 0 来自无条件 return 0。
二、复现步骤
cd scenario/2026-10-09-1600-2
docker compose up -d --build
docker compose exec -T brokerd bash -c 'cd /srv && bash ops/repro.sh; echo "rc=$?"'
问题态输出(要点):
[client] jobops uid=3001 groups=2000 -> connected
[client] jobbuilder uid=3002 groups=2001,2000 -> connected
[client] jobguest uid=3003 groups=2001 -> connect-failed(rc=1)
stderr: PermissionError: [Errno 13] Permission denied
[client] jobops-backup uid=3004 groups=2000 -> connected
[report] {"accepted": 3, "connections": 3, "ok": true, "rejected": 0, "self_consistent": true}
health rc=0 {"accepted": 3, "degraded": false, "issues": [], "rejected": 0}
受理判定与契约口径不一致:uid=3002(jobbuilder) 期望=reject 实际=accept
RESULT: BAD
repro rc=1
断言式验收集:
docker compose exec -T brokerd bash -c 'cd /srv && bash ops/verify.sh; echo "rc=$?"'
# -> PASS=6 FAIL=4 (红:A3 / C2 / D2 / D3) rc=1
问题态的审计日志(state/broker-audit.ndjson)里,
jobbuilder 那一行 gid 是 2001(内核给出的主组 builders),
reason 却是空字符串 —— 判定根本没看这个 gid:
{"gid": 2000, "peer_id_source": "kernel", "uid": 3001, "verdict": "accept"}
{"gid": 2001, "peer_id_source": "kernel", "uid": 3002, "verdict": "accept", "reason": ""}
{"gid": 2000, "peer_id_source": "kernel", "uid": 3004, "verdict": "accept"}
三、根本原因
四处缺陷叠加、彼此掩护,全都不抛异常。
根因 1 · 授权判定被套接字文件权限绑架(题眼)
app/authorizer.py::_group_allows() 是这样判「调用方属不属于 ops 组」的:
st = os.stat(cfg["socket"]) # 统计的是代理自己建的套接字文件
sock_gid = st.st_gid
if sock_gid == want: # want = ops 的 gid = 2000
return True
套接字是按契约用 0660 + 属组 ops 建出来的(这是正确的第一道门),
于是 os.stat(sock).st_gid == allowed_gid(cfg) 恒为真。
判据退化成了「谁能连上套接字谁就被受理」——
授权判定 = 第一道门的权限位,而契约第 3 节明说这不能当唯一手段。
关键认知:os.stat() 统计的对象是代理这一侧的套接字文件,
它的 st_gid 反映的是「代理把套接字的属组设成了谁」,
跟过来连的调用方是谁毫无关系。
根因 2 · 采信客户端自述的 groups
同一个函数还有兜底:
claimed = cred.get("claimed_groups") # 来自报文 msg["groups"]
if claimed and want in claimed:
return True
claimed_groups 是客户端在报文里自己声明的,客户端想放什么就放什么,
等于没有判据。
根因 3 · 内核主组读到了,但一次都没用上
app/peercred.py::read_peer_cred() 正确地用
getsockopt(fd, SOL_SOCKET, SO_PEERCRED) 拿到 struct ucred{pid, uid, gid},
其中 gid 就是调用方主组;也顺带读了 /proc/<pid>/status 的 Groups: 补充组。
但在 _group_allows() 里,cred["gid"] 一次都没被读 ——
契约字段读进内存后丢在半路。审计日志里明明记着 gid: 2001,判定却没用它。
根因 4 · 健康检查空判据表 + fail-open
app/health.py:
BAD_FIELDS = () # 空判据表
for field, expected in BAD_FIELDS: # 一次都不循环 -> issues 恒为空
...
except ContractError:
return EXIT_OK # 契约读不到 = 「没问题」+ 退出码 0
...
return EXIT_OK # 无条件 return 0
它只读自述报告,从不读审计日志真实行、不读套接字文件属性、不读契约。
根因 5 · 自述字段写死常量
app/report.py 里 "ok": True / "self_consistent": True 是硬编码;
accepted 只是「自己数自己写出来的审计行」,从不与契约口径对账。
根因 6(附带)· 拿不到内核凭据时退化成按自述判
app/broker.py 捕获 PeerCredError 后构造了
{"source": "claimed", ...} 的假凭据继续走授权流程 —— 本该 fail-closed。
四、解决办法
约束:conf/(只读契约)、client/(外部验收口径)、
ops/*.sh(验收脚本)、docs/ 一个字符都不许改;
修复只能落在 app/ 下。改 conf/ 或 client/ 让判据变绿 = 造假。
1) app/authorizer.py —— 判据收敛到内核主组 + 逐条 fail-closed
def _group_allows(cfg, cred):
"""调用方是否属于契约允许组 —— 只认内核给出的**主组**。"""
want = allowed_gid(cfg)
return cred["gid"] == want
def authorize(cfg, cred):
uid, gid = cred["uid"], cred["gid"]
# 1) 凭据必须来自内核
if cred.get("source") != "kernel":
return Decision(REJECT, uid, gid,
"拿不到内核凭据(source=%r),按契约拒绝" % cred.get("source"), ...)
# 2) 内核给出的主组必须等于契约允许组
if not _group_allows(cfg, cred):
return Decision(REJECT, uid, gid,
"调用方主组 gid=%d 不属于契约允许组 %s(gid=%d)" % ..., ...)
# 3) uid 必须在白名单里
if cfg["allowed_uids"] and uid not in cfg["allowed_uids"]:
return Decision(REJECT, uid, gid, "uid %d 不在白名单 %s" % ..., ...)
return Decision(ACCEPT, uid, gid, "", ...)
- 删掉
os.stat(cfg["socket"])那一段(套接字属组不当判据); - 删掉
claimed_groups兜底(客户端自述不当判据); SelfCheck.summary(audit_rows)改为按审计日志真实行算出ok/self_consistent。
2) app/broker.py —— 拿不到内核凭据就 fail-closed
except PeerCredError as exc:
# 拿不到内核凭据 —— fail-closed:按契约拒绝,绝不退化成按自述字段判
cred = {"source": "unavailable", "pid": -1, "uid": -1, "gid": -1,
"groups": [], "error": str(exc)}
msg = _recv_message(conn) or {}
# 客户端自述的 groups 只留作审计旁证,绝不参与授权判定
cred["claimed_groups"] = msg.get("groups")
3) app/report.py —— 自述字段算出来 + 与契约口径独立对账
forged = []
for r in rows:
if r.get("verdict") != "accept":
continue
ok = (r.get("peer_id_source") == "kernel"
and r.get("gid") == want_gid
and (not cfg["allowed_uids"] or r.get("uid") in cfg["allowed_uids"]))
if not ok:
forged.append(r.get("uid"))
report = {..., "forged_accepts": len(forged),
"ok": not forged, # 算出来的
"self_consistent": accepted + rejected == len(rows)} # 算出来的
4) app/health.py —— 非空判据表 + 只读事实 + 退出码分层
EXIT_OK, EXIT_BAD, EXIT_CONTRACT = 0, 2, 3
BAD_FIELDS = (
("bad_verdicts", 0), # 与契约口径不一致的判定条数
("claimed_source_rows", 0), # 凭据来源不是 kernel 的行数
("socket_mode_ok", True), # 套接字权限 == 契约值
("report_matches_audit", True), # 自述计数 == 审计日志真实统计
)
判据全部落在能被外部按契约独立复核的事实上:
契约 + 审计日志真实行 + 套接字文件属性(reports/ 只用于交叉对账):
| 场景 | 退出码 |
|---|---|
| 干净一轮 | 0 |
| 审计日志出现越权放行 / 拿不到事实(无审计日志) | 2(fail-closed) |
| 契约不可用 / 判据非法 | 3 |
五、验证
改完先 rm -rf app/__pycache__(旧字节码会把「已改」掩盖成「没改」);
app/ 是挂载进容器的,docker compose restart brokerd 即可生效。
1) ops/repro.sh + 外部探针 → 双 OK
docker compose exec -T brokerd bash -c 'cd /srv && rm -rf app/__pycache__ && bash ops/repro.sh; echo rc=$?'
[client] jobbuilder uid=3002 groups=2001,2000 -> connected
[report] {"accepted": 2, "connections": 3, "forged_accepts": 0, "ok": true, "rejected": 1, "self_consistent": true}
health rc=0 {"audit_rows": 3, "bad_verdicts": 0, "claimed_source_rows": 0, "degraded": false, "issues": [], "report_matches_audit": true, "socket_mode_ok": true}
用户名 jobguest(uid 3003) 没有留下审计行 —— 非 ops 主组,连套接字都被内核拦住,符合预期
RESULT: OK
repro rc=0
accepted 由 3 → 2、rejected 由 0 → 1。
2) ops/verify.sh → 全绿 PASS=10 FAIL=0
[PASS] A3 jobbuilder(uid 3002,主组 builders,仅补充组含 ops) 被拒绝
[PASS] B1 套接字放开到 0666 后 jobguest 连上了(connect-ok)但仍被拒
[PASS] C2 客户端伪造 groups=[2000] 也不被放行(自述字段不作判据)
[PASS] D2 审计日志里出现越权放行 → health 退出码 2
[PASS] D3 契约不可用 → health 退出码 3(fail-closed)
[PASS] E1 白名单收紧到只剩 3001 → jobops-backup(3004) 被拒(行为随契约变)
PASS=10 FAIL=0
3) ops/negative.sh → 4/4,证明闸门真的有牙
[PASS] A1 注入「看套接字属组」缺陷后,jobbuilder 被越权放行(闸门有牙)
[PASS] A2 外部探针报 RESULT: BAD(rc=1)
[PASS] B1 判据表清空 + 无条件 return 0 → 越权放行时 health 仍退出码 0(实证假绿)
[PASS] C1 契约缺失 → health 退出码 3
PASS=4 FAIL=0
4) 手工构造最恶劣输入 —— 两扇门都撬开
套接字 chmod 0666 + 报文伪造 groups:[2000],主组仍是 builders(2001):
connect-ok, forged groups=[2000]
{"gid": 2001, "peer_id_source": "kernel", "pid": 287,
"reason": "调用方主组 gid=2001 不属于契约允许组 ops(gid=2000)",
"uid": 3002, "verdict": "reject"}
仍被拒 —— 判定确实独立于套接字权限,也确实不采信自述。
5) authorize() 判据矩阵单元测试
claimed uid 3001 gid 2000 -> reject | 拿不到内核凭据(source='claimed'),按契约拒绝
unavailable uid 3001 gid 2000 -> reject | 拿不到内核凭据(source='unavailable'),按契约拒绝
kernel uid 3001 gid 2001 -> reject | 调用方主组 gid=2001 不属于契约允许组 ops(gid=2000)
kernel uid 3001 gid 2000 -> accept |
kernel uid 3003 gid 2000 -> reject | uid 3003 不在白名单 [3001, 3002, 3004]
6) health.py 退出码分层矩阵
| 输入 | 输出要点 | rc |
|---|---|---|
BROKER_CONTRACT=/srv/nope.env | contract_available:false | 3 |
| 无审计日志 | 拿不到审计日志…无法判定(判不了 ≠ 没问题) | 2 |
审计行 uid=3003 accept | bad_verdicts:1 | 2 |
审计行 peer_id_source:"claimed" | claimed_source_rows:1 | 2 |
六、经验与避坑
核心认知
- 内核
SO_PEERCRED只返回主组(ucred.gid),不返回补充组。 契约判据「调用方属于 ops 组」在 Linux 上只有一种可验证的读法:ucred.gid == ops(即/proc/<pid>/status里Gid:的第一列)。Groups:那几列是补充组,不能当依据 —— 「主组是 builders、碰巧还在 ops 补充组」≠「属于 ops 组」。 os.stat(path).st_gid里的st_gid是「文件的属组」,不是「来访问的人的组」。 三类「组」极易混淆,分清它们:- 内核
SO_PEERCRED的ucred.gid→ 调用方主组(授权判据的正解); /proc/<pid>/status的Groups:→ 调用方补充组(旁证,非判据);os.stat(sock).st_gid→ 代理这一侧套接字文件的属组(跟调用方无关)。
- 内核
用第 3 类当判据 = 把「第一道门的门锁型号」当成了「来人的身份证」。
- 套接字权限是门,不是判据。
0660 + chown ops只是第一道门,chmod随时能改;真正的授权判定必须独立于文件权限生效。 测试方式是 B 组那条:把套接字chmod 0666, 一个非ops的调用方连上了也必须被拒 —— 这才叫判定没被绑架。 - 客户端自述字段永远不能当判据。
msg["groups"]/msg["uid"]/X-Forwarded-For/ JWT 里未验签的自定义 claim —— 一个套路。 自述字段只能做旁证/审计留痕。 - 「读到了」≠「用上了」。
read_peer_cred()读出了正确的主组, 审计日志里也记着gid: 2001,但判定函数一次都没读它。 契约字段读进内存却丢在调用链半路,是静默缺陷的经典形态 —— 审计日志里reason是空字符串、gid与verdict明显矛盾,就是第一现场。 - 空判据表
BAD_FIELDS = ()比没有检查更危险,因为它静默地骗人。for field, expected in BAD_FIELDS一次都不循环 →issues恒空 →degraded: false恒假绿。加上except ContractError: return EXIT_OK与main()末尾无条件return 0,健康检查就退化成了给自己发体检合格证。 - 「判不了」≠「没问题」(fail-closed)。 退出码必须分层: 拿不到事实 → 非 0;契约不可用 → 非 0;只有真正干净一轮才 0。
- 不要修过头 —— 安全闸门的方向永远是收紧。
本场景最容易修反的地方是:为了「让
jobbuilder能进」而把判据改成看补充组。 那是放宽,方向反了。修复后jobbuilder(uid 3002 在白名单内、 但主组是builders)就是要被拒。
踩坑记录
rm -rf state/ reports/报Device or resource busy这两个目录是 compose 的 bind mount 挂载点,挂载点本身删不掉。 这是正常噪声(ops/README.md已注明),脚本随后mkdir -p,不影响判定。- 非交互环境
docker compose exec必须加-T否则申请 TTY 失败:the input device is not a TTY,流水线里会卡住。 - 旧
app/__pycache__会把「已改」掩盖成「没改」 症状是:明明改了代码、verify 结果却纹丝不动。 每次改完先rm -rf app/__pycache__(ops/verify.sh内部也调了clean_pyc)。 ops/negative.sh只能跑修复态,且会覆写app/开头grep -q 'return cred\["gid"\] == want' app/authorizer.py硬校验修复态 (注释与代码结构也是契约的一部分,别把_group_allows写变形), 过程里会cp覆写再trap restore EXIT还原。跑完务必git diff app/复核,别把负向脚本的脏改动一起提交。ops/repro.sh以探针退出码为脚本退出码 问题态下返回 1,CI 里表现为「命令失败」—— 这是预期的复现信号,不是环境坏了(用; echo rc=$?接住)。- 排查顺序:先看事实,再看自述。
cat state/broker-audit.ndjson(真实行)→ 亲手按契约口径重算 → 最后才看reports/broker_report.json(自述)。 反过来就会先被ok: true/self_consistent: true骗一轮。 - 提交前
git status --short确认只有预期的app/*.py改动state/、reports/是运行期产物且已 gitignore; 手改审计日志让闸门变绿 = 造假。
自查口诀
你的「调用方属于某个组」这个判断,读的是内核给的主组, 还是文件自己的属组、还是客户端自己说的? 套接字
chmod 0666之后,非该组的调用方还能不能被拒? 判据表是不是空的?拿不到数据时你判通过还是不通过? 退出码恒 0 的那个return,是不是写在所有判据之后?
同类影响面
- 一切 Unix 域套接字 / 本地 IPC 的访问控制:
SO_PEERCRED(Linux)、getpeereid(BSD/macOS)、SO_PEERNAME/LOCAL_PEERCRED、D-BusGetConnectionUnixUser、 systemd socket activation、Docker/containerd 的sock代理、 PostgreSQL/MySQL 本地unix_socket认证、SSH agent / gpg-agent socket; - 一切**「套接字权限当访问控制」**的误用(
0660/0666/chmod后的信任假设); - 一切主组 vs 补充组的判定(
uid/gid白名单、os.setgroups()/setgid的语义、newgrp、 NFSroot_squash、sudoers的组匹配、K8sfsGroup); - 一切采信客户端自述身份的缺陷:
X-Forwarded-For/X-Real-IP、未验签的 JWT claim、 Header 里传 uid/gid/groups、GraphQL 客户端传 tenantId、 gRPC metadata 里的身份字段; - 一切**「自述字段自证 + 空判据表 + 退出码恒 0」**的假绿健康检查与巡检脚本:
BAD_FIELDS = ()、ok: True写死、self_consistent拿自己的计数器比自己、main()无条件return 0、except ContractError: return EXIT_OK。
一句话总结
授权判定必须落在内核给出的身份上,且必须独立于文件权限生效 —— 把「文件属组」当「调用方主组」、把「客户端自述」当「事实」、 再配一张空判据表和恒 0 退出码,就会得到一个四个绿灯、 而唯一说真话的是外部探针的越权放行系统。
