前阵子出现的一个神奇的洞,有同事问起,就进一步研究了一下,发现还挺有意思的,这里遂作记录。
Git 也能 RCE – CVE-2024-32002
漏洞描述
官方描述为
Git is a revision control system. Prior to versions 2.45.1, 2.44.1, 2.43.4, 2.42.2, 2.41.1, 2.40.2, and 2.39.4, repositories with submodules can be crafted in a way that exploits a bug in Git whereby it can be fooled into writing files not into the submodule’s worktree but into a
.git/
directory. This allows writing a hook that will be executed while the clone operation is still running, giving the user no opportunity to inspect the code that is being executed. If symbolic link support is disabled in Git (e.g. viagit config --global core.symlinks false
), the described attack won’t work. As always, it is best to avoid cloning repositories from untrusted sources.
从描述中,可以摘取几个关键内容
- 该漏洞由符号链接引发
- 漏洞涉及submodule
- 本该涉及submodule的操作最后指向了
.git
目录,最终导致了漏洞的发生 git clone
过程中将会引发hook,导致恶意利用发生,因此用户可能不能及时的看到恶意代码的行为git hook
将会参与整个漏洞的利用链
总结一下,漏洞发生在git clone阶段,并且操作对应的submodule
模块,因为漏洞的原因,未将数据写入对应目录,而是将数据写入.git目录,并且最终触发了一个hook脚本,导致了漏洞的发生。
git基础补习
如果想要看懂这个洞,需要对git的目录结构要有基本的认知。如果对这一部分知识了解,可以直接跳到漏洞成因
当我们执行git clone
操作的时候,实际上执行了以下几个操作
- 创建指定的仓库名字
mkdir -p <path>
- 初始化git仓库
git init
- 添加远程仓库
git remote add origin <url>
- 下载对象引用等
git fetch origin
- 创建远程跟踪分支
git branch --track <branch> origin/<branch>
- 检出默认分支
git checkout <branch>
实际上,代码文件在第4步就会被下载下来,并且存放在.git
文件中,之后由对应的branch
和checkout
操作来进行编辑组合。举个例子来说,假设我们现在有一个空仓库叫做main-repo
,此时我们在其中创建文件main.txt
,main.txt
中包含内容
1 | main testintg |
在未commit的时候,此文件结构如下:
1 | ├── .git |
此时,如果我们将这个修改commit之后,目录结构会变成如下
1 | . |
此时我们的commit 如下
1 | commit 68c0443c8b532833b48a835215ff1f2dac33af2a (HEAD -> master) |
可以看到,commit正对应着目录68
,而文件名正好就是68后面的一串hash,可以看到这些目录和log存在一定的对应关系,这些关系是什么呢?这就要牵扯到git的本质
git 对象管理
git本质上是一个类文件管理系统,其使用一种称为对象模型的方式来存储数据。主要的 Git 对象类型包括:
Blob(Binary Large Object)
:存储文件的内容。Tree
:存储目录结构和文件名到 blob 引用的映射。Commit
:存储指向 tree 对象的引用,以及提交信息(如作者、日期、父提交等)。
使用指令
1 | git cat-file -p 目录名+文件名 |
即可查看对应的文件内容(这里文件名和git默认规则一样,不需要敲全)
我们检查之前提到的68
目录下的文件,可以看到内容为
1 | git cat-file -p 68c0443 |
这种文件就被称之为commit
。然后可以看到其指向了一个叫做tree
的对象,正好也对应了一个目录和文件,尝试访问可以得到如下结果
1 | git cat-file -p 0c20 |
这个就是tree
,表示对一个blob
的引用,我们最后查看对应的blob
1 | git cat-file -p af21 |
正是我们文件的内容。git正是使用了这种层级的对象管理机制,将所有的内容关联起来。
submodule
有些时候,我们可能要再一个仓库中引用另一个仓库的内容,这个库可能是一个基础库,会在多个库中被使用,例如压缩,日志打印等等,为了能够正确处理上述的场景,在git中,支持将另一个仓库作为
submodule
引入到当前库中。
在讨论子模块之前,我们需要区分为三个概念
- 子模块的名字,体现在
--name
参数上,我们这里写作<name>
- 子模块的路径,这个为倒数第二个参数,这里写作
<submodule_repo>
- 子模块在主仓库中的名字,这里写作
<submodule_path>
之后我们会反复使用这三个概念来描述不同的术语。
例如我们有另一个库,叫做submodule-repo
,里面有一个文件叫做submodule.txt
,内容如下
1 | cat .\submodule.txt |
此时文件结构如下
1 | . |
此时,假设我们想将其引入到我们主要仓库中,我们可以这样做
- 将其作为一个叫做
submodule
的库,添加到当前的库中
1 | git submodule add ../submodule-repo submodule |
- 提交修改
1 | git commit -m "Add submodule" |
那么此时,我们上面提及的三个参数分别为
- name:submodule
- submodule_repo:…/submodule-repo
- submodule_path: submodule
此时我们再次检查main-repo
的目录,结果如下
1 | . |
可以发现,在main-repo
目录中新增了如下内容
- 根据
submodule_path
创建的submodule
目录,里面包含了submodule-repo
的内容,其中这里的.git
为符号链接,指向../.git/modules/submodule
,也就是<target_repo>/.git/modules/<name>
这个路径 .gitmodules
文件.git
目录中新增了modules
,里面包含了一个由<name>
命名的submodule
的目录,如果此处使用了--add name
,此时目录名字会被替换成name
;这个目录中包含的是submodule-repo
中.git
的全部内容
这里的.gitmodules
文件记录了当前submodule的基本情况
1 | [submodule "submodule"] |
- 引号部分记录的正是
<name>``submodule_name
,如果我们此处加了参数--add name
,那么引号部分将会被替换成name
- path 中记录了模块在这个仓库中的路径
<submodule_path>
,也就是我们最后跟着的参数,这个是【submodule实际的存放路径,以及检出后存放的路径】 - url 中则记录了对应的路径
<submodule_repo>
,是倒数第二个参数
同时我们也注意到,此时git会将子目录中的.git放到当前目录的.git中,存放规则为
1 | .git/modules/<name> |
<name>
的命名支持为多级路径,例如如果命名为path1/path2
,则此时存放路径就会变为
1 | .git/modules/path1/path2 |
整个submodule
的clone过程,根据逆向分为两个部分
- 尝试将对应仓库的
.git
单独clone
下来,但是不进行checkout,根据分析代码,其指令大致如下
1 | git clone --no-checkout --progress --separate-git-dir <target_repo>/.git/modules/submodule --no-single-branch -- <submodule_path> <target_repo>/<name> |
- 完成clone之后,最终会根据指定的branch,将内容进行checkout,最终释放对应的文件内容
hooks
之前的展示中特意跳过了hooks
这个目录,因为这个目录中有很多脚本的样例:
1 | +── hooks |
这些脚本会在git的某个操作阶段执行。例如pre-push
这个名字的脚本会在git执行push指令前执行,commit-msg
则是在commit阶段会执行。我们之后的攻击中会涉及一个叫做post-checkout
的脚本,这个脚本会在checkout
操作后执行。
漏洞成因
漏洞本质上是由于git在进行clone的时候,在repo整体拷贝过程中,由于Windows或者MacOS操作系统大小写不敏感的特点,主repo中用于存放子模块的目录被另一个指向.git目录的同名符号链接覆盖,导致往子模块写入数据的时候,全部都写入了.git目录中,最后配合hook脚本完成隐蔽的攻击。
漏洞复现的时候,需要进行如下的配置才能生效
1 | # Set Git configuration options |
并且,如果想尝试在Windows下复现,git需要获得管理员权限才能进行符号链接的创建。
在官方的仓库中,给出了针对这个漏洞的poc用作测试用例,我们可以从检测脚本中一窥究竟。
1 | test_expect_success CASE_INSENSITIVE_FS,SYMLINKS \ |
脚本的前半段添加一个叫做hook
的仓库,这个仓库添加完以后目录结构如下
1 | . |
这里会注意到一个很有趣的现象,这个路径有意的在模仿.git的目录结构,尤其是hooks/post-checkout
,当然,由于这个脚本本身并未放在本仓库的.git
目录中,当这个仓库被clone的时候脚本并不会被触发。
后半段的部分比较精妙,我们来仔细讲解一下。首先创建了一个叫做captain
的仓库,然后调用了这个指令
1 | git submodule add --name x/y "$hook_repo_path" A/modules/x |
我们根据之前的做法,确认三个参数对应的值
<name>
:x/y
<submodule_repo>
:"$hook_repo_path"<submodule_path>
:A/modules/x
当调用这个指令之后,git会做如下的事情
- 在
captain
目录中创建一个叫做A/modules/x
的子目录,这个目录将会存放来自"$hook_repo_path"
(也就是前面添加的hook仓库)中的所有内容 - 上述步骤中,拷贝到
captain
仓库的hook
仓库中的.git
文件被替换成符号链接,指向../../../.git/modules/x/y
,也就是<target_repo>/.git/modules/<name>
的路径,这里会存放真正的hook
的.git
目录 - 在
captain
的.git
目录中的modules
目录下,创建x/y
目录,并且往其中拷贝所有的hooks/.git
的内容 - 创建
.gitmodule
目录
此时,captain
中比较重要的文件结构如下
1 | . |
让我们把几个关键目录罗列以下
- 实际存放了
hook
仓库中.git
的路径
1 | .git/modules/x/y |
- 存放了被拷贝过来的
hook
仓库内容的路径
1 | A/modules/x |
- 从
captain
的视角上看,子模块hook
仓库中存放post-checkout
的路径
1 | A/modules/x/y/hooks/post-checkout |
- 从
captain
的视角上看,子模块hook
仓库.git
中hooks的路径为
1 | .git/modules/x/y/hooks/ |
仔细看会发现,3和4的路径几乎只相差了A
和.git
部分,这就是这个漏洞攻击的一个前提。
在完成了布置之后,脚本会执行如下的逻辑
1 | printf .git >dotgit.txt && |
这里利用了git的比较底层的指令,通过这个操作,能够将a
作为一个符号链接文件添加到 Git 索引中,符号链接指向 .git。这个操作会存放在git的索引中,而不会直接在目录中存在。实际上,这样操作完之后,目录结构如下
1 | . |
可以发现,这个a
并不存在,但是在git的对象管理中,这个a作为一个对象存放了下来
1 | git cat-file -p 76d2a0138b |
这就是这个攻击的隐蔽之处:整个攻击过程中,符号链接文件始终藏在.git
的对象索引中,所以粗略一看是无法找到有问题的部分的。但是,当我们在对captain
仓库进行clone的时候,这个符号链接a
就会被释放出来。
最后执行
1 | git clone --recursive captain hooked |
就能实现最终的攻击。
此时,我们可以模拟以下整个攻击流程:
当我们在进行clone的时候,程序首先尝试将captain
目录拷贝下来,执行ed64559167
的操作,此时根据顺序,首先会创建这样的目录(tree)
1 | . |
然后,git会紧接着创建符号链接a
(Blob),此时由于大小写不敏感的特点,此时目录会变成
1 | . |
接下来,会尝试对41eaba3
中的对象进行释放,整个对象指向的为
1 | 040000 tree a555b64513d2e0a23bca63e990b793927daafa43 modules |
于是就会顺着我们之前A
目录指向的内容一点点进行释放,此时的释放路径变为
1 | A/modules/x/y/hooks/post-checkout |
而由于A
此时被a
顶替,a
指向了.git
,所以此时释放的路径改变为
1 | .git/modules/x/y/hooks/post-checkout |
于是,此时在我们的captain
仓库中的.git/modules/x/y/hooks/post-checkout
就成为了原本存放在hook目录中的一个脚本。而当完成了clone之后,最终captain
目录中的git
会尝试将hook
的内容进行checkout
操作,此操作最终就会诱发对应的post-checkout
,导致脚本被执行!
修复策略
从git的官方修复,中,可以看到引入了一个叫做dir_contains_only_dotgit
的函数
1 | static int dir_contains_only_dotgit(const char *path) |
这个函数的作用为保证当前目录中仅包含.git目录这一个文件。之后程序还引入了如下的的修复
1 | static int clone_submodule(const struct module_clone_data *clone_data, |
此处的clone_data_path
实际上为<target_repo>/<name>
可以看到,在clone_submodule
阶段,程序会保证本地路径满足以下条件才会进行clone操作
- 当进行clone操作前,目的地址为空
- 完成预备环境准备后(safe_create_leading_directories_const)和
submodule
的clone
(但是不立即检出check-out
工作目录中的文件)(run_command(&cp))后,程序检查目标目录中是否仅包含.git
文件
此时这里的submodule的clone操作实际上执行的
1 git clone --no-checkout --progress --separate-git-dir <target_repo>/.git/modules/submodule --no-single-branch -- <submodule_path> <target_repo>/<name>这个指令会将submodule的内容拷贝到根目录,但是不进行检出,也就是说此时 .git 中已经存放了 submodule 的 .git,但是还未发生检出(check-out)动作
实际上,指令执行的时候,会将.git
中的内容放置在.git/modules/x/y
这个路径下。
而根据我们之前的漏洞分析,clone_data_path
,也就是<target_repo>/<name>
,target_repo/A/modules/x/
。如果未漏洞的影响下,此时的路径实际上是
1 | /A/modules/x |
那么此时就应该仅仅只有目标文件的.git文件。但是如果在漏洞印象下,此时的路径变为了
1 | .git/A/modules/x |
而显然根据我们前文分析,在这个路径下会有一个叫做y
的目录存在,因此能够被检测出来。即便我们在起名阶段进行了绕过,实际上这个攻击的过程中一定会往对应的路劲写入文件,因此仓库一定会影响到指定的目录。所以当前的修复其实是非常合理的。
总结
整个分析下来,感觉这类漏洞最重要的点其实是时机,比如说:
- git clone 的时候,究竟是先拷贝了.git文件,还是先拷贝了目录中的其他文件?
- git clone 的时候,是先拷贝的符号链接,还是原始文件?它的依据是什么?
- post-checkout 是在
captain
进行checkout
的时候执行,还是在hook
进行checkout
的时候进行的执行?
正是不同时机的巧妙结合,导致了这个漏洞的出现。这类符号链接类的漏洞需要抓住关键,找到最合适的诱发条件,才能最终触发。