ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Git泄露攻防实战:从git log与git stash到CTFHub复现

Git泄露攻防实战:从git log与git stash到CTFHub复现

1. 从一个被忽略的目录说起:Git泄露到底在泄露什么

很多人第一次接触“Git泄露”这个词,是在CTF比赛或者企业安全自查的报告里。表面上看,它只是网站根目录下多了一个.git文件夹,浏览器直接访问https://target/.git/config还能看到内容。但真正做过应急响应的人都知道,这个小小的目录一旦暴露,攻击者能拿到的东西远超想象——完整的源码历史、开发者的提交备注、数据库连接字符串、云服务密钥、甚至已经删除但从未真正消失的敏感文件。

我先把结论放在前面:Git泄露的本质不是“目录遍历”,而是“版本控制元数据的非授权读取”。.git目录里保存的是整个仓库的对象数据库,包括每一次commit的树对象、父提交指针、作者信息、时间戳,以及所有曾经被git add过的文件快照。只要这些对象还在,哪怕当前工作区里已经删掉了某个配置文件,攻击者依然可以通过git log、git stash、git fsck等命令把它完整还原出来。

这篇文章面向三类人:一是刚接触Web安全、准备打CTFHub Git泄露系列题目的新手;二是负责企业代码仓库安全、需要做暴露面排查的运维或安全工程师;三是对版本控制内部机制好奇、想搞清楚“为什么删了还能恢复”的开发者。我会从Git的对象模型讲起,把git log和git stash这两条最常被利用的路径拆开揉碎,再结合CTFHub的题目类型给出可复现的操作流程,最后整理一份实战排查清单。全文基于我自己的复现记录和常见工具实践,涉及的命令和参数都可以直接抄作业。

需要提前说明的是,本文所有案例均使用虚构的靶场环境和代称,不涉及任何真实站点或项目。工具选型上以开源命令行工具为主,理由很简单:CTF场景下你往往只有一个浏览器和一个终端,越轻量的工具越不容易出错。

2. Git对象模型与泄露原理拆解

2.1 为什么删掉的文件还能被“挖”出来

要理解Git泄露的危害,必须先搞清楚Git是怎么存数据的。很多人以为Git保存的是“文件差异”,其实不是。Git的核心是一个内容寻址的键值数据库,存在.git/objects目录下。每次你执行git add,Git会把文件内容做SHA-1哈希(现在也有SHA-256仓库,但CTF里几乎全是SHA-1),然后用哈希值的前两位做子目录名,后38位做文件名,把内容压缩后存进去。这个对象叫blob对象。

举个例子,假设你有一个config.php,内容是password=123456。执行git add config.php之后,.git/objects里就会多出一个blob对象。哪怕你下一秒执行rm config.php再git commit,这个blob对象依然躺在objects目录里,因为Git的垃圾回收机制默认不会立即删除未被引用的对象。只有当你执行git gc --prune=now并且该对象确实没有任何引用时,它才会被真正清理。

这就是“删了还能恢复”的底层原因。攻击者拿到.git目录后,不需要知道你的文件名,只需要遍历所有对象,用git cat-file逐个解压查看内容,就能把历史版本里的敏感信息全部翻出来。我实测过一个中等规模的仓库,objects目录下有上万个对象,用脚本批量dump也就几分钟的事。

2.2 commit、tree、blob三者的关系

Git的对象类型主要有四种:blob、tree、commit、tag。理解它们的关系是读懂git log输出的前提。

  • blob:存文件内容,不存文件名。
  • tree:相当于一个目录,记录文件名到blob/tree哈希的映射。
  • commit:指向一个tree(代表某次提交的完整快照),同时包含父commit哈希、作者、提交者、提交信息。
  • tag:指向某个commit的带注释标签。

当你执行git log时,Git从HEAD指向的commit开始,沿着parent指针一路往回走,把每个commit的信息打印出来。而git log -p或git show则会进一步展开commit对应的tree,对比父commit的tree,算出差异并显示。攻击者利用的就是这个机制:只要拿到commit对象,就能顺着tree找到所有blob,还原出任意历史版本的文件。

这里有个容易被忽略的点:即使当前分支的HEAD被重置了,旧的commit对象也不会立刻消失。它们可能被reflog引用,也可能成为“悬空对象”(dangling commit)。git fsck --lost-found就是专门用来找这些悬空对象的命令,CTF里经常用它来捞被reset掉的flag。

2.3 .git目录暴露的常见成因

从防守方角度看,.git泄露通常不是黑客“攻进去”的,而是自己“放出来”的。我总结了几种高频场景:

