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.