{"repro_id":"REPRO-2026-00345","version":6,"title":"Forgejo <16.0.4 RCE via crafted template repository (.forgejo/template expansion recreates .git folder adopted by git init)","repro_type":"security","status":"published","severity":"critical","description":"Forgejo before 16.0.4 allows remote code execution via a crafted template repository. When generating a new repository from a template, Forgejo clones the template repo, removes the .git folder, performs variable template expansion on files listed in .forgejo/template, and initializes a new git repository. Variable template expansion could be misused to recreate a .git folder, which git would adopt during initialization of the new repository. A malicious template repository could read arbitrary data from the Forgejo host and execute arbitrary processes (e.g. via git hooks/config such as core.fsmonitor or hooks). Fix (PR #14301, milestone 139655, v16.0.4): after variable expansion completes, any existing .git folder is removed before git init. CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H = 9.9. Reproduction: deploy Forgejo 16.0.3 (vulnerable), create a malicious template repository whose .forgejo/template causes a .git directory with a malicious config/hook to be recreated during expansion, create a new repository from that template, and verify code execution on the host.","root_cause":"# CVE-2026-89094 — Forgejo <16.0.4 RCE via crafted template repository\n\n## Summary\n\nWhen 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`.\n\n## Impact\n\n- **Package/component affected:** Forgejo `services/repository/generate_repo_commit.go` (repository generation from template), interacting with `services/repository/generate.go` (variable expansion).\n- **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`).\n- **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.\n\n## Impact Parity\n\n- **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\").\n- **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.\n- **Parity: `full`**\n- **Not demonstrated:** Nothing material; the claimed impact (arbitrary process execution + host data read) was demonstrated directly.\n\n## Root Cause\n\nIn `services/repository/generate_repo_commit.go` (v16.0.3), `generateRepoCommit`:\n\n1. Clones the template repository into a temp dir (`git.Clone`, depth 1).\n2. Removes the cloned `.git` (`util.RemoveAll(path.Join(tmpDir, \".git\"))`) **before** expansion.\n3. 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`.\n4. 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**.\n5. Runs `git remote add`, `git add --all`, `git commit` (`initRepoCommit`) — and git honors the adopted config.\n\nTwo execution vectors were validated:\n\n- `core.fsmonitor` in the adopted `.git/config` executes during `git add --all` (git appends version/token arguments to the configured command).\n- 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`.\n\nFix: 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`:\n\n```go\n// Before template expansion, .git was removed so that a fresh repo can be initialized; remove it again in case\n// some template variable usage has conflicted with this directory and impacts git operations.\nif err := root.RemoveAll(\".git\"); err != nil {\n    return fmt.Errorf(\"unable to remove .git folder\")\n}\n```\n\n## Reproduction Steps\n\n1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker).\n2. 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):\n   - Pulls/starts the official Forgejo container image, waits for `/api/healthz`.\n   - Creates the admin user via the real `forgejo admin user create` CLI and an API token.\n   - 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}`).\n   - 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).\n   - 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`.\n   - 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.\n3. Expected evidence of reproduction:\n   - Vulnerable attempts: `generate` returns 201; `/tmp/marker-<tag>` inside the container contains exactly `CVE-2026-89094-RCE-<tag>`; `/tmp/rce-id-<tag>` contains `uid=1000(git) ...`; `/tmp/hostdata-<tag>` contains the container's `/etc/passwd`; generated repo still serves `README.md` == `Hello!`.\n   - Fixed attempts (16.0.4): same attacker procedure; marker/hook/hostdata files absent; generation still succeeds with `README.md` == `Hello!`.\n\n## Evidence\n\nAll artifacts under `bundle/` (SHA-256 digests in `bundle/repro/runtime_manifest.json`):\n\n- `bundle/logs/vuln-1/`, `bundle/logs/vuln-2/` — vulnerable attempts:\n  - `http_generate_request.txt`, `http_generate_response.json`, `http_generate_status.txt` — the triggering API call (`HTTP 201`).\n  - `marker.txt` — exact bytes `CVE-2026-89094-RCE-vuln-1` / `...-vuln-2` written by the attacker hook.\n  - `rce_id.txt` — `uid=1000(git) gid=1000(git) groups=1000(git)` (command executed as the Forgejo runtime user).\n  - `hostdata_etc_passwd.txt` — 18 lines of `/etc/passwd` read from the Forgejo container host filesystem.\n  - `forgejo_service.log` — full product log including the `POST /api/v1/repos/admin1/evil-template/generate ... 201` router line.\n  - `template_tree.txt` — git tree of the malicious template actually pushed (`.g${REPO_NAME}/config` mode 100644, `.ghooks/post-commit` mode 100755).\n- `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`.\n- `bundle/repro/runtime_manifest.json` — runtime stack, image digests (`sha256:214f4ae6...` vulnerable / `sha256:a263a129...` fixed), source commits, and artifact digests.\n- `bundle/logs/reproduction_steps.log` — console transcript of both verification runs.\n\nEnvironment: 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.\n\n## Recommendations / Next Steps\n\n- **Upgrade to Forgejo v16.0.4** (or later), which removes any `.git` directory after template expansion and before `git init` (PR #14301).\n- 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.\n- 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.\n\n## Additional Notes\n\n- **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.\n- Two important mechanics discovered while validating:\n  - 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.\n  - 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`).\n- 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.\n","cve_id":"CVE-2026-89094","cwe_id":"CWE-22","source_url":"https://codeberg.org/forgejo/forgejo/milestone/139655","reproduced_at":"2026-09-11T09:31:43.606332+00:00","duration_secs":2982.744438,"tool_calls":257,"handoffs":2,"total_cost_usd":4.409714,"agent_costs":{"claim_matcher":0.029106,"judge":0.369694,"learning_policy":0.008036,"repro":2.653447,"support":0.055565,"vuln_variant":1.293866},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.029106},"judge":{"gpt-5.6-sol":0.369694},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.008036},"repro":{"accounts/fireworks/models/glm-5p3":2.653447},"support":{"accounts/fireworks/models/glm-5p3":0.055565},"vuln_variant":{"accounts/fireworks/models/glm-5p3":1.293866}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"not_found","variant_disclosure_state":"not_applicable","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-11T09:31:44.694861+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10576,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16283,"category":"reproduction_script"},{"path":"bundle/logs/fixed-1/forgejo_service.log","filename":"forgejo_service.log","size":9038,"category":"log"},{"path":"bundle/logs/fixed-1/generated_readme.txt","filename":"generated_readme.txt","size":7,"category":"other"},{"path":"bundle/logs/fixed-1/hostdata_etc_passwd.txt","filename":"hostdata_etc_passwd.txt","size":11,"category":"other"},{"path":"bundle/logs/fixed-1/http_create_repo_status.txt","filename":"http_create_repo_status.txt","size":9,"category":"other"},{"path":"bundle/logs/fixed-1/http_generate_request.txt","filename":"http_generate_request.txt","size":121,"category":"other"},{"path":"bundle/logs/fixed-1/http_generate_response.json","filename":"http_generate_response.json","size":2287,"category":"other"},{"path":"bundle/logs/fixed-1/http_generate_status.txt","filename":"http_generate_status.txt","size":9,"category":"other"},{"path":"bundle/logs/fixed-1/http_mark_template_status.txt","filename":"http_mark_template_status.txt","size":9,"category":"other"},{"path":"bundle/logs/fixed-1/marker.txt","filename":"marker.txt","size":11,"category":"other"},{"path":"bundle/logs/fixed-1/rce_id.txt","filename":"rce_id.txt","size":11,"category":"other"},{"path":"bundle/logs/fixed-1/template_tree.txt","filename":"template_tree.txt","size":282,"category":"other"},{"path":"bundle/logs/fixed-2/forgejo_service.log","filename":"forgejo_service.log","size":9038,"category":"log"},{"path":"bundle/logs/fixed-2/generated_readme.txt","filename":"generated_readme.txt","size":7,"category":"other"},{"path":"bundle/logs/fixed-2/hostdata_etc_passwd.txt","filename":"hostdata_etc_passwd.txt","size":11,"category":"other"},{"path":"bundle/logs/fixed-2/http_create_repo_status.txt","filename":"http_create_repo_status.txt","size":9,"category":"other"},{"path":"bundle/logs/fixed-2/http_generate_request.txt","filename":"http_generate_request.txt","size":121,"category":"other"},{"path":"bundle/logs/fixed-2/http_generate_response.json","filename":"http_generate_response.json","size":2287,"category":"other"},{"path":"bundle/logs/fixed-2/http_generate_status.txt","filename":"http_generate_status.txt","size":9,"category":"other"},{"path":"bundle/logs/fixed-2/http_mark_template_status.txt","filename":"http_mark_template_status.txt","size":9,"category":"other"},{"path":"bundle/logs/fixed-2/marker.txt","filename":"marker.txt","size":11,"category":"other"},{"path":"bundle/logs/fixed-2/rce_id.txt","filename":"rce_id.txt","size":11,"category":"other"},{"path":"bundle/logs/fixed-2/template_tree.txt","filename":"template_tree.txt","size":282,"category":"other"},{"path":"bundle/logs/vuln-1/generated_readme.txt","filename":"generated_readme.txt","size":7,"category":"other"},{"path":"bundle/logs/vuln-1/http_create_repo_status.txt","filename":"http_create_repo_status.txt","size":9,"category":"other"},{"path":"bundle/logs/vuln-1/http_generate_status.txt","filename":"http_generate_status.txt","size":9,"category":"other"},{"path":"bundle/logs/vuln-1/http_mark_template_status.txt","filename":"http_mark_template_status.txt","size":9,"category":"other"},{"path":"bundle/logs/vuln-1/template_tree.txt","filename":"template_tree.txt","size":282,"category":"other"},{"path":"bundle/logs/vuln-2/forgejo_service.log","filename":"forgejo_service.log","size":9037,"category":"log"},{"path":"bundle/logs/vuln-2/generated_readme.txt","filename":"generated_readme.txt","size":7,"category":"other"},{"path":"bundle/logs/vuln-2/hostdata_etc_passwd.txt","filename":"hostdata_etc_passwd.txt","size":748,"category":"other"},{"path":"bundle/logs/vuln-2/http_create_repo_status.txt","filename":"http_create_repo_status.txt","size":9,"category":"other"},{"path":"bundle/logs/vuln-2/http_generate_request.txt","filename":"http_generate_request.txt","size":121,"category":"other"},{"path":"bundle/logs/vuln-2/http_generate_response.json","filename":"http_generate_response.json","size":2287,"category":"other"},{"path":"bundle/logs/vuln-2/http_generate_status.txt","filename":"http_generate_status.txt","size":9,"category":"other"},{"path":"bundle/logs/vuln-2/http_mark_template_status.txt","filename":"http_mark_template_status.txt","size":9,"category":"other"},{"path":"bundle/logs/vuln-2/rce_id.txt","filename":"rce_id.txt","size":45,"category":"other"},{"path":"bundle/logs/vuln-2/template_tree.txt","filename":"template_tree.txt","size":282,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":8417,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1934,"category":"other"}]}