第一种是部署方式粗暴。开发者在服务器上直接git clone代码,然后用Nginx或Apache把整个项目目录作为网站根目录。Web服务器默认会返回.git下的静态文件,因为.git不是隐藏文件(Linux下以点开头只是约定,不是权限限制),只要URL能拼出来就能下载。

第二种是打包发布时误包含。有些构建脚本用tar -czf dist.tar.gz .打包,没有排除.git,结果发布包上传到Web目录后整个版本库都暴露了。

第三种是备份文件残留。比如有人把.git目录压缩成.git.zip放在Web目录下做备份,或者用编辑器生成的.git/config.swp之类临时文件没清理。

第四种是框架默认行为。某些静态站点生成器或CI工具在构建时会保留.git用于版本信息注入,如果配置不当就会连同产物一起发布。

注意:判断一个站点是否存在Git泄露,最直接的方法是访问/.git/HEAD。如果返回ref: refs/heads/master或类似内容,基本可以确认。如果返回404,也不要轻易下结论,有些服务器配置了规则只拦截特定路径,可以尝试/.git/config、/.git/index等。

3. git log与git stash:两条最常被利用的路径

3.1 git log能告诉你什么

git log是攻击者拿到.git后第一个会跑的命令。它的默认输出包括commit哈希、作者、日期、提交信息。别小看这些信息,提交信息里经常藏着“修复了数据库密码硬编码问题”“临时关闭鉴权”之类的描述,直接指向敏感点。

更关键的是git log --all。默认情况下git log只显示当前分支的历史,但加上--all会显示所有引用(包括远程分支、标签)的历史。CTF题目里经常把flag放在一个非默认分支上,比如dev或feature分支,这时候不加--all就会漏掉。

还有一个组合是git log -p,它会显示每次提交的完整diff。如果flag是在某次提交里被添加后又删除的,-p能直接把它打印出来。我做过一道题,flag在第三次提交时被加入flag.txt,第四次提交时被删除,用git log -p --all一眼就能看到。

实际操作中,如果.git目录不完整(比如只有objects没有refs),git log可能报错“not a git repository”。这时候需要先修复仓库结构。常见做法是创建一个空目录,git init,然后把下载到的.git内容覆盖进去,再执行git reset --hard或git checkout .来恢复工作区。

3.2 git stash为什么是“藏宝箱”

git stash是Git用来临时保存工作区修改的命令。当你手头有未提交的改动,但又需要切换分支时,git stash会把这些改动压入一个栈结构,存在.git/refs/stash和.git/logs/refs/stash里。很多人不知道的是,stash的内容同样以commit对象的形式存在,只是它的parent指向当时的HEAD。

这意味着,如果开发者曾经git stash过包含flag的改动,然后忘记git stash pop,这个flag就一直躺在stash栈里。攻击者执行git stash list能看到所有stash记录,git stash show -p stash@{0}能查看具体内容,git stash apply则能把改动恢复到工作区。

CTFHub的Git泄露系列里,stash题目通常是这样设计的:仓库历史里看不到flag,但git stash list有一条记录,apply之后flag文件就出现了。我踩过的坑是,有些题目里stash的索引不是stash@{0},需要先list确认。另外,如果.git/logs目录被删了,git stash list可能为空,但.git/refs/stash文件还在,可以直接git cat-file -p $(cat .git/refs/stash)来查看。

3.3 两条路径的对比与组合利用

对比维度git log路径git stash路径
数据位置.git/objects+.git/refs/heads.git/objects+.git/refs/stash
触发命令git log --all -pgit stash list+git stash show -p
常见题目特征flag在历史提交中被删工作区干净但stash有记录
恢复难度低,历史完整即可中,依赖stash引用未丢失
易漏点非默认分支、悬空commitstash索引、logs缺失

实战中这两条路径经常组合使用。比如先用git log --all找到可疑commit,发现flag被删了,再用git fsck --lost-found找悬空对象,最后用git stash list确认有没有遗漏的临时改动。我个人的习惯是拿到.git后按这个顺序跑一遍:git log --all --oneline→git log --all -p→git stash list→git fsck --lost-found→ 批量dump objects。这套流程覆盖了90%以上的CTF Git泄露题目。

4. CTFHub Git泄露系列实操复现

4.1 环境准备与工具选型

