一个分号击穿GitHub新智元

5/11/2026

一个小小的分号,能让任何有push权限的GitHub用户都可能在服务器后端执行任意命令,它撬开的不只是一次输入过滤失误,而是多租户云平台长期依赖的内部信任假设。

2026年3月4日,GitHub收到Wiz通过Bug Bounty提交的报告,报告描述的攻击入口极其简单:

一条构造过的git push,带一个push option,值里藏了一个分号。

40分钟内,GitHub内部复现了漏洞,从确认根因到云端修复上线总计约75分钟,不到2小时。

任何对某个仓库拥有push权限的已认证用户,理论上都能在处理这次git push的GitHub/GHES后端服务器上执行任意命令。

虽然GitHub官方博客写得很清楚:「没有任何客户数据被访问、修改或外泄。」

GitHub表示,由于该利用链会触发正常GitHub.com操作不会走的异常代码路径,他们据此查询遥测,未发现除Wiz测试外的触发记录。

Wiz自己也说:「我们没有访问任何其他租户的仓库内容。」

但这次事件,仍然撬开了那个被信了多年的内部信任假设。

击穿三层信任

要看懂这次的漏洞链,得先看清GitHub内部那条push流水线长什么样。

你执行git push,请求进入GitHub内部的第一站叫babeld,它是GitHub自研的git代理,负责把你的连接往下转。

babeld先去问gitauth:这个用户有没有权限,这次push该遵守哪些规则?gitauth回来给一张清单:文件大小上限、分支命名规则、hook配置。

babeld把这张清单打包进一个叫X-Stat的内部请求头,往下传给gitrpcd。

gitrpcd是内部的RPC服务,它只认这个头,完全信任里面的每一个字段。

最后,负责最终检查的pre-receive hook拿到X-Stat,决定这次push能不能过。

整条流水线,X-Stat头就是通行证。

GitHub内部git push流水线:babeld → gitauth → gitrpcd → pre-receive hook

这个X-Stat头是一串以分号隔开的key=value,比如a=1;b=2;c=3。

解析的时候,系统把它们依次读进一个map。有个细节很关键:如果同一个key出现两次,后面那个会悄悄把前面那个覆盖掉。

问题出在哪?

git push有个正常功能,叫push option,允许你在推代码时顺带传一些自定义字符串给服务端。

babeld会把这些字符串原样编进X-Stat,比如你传了两个option,它就变成push_option_0=你传的内容;push_option_1=你传的内容。

babeld忘了一件事:没有过滤分号。

X-Stat里本来有个字段large_blob_rejection_enabled=bool:true,是文件大小限制的开关,默认开着。

攻击者构造一个push option,值里塞一个分号,后面跟上large_blob_rejection_enabled=bool:false。babeld原样写进去,X-Stat里同一个key就出现了两次。

X-Stat字段注入示意:攻击者构造的push option如何覆盖合法字段

map解析到重复的key,后写入的值覆盖前面那个。攻击者注入的false排在后面,赢了。文件大小限制,就这么被关掉了。

整个攻击的地基,就是babeld少写的那一行过滤代码。

这个漏洞背后的逻辑,在很多系统里都存在。

多个服务串在一条流水线上,每一站只管干自己的活,默认上一站传过来的数据是干净的、没有问题的。没有人在中间做二次检查。

结果就是:babeld没拦住分号,gitrpcd收到数据不验证,pre-receive拿到字段直接用。

每一站都默认上一站传来的是干净的、可信的。

三层信任叠在一起,一个分号全部击穿。

注入三个字段

GHES实例沦陷

绕过文件大小限制,只是Wiz用来验证注入可行的第一个测试。真正的目标,是拿到服务器的执行权限。

第一步,把沙箱关掉。

GHES允许管理员自定义一些hook脚本,在代码推送前自动运行。这些脚本默认在沙箱里跑,权限受限。

但Wiz逆向分析后发现,沙箱是否启用,取决于X-Stat头里一个叫rails_env的字段。值是production,进沙箱;填任何其他值,直接以git服务账户身份运行,没有任何隔离。

这个字段,可以注入。

第二步,把hook脚本的查找目录换掉。

注入custom_hooks_dir,把系统查找hook脚本的根目录,从默认位置改成攻击者能控制的地方。

第三步,指定一个恶意脚本来执行。

注入repo_pre_receive_hooks,在里面填一段路径穿越,让系统跳出正常目录范围,去执行服务器上任意一个二进制文件。

三步串起来,GHES服务器返回了这样一行输出:

remote: uid=500(git) gid=500(git) groups=500(git)

这行输出的意思是:代码已经在服务器上以git账户身份执行了。这说明研究员已经能以git服务账户身份在GHES服务器上执行命令。

Wiz研究员sagitz在X上展示的PoC:一条普通的git push命令,远端服务器返回了uid=500(git):代码在GitHub后端以git服务账户身份执行了。

Wiz把同样的攻击链对准GitHub.com,没成功。push走完了,hook没有触发,服务器什么都没返回。

Wiz发现X-Stat里还藏着一个标志位,控制服务器是否以「企业模式」运行。GHES上这个标志默认开着,自定义hook一直可以跑;GitHub.com上默认关着,自定义hook根本不会被触达。

但这个标志,也在X-Stat里。也可以注入。

补上这第四步,整条链路打通了。Wiz在GitHub.com上执行了hostname,服务器返回了一个.github.net结尾的内部主机名。进去了。

GitHub在事后博客里坦承了一件事:那条非production的执行路径,本来就不应该出现在GitHub.com的生产环境里。

早期部署时专门把这段代码排除掉了,后来部署方式改了,排除的逻辑没有跟着迁移过来,这段代码就这么悄悄留在了镜像里,一直没人注意到。

漏洞利用之所以能贯通,正是因为这段「不该在那里」的代码恰好在那里。

Wiz枚举了两台被攻陷的节点,每台上都看到了百万级其他用户和组织的仓库索引项,他们表示:

没有读取任何其他用户的仓库内容。只是用自己的测试账户确认了一件事:git用户的权限,确实能访问这台节点上任何一个仓库。

GitHub的日志也印证了这一点。那条异常代码路径,所有触发记录全部指向Wiz自己的测试流量,没有其他账户出现过。

能访问,和真的去读了,是两件性质完全不同的事。这次是前者,不是后者。

但根本问题还是出在多租户平台的底层设计上。

GitHub.com把大量用户和组织的仓库放在同一批服务器上,统一交给同一个git服务账户管理,因为这个账户本来就需要处理所有人的数据。

任何人只要拿到这个账户的执行权限,该节点上的大量仓库都会进入权限视野。

Scroll for more