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

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 节写明「一次合规受理」的判据是 三条件与关系:

  1. 凭据来源必须是内核(peer_id_source == kernel);
  2. 内核给出的主组(primary gid,即 ucred.gid)必须等于契约允许组 ops;
  3. 内核给出的 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.envcontract_available:false3
无审计日志拿不到审计日志…无法判定(判不了 ≠ 没问题)2
审计行 uid=3003 acceptbad_verdicts:12
审计行 peer_id_source:"claimed"claimed_source_rows:12

六、经验与避坑

核心认知

  1. 内核 SO_PEERCRED 只返回主组(ucred.gid),不返回补充组。 契约判据「调用方属于 ops 组」在 Linux 上只有一种可验证的读法: ucred.gid == ops(即 /proc/<pid>/status 里 Gid: 的第一列)。 Groups: 那几列是补充组,不能当依据 —— 「主组是 builders、碰巧还在 ops 补充组」≠「属于 ops 组」。
  2. os.stat(path).st_gid 里的 st_gid 是「文件的属组」,不是「来访问的人的组」。 三类「组」极易混淆,分清它们:
    • 内核 SO_PEERCRED 的 ucred.gid → 调用方主组(授权判据的正解);
    • /proc/<pid>/status 的 Groups: → 调用方补充组(旁证,非判据);
    • os.stat(sock).st_gid → 代理这一侧套接字文件的属组(跟调用方无关)。

用第 3 类当判据 = 把「第一道门的门锁型号」当成了「来人的身份证」。

  1. 套接字权限是门,不是判据。 0660 + chown ops 只是第一道门, chmod 随时能改;真正的授权判定必须独立于文件权限生效。 测试方式是 B 组那条:把套接字 chmod 0666, 一个非 ops 的调用方连上了也必须被拒 —— 这才叫判定没被绑架。
  2. 客户端自述字段永远不能当判据。 msg["groups"] / msg["uid"] / X-Forwarded-For / JWT 里未验签的自定义 claim —— 一个套路。 自述字段只能做旁证/审计留痕。
  3. 「读到了」≠「用上了」。 read_peer_cred() 读出了正确的主组, 审计日志里也记着 gid: 2001,但判定函数一次都没读它。 契约字段读进内存却丢在调用链半路,是静默缺陷的经典形态 —— 审计日志里 reason 是空字符串、gid 与 verdict 明显矛盾,就是第一现场。
  4. 空判据表 BAD_FIELDS = () 比没有检查更危险,因为它静默地骗人。for field, expected in BAD_FIELDS 一次都不循环 → issues 恒空 → degraded: false 恒假绿。加上 except ContractError: return EXIT_OK 与 main() 末尾无条件 return 0,健康检查就退化成了给自己发体检合格证。
  5. 「判不了」≠「没问题」(fail-closed)。 退出码必须分层: 拿不到事实 → 非 0;契约不可用 → 非 0;只有真正干净一轮才 0。
  6. 不要修过头 —— 安全闸门的方向永远是收紧。 本场景最容易修反的地方是:为了「让 jobbuilder 能进」而把判据改成看补充组。 那是放宽,方向反了。修复后 jobbuilder(uid 3002 在白名单内、 但主组是 builders)就是要被拒。

踩坑记录

  1. rm -rf state/ reports/ 报 Device or resource busy 这两个目录是 compose 的 bind mount 挂载点,挂载点本身删不掉。 这是正常噪声(ops/README.md 已注明),脚本随后 mkdir -p,不影响判定。
  2. 非交互环境 docker compose exec 必须加 -T 否则申请 TTY 失败:the input device is not a TTY,流水线里会卡住。
  3. 旧 app/__pycache__ 会把「已改」掩盖成「没改」 症状是:明明改了代码、verify 结果却纹丝不动。 每次改完先 rm -rf app/__pycache__(ops/verify.sh 内部也调了 clean_pyc)。
  4. ops/negative.sh 只能跑修复态,且会覆写 app/ 开头 grep -q 'return cred\["gid"\] == want' app/authorizer.py 硬校验修复态 (注释与代码结构也是契约的一部分,别把 _group_allows 写变形), 过程里会 cp 覆写再 trap restore EXIT 还原。跑完务必 git diff app/ 复核,别把负向脚本的脏改动一起提交。
  5. ops/repro.sh 以探针退出码为脚本退出码 问题态下返回 1,CI 里表现为「命令失败」—— 这是预期的复现信号,不是环境坏了(用 ; echo rc=$? 接住)。
  6. 排查顺序:先看事实,再看自述。cat state/broker-audit.ndjson(真实行)→ 亲手按契约口径重算 → 最后才看 reports/broker_report.json(自述)。 反过来就会先被 ok: true / self_consistent: true 骗一轮。
  7. 提交前 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-Bus GetConnectionUnixUser、 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、 NFS root_squash、sudoers 的组匹配、K8s fsGroup);
  • 一切采信客户端自述身份的缺陷: 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 退出码,就会得到一个四个绿灯、 而唯一说真话的是外部探针的越权放行系统。

版权声明

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

分享这篇文章