CTFHub的Git泄露系列通常提供一个Web靶场,你需要通过浏览器或命令行把.git目录下载到本地。工具方面,我推荐三个:

  • Git本身:核心工具,所有操作都依赖它。版本建议2.20以上,老版本对git fsck的输出格式略有差异。
  • wget或curl:用于批量下载.git目录。wget -r -np -nH --cut-dirs=1 -R index.html https://target/.git/是常用命令,但要注意有些服务器会返回403,需要调整User-Agent。
  • Python脚本:用于批量解压objects。当.git目录不完整、无法直接用git命令时,写个脚本遍历objects目录,用zlib解压再解析内容,往往能救命。

我试过用git-dumper这个工具,它专门用来下载暴露的.git目录,能自动处理目录列表和文件下载。但在CTF环境下,靶场往往限制了目录列表,git-dumper可能失败,这时候手动拼URL更可靠。比如先访问/.git/HEAD确认存在,再访问/.git/index、/.git/logs/HEAD、/.git/refs/heads/master等关键文件。

提示:下载.git时不要用浏览器直接点,浏览器可能会把二进制文件当文本渲染导致损坏。用wget或curl -O保存原始字节。

4.2 题目一:git log基础利用

这类题目的典型特征是:靶场根目录存在.git,flag藏在历史提交里。我的操作步骤如下:

第一步,确认泄露。访问http://target/.git/HEAD,返回ref: refs/heads/master,确认存在。

第二步,下载.git目录。用wget -r -np -nH --cut-dirs=1 -R index.html http://target/.git/,下载完成后本地会有一个.git文件夹。

第三步,修复仓库。进入.git所在目录的上一级,执行git status。如果报错“not a git repository”,说明.git结构不完整,需要手动补全。常见缺失是refs目录为空,可以执行git init后把下载的objects覆盖进去。

第四步,查看历史。执行git log --all --oneline,看到提交列表。然后git log --all -p,逐条查看diff。flag通常以flag{...}形式出现在某次提交的添加行里。

第五步,如果flag被删了,用git checkout <commit> -- <file>恢复该文件,或者直接git show <commit>:<file>查看内容。

我遇到过一个变种:flag不在master分支,而在一个叫dev的分支上。git log --all能看到dev的commit,但git branch不显示,因为本地没有对应的ref。这时候需要git checkout -b dev origin/dev或者直接git show <commit>。

4.3 题目二:git stash藏flag

stash题目的特征是:git log看不到flag,工作区也没有。操作流程如下:

第一步,同样下载.git目录并修复仓库。

第二步,执行git stash list。如果输出类似stash@{0}: WIP on master: abc1234 initial commit,说明有stash记录。

第三步,执行git stash show -p stash@{0}查看具体改动。flag通常就在diff的添加行里。

第四步,如果git stash list为空,但你知道应该有stash,检查.git/refs/stash文件是否存在。如果存在,直接git cat-file -p $(cat .git/refs/stash)查看commit对象,再顺着tree找blob。

第五步,执行git stash apply把改动恢复到工作区,然后ls和cat查看新出现的文件。

这里有个细节:有些题目会把stash的parent指向一个不存在的commit,导致git stash apply报错。这时候不要慌,直接用git cat-file -p手动解析stash commit的tree,找到flag对应的blob哈希,再git cat-file -p <blob-hash>就能看到内容。我实测下来,这种方法比修复引用更稳。

4.4 题目三:index与悬空对象

除了log和stash,还有两类题目分别利用.git/index和悬空对象。

.git/index是暂存区,记录了当前被git add但未commit的文件。如果开发者git add flag.txt后没有commit就发布了,flag会留在index里。恢复方法是git ls-files --stage查看index中的文件,然后git checkout-index -a把文件恢复到工作区。

悬空对象题目则依赖git fsck --lost-found。这个命令会扫描所有对象,找出没有被任何引用指向的commit和blob。输出里会列出dangling commit和dangling blob。对于dangling commit,用git show <hash>查看;对于dangling blob,用git cat-file -p <hash>查看。CTF里经常把flag放在一个被git reset --hard丢弃的commit里,git fsck是唯一能找到它的方法。

题目类型关键命令恢复目标
log泄露git log --all -p历史提交中的文件
stash泄露git stash list+apply临时保存的改动
index泄露git ls-files --stage暂存区文件
悬空对象git fsck --lost-found被reset丢弃的commit

5. 常见问题与排查技巧实录

5.1 下载的.git不完整怎么办

这是最常见的问题。CTF靶场往往只暴露部分.git文件,比如只有HEAD和objects,没有refs和logs。这时候直接用git命令会报错。我的处理思路是:

