# REPRO-2026-00345: Forgejo <16.0.4 RCE via crafted template repository (.forgejo/template expansion recreates .git folder adopted by git init) ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00345 CVE: CVE-2026-89094 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # CVE-2026-89094 — Forgejo <16.0.4 RCE via crafted template repository ## Summary When a user generates a new repository from a template repository, Forgejo clones the template, deletes the `.git` directory, applies variable template expansion to the files listed in `.forgejo/template` (expanding variables in both file **content** and file **paths**, e.g. `.g${REPO_NAME}/config` → `.git/config` when the generated repository is named `it`), and then runs `git init` in that working directory. Because no cleanup happens after the expansion step, an attacker-controlled template can *recreate* a `.git` directory during expansion, and the subsequent `git init` adopts it. Forgejo then performs `git add --all` and `git commit` in that directory, honoring the attacker-supplied `.git/config` (e.g. `core.fsmonitor`, `core.hooksPath`) and hooks, which leads to arbitrary command execution on the Forgejo host as the Forgejo runtime user. The fix (PR #14301, released in v16.0.4) removes any `.git` directory again after the expansion completes and before `git init`. ## Impact - **Package/component affected:** Forgejo `services/repository/generate_repo_commit.go` (repository generation from template), interacting with `services/repository/generate.go` (variable expansion). - **Affected versions:** Forgejo < 16.0.4 (verified on the official container image `codeberg.org/forgejo/forgejo:16.0.3-rootless`, source tag `v16.0.3` = commit `eccddb2d17c93b42b2c8995725e03e549ac9ec0c`). - **Risk level:** Critical (CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H = 9.9). Any authenticated user who can create a template repository and generate a repository from it obtains arbitrary command execution in the Forgejo container/host as the Forgejo runtime user, plus arbitrary read (and write) of host data reachable by that user. ## Impact Parity - **Disclosed/claimed maximum impact:** Remote code execution on the Forgejo host via a crafted template repository ("read arbitrary data from the Forgejo host and execute arbitrary processes, e.g. via git hooks/config such as core.fsmonitor or hooks"). - **Reproduced impact from this run:** Full remote code execution through the real product HTTP API: attacker-supplied `post-commit` hook executed as `uid=1000(git)` inside the Forgejo container during the generated repository's initial commit; the hook wrote an exact marker string, captured `id` output, and read `/etc/passwd` from the host filesystem (arbitrary host data read). Both the vulnerable (16.0.3) and fixed (16.0.4) builds were exercised end-to-end. - **Parity: `full`** - **Not demonstrated:** Nothing material; the claimed impact (arbitrary process execution + host data read) was demonstrated directly. ## Root Cause In `services/repository/generate_repo_commit.go` (v16.0.3), `generateRepoCommit`: 1. Clones the template repository into a temp dir (`git.Clone`, depth 1). 2. Removes the cloned `.git` (`util.RemoveAll(path.Join(tmpDir, ".git"))`) **before** expansion. 3. Reads `.forgejo/template` (or `.gitea/template`) and, for every file matching one of its globs, expands variables in the content (`generateExpansion(content, ..., false)`) and in the path (`generateExpansion(relPath, ..., true)`, then `root.Rename(relPath, substPath)`). Since `${REPO_NAME}` is attacker-influenced (the name chosen for the generated repository), a template file named `.g${REPO_NAME}/config` is renamed to `.git/config` when the generated repo is named `it`. 4. Runs `git.InitRepository(ctx, tmpDir, ...)` — `git init` in a directory that already contains a `.git` directory reinitializes it and **preserves the attacker-written `config` and hooks**. 5. Runs `git remote add`, `git add --all`, `git commit` (`initRepoCommit`) — and git honors the adopted config. Two execution vectors were validated: - `core.fsmonitor` in the adopted `.git/config` executes during `git add --all` (git appends version/token arguments to the configured command). - Repo-local `core.hooksPath = .ghooks` overrides Forgejo's *global* `core.hooksPath = /var/lib/gitea/home/hooks` (which is why a plain `.git/hooks/post-commit` file is silently ignored), so the attacker's `.ghooks/post-commit` (an ordinary template file that rides along) executes during `git commit`. Fix: PR #14301 "fix: prevent template expansion from interfering with git initialization" (commit `0d74280e071a9ddc145930f93fa7b5cb50c2a4e8`, released in v16.0.4, tag `6e56b5ebad3fb05036b1ff68a6b47b80f5859c7c`) adds, after the expansion loop and before `git.InitRepository`: ```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") } ``` ## Reproduction Steps 1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker). 2. What the script does, per attempt (2× vulnerable 16.0.3-rootless, 2× fixed 16.0.4-rootless, each a fresh container on its own port): - Pulls/starts the official Forgejo container image, waits for `/api/healthz`. - Creates the admin user via the real `forgejo admin user create` CLI and an API token. - Creates a repository `evil-template` via `POST /api/v1/user/repos`, marks it as a template via `PATCH /api/v1/repos/admin1/evil-template` (`{"template":true}`). - Builds and pushes the malicious template over the real git-HTTP receive path: `README.md`, `.forgejo/template` (globs `README.md`, `.g*/config`, `.ghooks/*`), `.g${REPO_NAME}/config` (repo-local `core.hooksPath = .ghooks` + `core.fsmonitor` fallback), executable `.ghooks/post-commit` (writes an exact marker string, `id` output, and `/etc/passwd` into `/tmp` inside the container). - Waits until the template content is registered, then triggers the vulnerability through the real product workflow: `POST /api/v1/repos/admin1/evil-template/generate` with `{"name":"it","owner":"admin1","git_content":true}` so that `${REPO_NAME}` = `it` turns `.g${REPO_NAME}/config` into `.git/config`. - Collects per-attempt evidence: HTTP request/response transcripts, marker files, hook `id` output, exfiltrated `/etc/passwd`, generated repo README, and the full Forgejo service log. 3. Expected evidence of reproduction: - Vulnerable attempts: `generate` returns 201; `/tmp/marker-` inside the container contains exactly `CVE-2026-89094-RCE-`; `/tmp/rce-id-` contains `uid=1000(git) ...`; `/tmp/hostdata-` contains the container's `/etc/passwd`; generated repo still serves `README.md` == `Hello!`. - Fixed attempts (16.0.4): same attacker procedure; marker/hook/hostdata files absent; generation still succeeds with `README.md` == `Hello!`. ## Evidence All artifacts under `bundle/` (SHA-256 digests in `bundle/repro/runtime_manifest.json`): - `bundle/logs/vuln-1/`, `bundle/logs/vuln-2/` — vulnerable attempts: - `http_generate_request.txt`, `http_generate_response.json`, `http_generate_status.txt` — the triggering API call (`HTTP 201`). - `marker.txt` — exact bytes `CVE-2026-89094-RCE-vuln-1` / `...-vuln-2` written by the attacker hook. - `rce_id.txt` — `uid=1000(git) gid=1000(git) groups=1000(git)` (command executed as the Forgejo runtime user). - `hostdata_etc_passwd.txt` — 18 lines of `/etc/passwd` read from the Forgejo container host filesystem. - `forgejo_service.log` — full product log including the `POST /api/v1/repos/admin1/evil-template/generate ... 201` router line. - `template_tree.txt` — git tree of the malicious template actually pushed (`.g${REPO_NAME}/config` mode 100644, `.ghooks/post-commit` mode 100755). - `bundle/logs/fixed-1/`, `bundle/logs/fixed-2/` — fixed control attempts: `marker.txt`/`rce_id.txt` contain `__ABSENT__`, `generated_readme.txt` == `Hello!`, `http_generate_status.txt` == `HTTP 201`. - `bundle/repro/runtime_manifest.json` — runtime stack, image digests (`sha256:214f4ae6...` vulnerable / `sha256:a263a129...` fixed), source commits, and artifact digests. - `bundle/logs/reproduction_steps.log` — console transcript of both verification runs. Environment: Docker on linux/x86_64; images `codeberg.org/forgejo/forgejo:16.0.3-rootless` (source `v16.0.3`, `eccddb2d17c93b42b2c8995725e03e549ac9ec0c`) and `codeberg.org/forgejo/forgejo:16.0.4-rootless` (source `v16.0.4`, `6e56b5ebad3fb05036b1ff68a6b47b80f5859c7c`); Forgejo runs sqlite3; repo push over git-HTTP with a token-authenticated admin session. ## Recommendations / Next Steps - **Upgrade to Forgejo v16.0.4** (or later), which removes any `.git` directory after template expansion and before `git init` (PR #14301). - Defense in depth for deployers: none of Forgejo's process-level settings prevent this once the bug is present; upgrading is the fix. The execution lands as the Forgejo runtime user — containerize/isolate and drop unneeded privileges. - Testing recommendations: the upstream integration test `TestRepoGenerateTemplatingDotGitDir` covers the `.git/config` recreation; add a case asserting that *executable hook files* and `core.hooksPath`/`core.fsmonitor` values delivered through template expansion never influence the generated repository's git operations. ## Additional Notes - **Idempotency:** the script was executed twice consecutively; both runs ended with `VERDICT: CONFIRMED` (exit 0). It removes containers/data dirs by name before each attempt, so repeated runs are clean. - Two important mechanics discovered while validating: - Forgejo sets a **global** `core.hooksPath` (`/var/lib/gitea/home/hooks`) for its git subprocesses, so a naive `.git/hooks/post-commit` payload is silently ignored; the working payload overrides it with a repo-local `core.hooksPath` pointing at an ordinary template directory (`.ghooks`). `core.fsmonitor` in the adopted config is an independent (also validated interactively) execution vector. - After a git push, the repository API's `empty` field disappears (omitempty) once non-empty, so readiness must be polled via `size`/raw-file availability — otherwise a generate issued too early is silently skipped (`opts.GitContent && !templateRepo.IsEmpty`). - Limitations: the marker/hook evidence is collected from the container filesystem via `docker exec` (the attacker-relevant boundary — template push + generate — is fully remote over HTTP). The execution user is the containerized Forgejo runtime user (`uid=1000 git`), not root, which matches the disclosed impact scope. ## Reproduction Details Reproduced: 2026-09-11T09:31:43.606Z Duration: 2983 seconds Tool calls: 257 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00345 pruva-verify CVE-2026-89094 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00345&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00345/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-89094 - Source: https://codeberg.org/forgejo/forgejo/milestone/139655 ## Artifacts - bundle/repro/rca_report.md (analysis, 10576 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 16283 bytes) - bundle/logs/fixed-1/forgejo_service.log (log, 9038 bytes) - bundle/logs/fixed-1/generated_readme.txt (other, 7 bytes) - bundle/logs/fixed-1/hostdata_etc_passwd.txt (other, 11 bytes) - bundle/logs/fixed-1/http_create_repo_status.txt (other, 9 bytes) - bundle/logs/fixed-1/http_generate_request.txt (other, 121 bytes) - bundle/logs/fixed-1/http_generate_response.json (other, 2287 bytes) - bundle/logs/fixed-1/http_generate_status.txt (other, 9 bytes) - bundle/logs/fixed-1/http_mark_template_status.txt (other, 9 bytes) - bundle/logs/fixed-1/marker.txt (other, 11 bytes) - bundle/logs/fixed-1/rce_id.txt (other, 11 bytes) - bundle/logs/fixed-1/template_tree.txt (other, 282 bytes) - bundle/logs/fixed-2/forgejo_service.log (log, 9038 bytes) - bundle/logs/fixed-2/generated_readme.txt (other, 7 bytes) - bundle/logs/fixed-2/hostdata_etc_passwd.txt (other, 11 bytes) - bundle/logs/fixed-2/http_create_repo_status.txt (other, 9 bytes) - bundle/logs/fixed-2/http_generate_request.txt (other, 121 bytes) - bundle/logs/fixed-2/http_generate_response.json (other, 2287 bytes) - bundle/logs/fixed-2/http_generate_status.txt (other, 9 bytes) - bundle/logs/fixed-2/http_mark_template_status.txt (other, 9 bytes) - bundle/logs/fixed-2/marker.txt (other, 11 bytes) - bundle/logs/fixed-2/rce_id.txt (other, 11 bytes) - bundle/logs/fixed-2/template_tree.txt (other, 282 bytes) - bundle/logs/vuln-1/generated_readme.txt (other, 7 bytes) - bundle/logs/vuln-1/http_create_repo_status.txt (other, 9 bytes) - bundle/logs/vuln-1/http_generate_status.txt (other, 9 bytes) - bundle/logs/vuln-1/http_mark_template_status.txt (other, 9 bytes) - bundle/logs/vuln-1/template_tree.txt (other, 282 bytes) - bundle/logs/vuln-2/forgejo_service.log (log, 9037 bytes) - bundle/logs/vuln-2/generated_readme.txt (other, 7 bytes) - bundle/logs/vuln-2/hostdata_etc_passwd.txt (other, 748 bytes) - bundle/logs/vuln-2/http_create_repo_status.txt (other, 9 bytes) - bundle/logs/vuln-2/http_generate_request.txt (other, 121 bytes) - bundle/logs/vuln-2/http_generate_response.json (other, 2287 bytes) - bundle/logs/vuln-2/http_generate_status.txt (other, 9 bytes) - bundle/logs/vuln-2/http_mark_template_status.txt (other, 9 bytes) - bundle/logs/vuln-2/rce_id.txt (other, 45 bytes) - bundle/logs/vuln-2/template_tree.txt (other, 282 bytes) - bundle/repro/runtime_manifest.json (other, 8417 bytes) - bundle/repro/validation_verdict.json (other, 1934 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00345 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00345/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00345 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev