FreeBuf 前两天有篇讲 self-hosted runner 的文章,角度是"新 runner 没人认识"。这句话在安全团队听起来应该刺耳:一台机器被注册进你的 CI 体系、拿到凭据、能跑任意代码,但资产库里没有它,EDR 不认识它,监控基线里也没有它。这就是冷启动盲区——不是漏洞,而是验证与检测链条上的一段真空。
我把这套机制从头到尾捋一遍:runner 在主机上到底留下了什么、为什么这些痕迹天然难以被检测、攻击者能怎么用、以及怎么用几条命令把它拉回视野。
Runner 在主机上留下的东西
一次注册就是一条命令:
./config.sh --url https://github.com/OWNER/REPO \
--token <REGISTRATION_TOKEN> --name build-07 \
--labels self-hosted,linux,x64,gpu --work /srv/actions-runner/_work \
--unattended --ephemeral
./svc.sh install runner && ./svc.sh start
注册 token 一小时的 TTL,作用域可以是仓库、组织或企业。注册完成后目录里会有这么几个文件,它们就是整台机器的身份:
| 文件 | 内容 |
|---|---|
.runner |
agentId、agentName、poolName、serverUrl、gitHubUrl |
.credentials |
与 GitHub 通信的认证 token |
.credentials_rsaparams |
RSA 私钥,用于任务负载的加密交换 |
_diag/Runner_*.log |
每次 job 的完整诊断日志 |
_work/_temp / _work/_actions / _work/_tool |
工作目录、拉下来的 action、工具缓存 |
服务化之后还会有 actions.runner.<org>-<repo>.<runner-name>.service 这个 systemd unit。这几样东西凑在一起,等于一份完整的入侵凭据包:拿到 .credentials 和 RSA 私钥,就能冒充这个 runner 的身份、领取 job、接收加密后的任务载荷。
另外还有两个属性值得单独拎出来:
- 自动更新。
Runner.Listener在领取 job 之前会检查新版本并自行升级,二进制几乎天天在变。任何基于文件哈希的完整性基线,都会被 runner 自己的正常升级冲掉。 --disableupdate。关掉自更新让版本可预测,代价是长期停在旧版本上,已知问题不会随上游修掉。很多团队为了稳定会这么干,然后忘了它。
现在加上 ephemeral 模式,是另一套逻辑。--ephemeral 或 JIT config(POST /repos/{owner}/{repo}/actions/runners/generate-jitconfig)注册出来的 runner 只领取一个 job,跑完自己注销并退出,靠外部调度器(Actions Runner Controller 或自己的脚本)补新实例。安全性明显更好,但换来的结果是:runner 的进程、主机名、二进制哈希每次都不一样,基于"长期存在的实体"建模的检测思路会直接失效。
盲区是怎么形成的
我数了一下,至少四个环节同时失灵:
- 资产维度:runner 注册由开发或平台团队自助完成,不进 CMDB。等安全团队做资产盘点时,一台跑着生产凭据的机器不在清单里。
- 进程维度:CI runner 的进程名固定是
Runner.Listener/Runner.Worker,路径是/home/runner/actions-runner这类非标准位置。EDR 实施时最常见的妥协就是按进程名加白名单,避免 CI 被误杀,于是 runner 里跑出来的东西天然不受约束。 - 完整性维度:如上,自更新让哈希基线和 FIM 规则持续告警或被直接关掉。
- 网络维度:runner 必须能出网访问 github.com、
*.githubusercontent.com和各类包仓库。出口策略通常开得很宽,因为没人愿意为了 CI 天天调白名单。
四个维度都开口,结论就是:从注册完成到第一次真正出事的这段时间,没有任何一层会主动说"这台机器我不认识"。
攻击面:五条能落地的路径
一、注册 token 泄漏。 仓库级 token 能往该仓库加 runner,组织级 token 能往组织下任意仓库加。泄漏途径往往很朴素——CI 日志里 echo 出来的变量、共享的自动化脚本、离职人员的凭据残留。攻击者注册一个"影子 runner",之后所有针对该仓库的 job 都可能落到他机器上,包括带着部署密钥和发布凭据的那些。
二、公网仓库上的不可信代码。 pull_request_target 会带着 base 分支的 secrets 运行,如果在哪个 job 上写了 runs-on: self-hosted,外部贡献者的 PR 就能在你的内网机器上执行代码,GitHub 文档专门警告过这一点。workflow_run 链式触发是同类问题的变体。
三、缓存与工件投毒。 actions/cache 的条目按分支/PR 作用域隔离,但 restore-keys 是前缀匹配的:不可信分支写一条 build-deps-* 的缓存,可信分支恢复时可能命中。工件同理——不可信 workflow 上传的产物被可信 workflow 下载并直接执行。
四、runner 自身的持久化。 用户通常把 runner 跑在 docker 组里(否则做不了容器化构建),而这个组等效于 root:job 里起个容器把宿主 / 挂进去就行。持久化手段也不需要多高级:往 _work/_temp 里丢脚本、改 _actions/ 下已缓存的 action、给 systemd unit 加个 ExecStartPre、或者在 runner 用户的 crontab 里留一行。
五、绕过 EDR 的温床。 白名单 + 出网宽松 + 凭据齐备,组合起来就是理想的落脚点。攻击者甚至不需要什么新手法,用 CI 合法的机制就能完成从执行到外带的全过程。
怎么把它拉回视野
最少三条命令,成本很低:
# 1) 本地有哪些 runner 在跑
ps -ef | grep -E 'Runner\.(Listener|Worker)' | grep -v grep
systemctl list-units --all 'actions.runner.*'
# 2) 平台侧登记了哪些 runner(含离线、异常状态)
curl -s -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/OWNER/REPO/actions/runners
# 3) runner 目录里的身份文件,谁在什么时候动过
sudo find / -name '.credentials*' -o -name '.runner' 2>/dev/null | xargs -r ls -la
第 2 条是重点。API 返回的 status 会因为 runner 停止心跳变成 offline,但记录还在——离线却仍在清单里的 runner,往往就是某个已经报废、却还持有有效身份的历史遗留。和本地进程两两对账,多出来的、缺掉的、名字不对的,都是线索。
再往下是行为层:
_diag/Runner_*.log里有每次 job 的领取时间和 workflow 名称。把它们和 GitHub 审计日志里workflow_job事件的runner_name、runner_group_name对齐,能直接回答"某个陌生 runner 到底领走过哪些 job"。- 出网做基线。runner 真正需要的目标很有限:GitHub 域名 + 你明确定义过的包仓库白名单。任何额外的长连接、非常规端口、直连 IP,都值得看一眼。
- 盯
_work/_temp的残留脚本和_work/_actions下 action 目录的修改时间。CI 正常运行不会去改已下载的 action。
加固:把"不认识"变成"不信任"
按投入产出排,我会这么做:
- 可信与不可信分开。公网仓库的 PR 构建绝不能落在持有生产凭据的 runner 上。宁可多买一组一次性 runner。
- ephemeral + 容器作业。每个 job 一个干净环境,跑完即弃;宿主上不留状态。
- runner group 只授权给私有仓库,组织级注册 token 严格管控,最好禁掉手动
config.sh,全部走自动化。 - 出口白名单,而不是"能出网就行"。
- 凭据降权。job 上写
permissions: {}或read-all,action 一律 pin 到 commit SHA,别用@v4这种浮动引用;runner 用户坚决不进docker组,需要容器就上 rootless。 - 给 runner 资产打标签,让它进资产库、进 EDR 的正常策略而不是白名单。
我的做法
我把这套东西压成三个动作,每周跑一次:
# 1) runner 清单对账(平台 vs 本地),只输出差异
comm -3 <(curl -s -H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/OWNER/REPO/actions/runners | jq -r '.runners[].name' | sort) \
<(ps -ef | grep 'Runner.Listener' | grep -v grep | awk '{print $NF}' | sort)
# 2) 身份文件变更告警
sudo find / -name '.credentials' -mtime -7 2>/dev/null
# 3) 出口异常
sudo journalctl -u 'actions.runner.*' --since '24 hours ago' | grep -iE 'connect|timeout|proxy'
第 1 条是核心:差异为空才算健康。真实的盲区不是某个 CVE,而是"机器上线了,没有任何一层系统知道该认识它"。CI 的信任模型不能建立在主机上,它能建立的唯一位置是 job 边界。
⚠️ 网络安全免责声明
本文内容仅供技术研究与安全学习之用。文中描述的漏洞分析、攻击链拆解和代码示例均基于公开披露的安全研究,旨在帮助开发者理解攻击原理、提升安全意识和防御能力。
请勿将本文所述技术用于任何未经授权的安全测试、渗透攻击或非法用途。任何因滥用本文信息而造成的法律后果,由使用者自行承担,与本文作者无关。
如发现系统安全漏洞,请通过合法渠道向相关厂商或平台报告,共同维护网络安全生态。
浙公网安备 33010602011771号