先看objects目录是否完整。如果objects里有大量文件,说明历史数据还在,只是引用丢了。可以手动创建.git/refs/heads/master,内容填入git fsck找到的最新commit哈希。或者更简单,直接git fsck --lost-found,它会列出所有悬空commit,从中找到包含flag的那个。

如果objects也不完整,比如只有几个文件,那就需要根据git log的输出反推。但这种情况很少见,因为CTF出题人通常会把完整objects放出来,只是删掉refs增加难度。

5.2 git log报“bad object”怎么处理

这个错误通常是因为.git/objects里的某个对象损坏,或者下载过程中文件被截断。排查方法是:

  • 检查.git/objects下是否有0字节文件,有的话重新下载。
  • 用git fsck --full查看具体是哪个对象报错。
  • 如果只是个别对象损坏,可以尝试从其他来源补全,或者跳过该commit,用git log --all --skip=<n>继续查看。

我遇到过一次是wget下载时把二进制文件当HTML处理了,导致objects文件全是乱码。解决办法是加--header="Accept-Encoding: gzip"或者直接用curl --compressed。

5.3 stash apply冲突的解决

git stash apply时如果工作区有同名文件,会报冲突。CTF环境下工作区通常是空的,所以冲突少见。但如果遇到,可以用git stash show -p stash@{0} > patch.diff,然后git apply patch.diff,或者直接git checkout stash@{0} -- <file>把特定文件从stash中取出。

5.4 如何快速定位flag

flag通常符合flag{...}或ctfhub{...}格式。在下载完.git后,可以用grep快速搜索:

grep -r "flag{" .git/objects/ 2>/dev/null

但objects是zlib压缩的,直接grep可能搜不到。更可靠的方法是批量解压:

import zlib, os for root, dirs, files in os.walk('.git/objects'): for f in files: path = os.path.join(root, f) try: data = zlib.decompress(open(path, 'rb').read()) if b'flag{' in data: print(path, data) except: pass

这个脚本我用了很多次,基本能秒出flag。注意排除pack目录,因为pack文件是打包格式,需要git unpack-objects先解包。

5.5 排查速查表

现象可能原因解决命令
/.git/HEAD返回404无泄露或路径被拦截尝试/.git/config、/.git/index
git log报not a git repositoryrefs缺失git init后覆盖objects,或git fsck
git stash list为空logs缺失检查.git/refs/stash,用git cat-file
objects文件0字节下载中断重新下载,用curl --compressed
grep搜不到flag对象被压缩用Python脚本批量解压
git fsck无输出无悬空对象检查其他分支和stash

注意:所有操作请在授权的靶场或自己的实验环境中进行。未经授权对真实站点进行.git目录探测和下载可能违反法律法规,本文仅用于技术学习和防御能力提升。

6. 防守视角:如何避免自己的项目泄露.git

聊完攻击利用,再从防守方角度说几句。我参与过几次企业代码仓库的暴露面排查,发现很多问题其实可以在CI/CD阶段就堵住。

第一,Web服务器配置拦截规则。Nginx里加一条location ~ /\.git { deny all; },Apache里用<DirectoryMatch "^/.*/\.git/">配合Require all denied。这能挡住绝大多数直接访问。

第二,构建产物排除版本控制目录。在Dockerfile或构建脚本里显式删除.git,比如RUN rm -rf .git。用tar打包时加--exclude=.git。

第三,定期扫描暴露面。用自动化工具定期检查线上域名是否存在/.git/HEAD可访问的情况。我见过一个团队用简单的curl脚本每天跑一遍所有域名,发现过好几次误发布。

第四,敏感信息不硬编码。即使.git泄露,如果代码里没有明文密钥,损失也能控制。用环境变量或密钥管理服务,提交前用git-secrets或gitleaks做扫描。

第五,限制Git仓库的访问权限。内部仓库用SSH密钥或访问令牌,不要用匿名HTTP。公开仓库要确认没有敏感历史。

我个人在实际操作中的体会是,Git泄露的防御成本远低于事后应急。一个Nginx配置行就能挡住的问题,没必要等到源码被拖走再补救。另外,CTF题目里那些“删了还能恢复”的场景,在真实企业环境里同样成立,所以不要以为git rm就安全了,该用git filter-branch或BFG清理的历史一定要清理干净。

最后分享一个小技巧:如果你在排查时不确定某个.git目录是否完整,可以先访问/.git/logs/HEAD。这个文件记录了所有引用变更,包括commit、reset、checkout、stash操作。它的内容往往比git log更全,能直接告诉你开发者做过哪些“危险操作”。我靠这个文件定位过好几次被reset掉的flag。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进