从 Git 原理到 Git 泄露利用:一次学习记录
最近做 CTF Web 题时遇到了 Git 泄露。一开始我只是知道 Git 可以保存代码版本,平时会用 git add、git commit、git log,但真正遇到 .git 泄露题时才发现:如果不理解 Git 底层是怎么保存文件的,就很容易只会跑工具,而不知道为什么能恢复源码、为什么删除过的文件还能找回来。
这篇文章主要记录两个重点:一个是 Git 的底层原理,另一个是 Git 泄露在 CTF 中为什么能被利用,以及应该怎么分析。
Git 保存的不是普通文件夹,而是一套对象数据库
平时看一个 Git 仓库时,我们看到的是普通项目目录,比如:
1 | project/ |
真正重要的是 .git/ 目录。它不是一个无关紧要的隐藏文件夹,而是整个 Git 仓库的核心数据库。Git 的历史提交、分支、标签、暂存区信息、对象数据,基本都放在这里。
Git 底层最核心的结构可以概括成一句话:
1 | HEAD → branch → commit → tree → blob |
这里面最关键的是 commit、tree 和 blob 三种对象。
blob 保存文件内容。它只关心内容本身,不关心文件名。比如 a.txt 和 b.txt 内容完全一样,它们可以指向同一个 blob。
tree 保存目录结构。因为 blob 不知道自己叫什么名字,所以文件名、目录名、哪个路径对应哪个 blob,这些信息由 tree 负责保存。
commit 保存一次提交。它指向一个根 tree,同时记录上一个 commit、作者、提交时间、提交信息等内容。
所以一次提交大概可以理解成:
1 | commit |
这也是 Git 能恢复历史版本的根本原因。每一次 commit 都能找到当时的目录树,目录树又能找到当时每个文件对应的内容。只要这些对象还在 .git/objects/ 里,Git 就有机会把某个历史状态重新还原出来。
git add 和 git commit 背后发生了什么
以前我一直觉得 git add 只是“把文件加入 Git”,但这个说法其实不准确。
当执行:
1 | git add a.txt |
Git 会读取 a.txt 当前的内容,把它做成一个 blob 对象,压缩后存入 .git/objects/。同时,Git 会把“a.txt 这个路径对应哪个 blob”记录到 .git/index 里。也就是说,git add 之后,文件内容其实已经进入 Git 的对象数据库了,只是还没有变成正式提交。
这点在 CTF 中很重要。因为有些文件可能只被 git add 过,没有被 git commit,但它的内容已经作为 blob 留在 objects 里了。这种 blob 如果后来没有被 commit 引用,就可能变成 dangling blob,仍然有机会通过 git fsck 找出来。
而执行:
1 | git commit -m "add a" |
Git 会根据暂存区 .git/index 生成 tree,再根据 tree 生成 commit,最后让当前分支指向这个新的 commit。
所以整个保存过程可以理解为:
1 | 工作区文件 |
这也是为什么 Git 不只是保存“当前文件”,而是保存了一整套可以追溯的历史结构。
.git 目录为什么这么敏感
.git 目录里面有几个非常关键的文件。
.git/HEAD 表示当前仓库位置。常见内容是:
1 | ref: refs/heads/main |
意思是当前 HEAD 指向 main 分支。
.git/refs/heads/main 里面通常保存当前分支最新提交的 commit hash。
.git/objects/ 里面保存真正的 Git 对象,包括 commit、tree、blob。
.git/index 是暂存区文件,里面记录文件路径和 blob 的对应关系。
.git/logs/HEAD 保存 HEAD 移动历史,也就是 reflog。它可能记录一些普通 git log 看不到的旧 commit。
所以如果一个网站暴露了 .git 目录,泄露的就不只是当前源码,而是整个 Git 仓库的历史数据库。当前版本里删掉的文件,历史提交里可能还有;当前分支看不到的提交,reflog 里可能还有;甚至只 add 过没 commit 的文件,也可能以 dangling blob 的形式残留在 objects 中。
这就是 Git 泄露危险的地方。
Git 泄露的判断方式
在 CTF 中,判断一个网站是否存在 Git 泄露,最常见的方式是访问:
1 | /.git/HEAD |
如果返回:
1 | ref: refs/heads/master |
或者:
1 | ref: refs/heads/main |
基本就可以确定 .git 目录存在泄露。
这里要注意,不能只访问:
1 | /.git/ |
因为服务器可能禁止目录浏览,访问 .git/ 返回 403,但具体文件比如 .git/HEAD、.git/index 仍然能访问。
一旦 .git/HEAD 能读,后续就可以继续尝试读取:
1 | /.git/config |
这些文件分别可以帮助我们确认分支、找到 commit hash、恢复文件列表、查找历史提交和下载 Git 对象。
Git 泄露为什么能恢复源码
假设访问:
1 | /.git/HEAD |
得到:
1 | ref: refs/heads/main |
再访问:
1 | /.git/refs/heads/main |
得到一个 commit hash:
1 | 84ed678cfb7099283a742008c0b5bed963c09e88 |
这个 hash 对应的对象路径是:
1 | .git/objects/84/ed678cfb7099283a742008c0b5bed963c09e88 |
下载这个对象后,可以用:
1 | git cat-file -p 84ed678cfb7099283a742008c0b5bed963c09e88 |
查看内容。如果它是 commit 对象,里面会有类似:
1 | tree 5f1a2b3c... |
这里的 tree 指向当前提交对应的目录结构。继续下载 tree 对象,就能看到文件名和 blob hash:
1 | 100644 blob aaaaaaaa... index.php |
再下载 blob 对象,就能拿到具体文件内容。
所以 Git 泄露恢复源码的本质不是“扫描网站目录”,而是沿着 Git 的对象关系链一步步还原:
1 | HEAD → refs → commit → tree → blob → 文件内容 |
自动化工具,比如 GitHack、GitTools,本质上也是在做这件事。工具可以帮我们快速下载 .git 里的关键文件和对象,但理解这条链之后,就算工具失败,也能手动分析。
CTF 中真正要看的不只是当前源码
很多 Git 泄露题不会把 flag 直接放在当前源码里。更常见的情况是:flag 曾经出现过,后来被删除了。
比如历史可能是:
1 | init |
当前版本看不到 flag.txt,但 add flag 那个提交里可能还存在。甚至 remove flag 这个提交的 diff 里,也可能直接显示被删除的内容。
这时应该看:
1 | git log --oneline --all --decorate --graph |
如果看到可疑提交,比如:
1 | a3c9f12 remove flag |
可以直接查看:
1 | git show a3c9f12 |
如果这是删除 flag 的提交,diff 里可能出现:
1 | -AAA{git_history_is_dangerous} |
也可以直接查看旧版本文件:
1 | git show 84ed678:flag.txt |
这比一个个切换版本更方便。
reflog 和 dangling object 往往是关键
有些题会更进一步。出题人可能先提交 flag,然后用:
1 | git reset --hard HEAD~1 |
把这次提交从当前分支历史里移掉。这样普通的 git log 可能看不到那个 commit。
但如果 .git/logs/HEAD 还在,就可以通过 reflog 找到 HEAD 曾经指向过的 commit:
1 | git reflog |
或者直接查看:
1 | cat .git/logs/HEAD |
如果里面出现:
1 | commit: add flag |
就说明曾经有一个提交可能包含 flag。找到对应 commit hash 后,用 git show 查看即可。
另外一种情况是 dangling object。比如某个文件被 git add 过,但没有 commit;或者某个 commit 被 reset 掉了,但对象还没有被 Git 清理。这时可以用:
1 | git fsck --full |
如果看到:
1 | dangling blob 9a1b2c3d... |
就可以继续用:
1 | git cat-file -p 9a1b2c3d... |
查看内容。CTF 中有些 flag 就直接藏在 dangling blob 里。
我的 Git 泄露分析思路
现在遇到 Git 泄露题,我不会只停留在“跑工具恢复源码”。更合理的思路应该是先确认 .git/HEAD 是否泄露,然后尽量恢复仓库;恢复后先搜索当前源码,再查历史提交。如果当前历史没有结果,就继续查 reflog、dangling object、其他分支、tag、stash、.gitignore 和 .git/config。
当前源码可以用:
1 | grep -RniE "flag|AAA\{|secret|password|token|key" . |
所有历史可以用:
1 | git grep -n -i -E "flag|AAA\{|secret|password|token|key" $(git rev-list --all) |
可疑提交用:
1 | git show <commit> |
历史文件用:
1 | git show <commit>:<path> |
悬空对象用:
1 | git fsck --full |
这些命令背后的逻辑其实都一样:围绕 Git 对象数据库找内容,而不是只看当前工作区。
总结
这次学习之后,我对 Git 的理解变得更清楚了。Git 的核心不是简单保存文件副本,而是通过 commit、tree、blob 这些对象,把项目的每一次状态组织成一套可以追溯的历史数据库。
也正因为这样,.git 泄露才会这么严重。它暴露的不只是当前源码,而是可能包含历史源码、删除文件、reset 前提交、暂存过的 blob、分支、tag、stash 和配置信息。
所以做 Git 泄露题时,重点不是死记工具,而是理解:
1 | HEAD → refs → commit → tree → blob |
这条链。
理解了这条链之后,就能明白为什么访问 .git/HEAD 可以作为入口,为什么 commit hash 能继续找到 tree,为什么 tree 能恢复文件名,为什么 blob 能还原文件内容,也能理解为什么删除过的 flag 仍然可能在 Git 历史中被找回来。
- 标题: 从 Git 原理到 Git 泄露利用:一次学习记录
- 作者: Aleph_null
- 创建于 : 2026-07-09 00:00:00
- 更新于 : 2026-07-09 14:42:54
- 链接: https://aleph-null.cc/2026/07/09/git-leak-learning-record/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。