tags: container,漏洞分析

docker CVE-2019-14271分析与复现

一、基本信息

条目 详情 备注
项目地址 https://github.com/moby/moby
发布日期 2019-07-25
CVE-ID CVE-2019-14271
EDB-ID \
exploits swr.cn-southwest-2.myhuaweicloud.com/container_pentest/cve-2019-14271:v0.1
https://bestwing.me/CVE-2019-14271-docker-escape.html
https://unit42.paloaltonetworks.com/docker-patched-the-most-severe-copy-vulnerability-to-date-with-cve-2019-14271/
cvedetails https://www.cvedetails.com/cve/CVE-2019-14271/
官方公告影响范围 v19.03.0
实际影响范围 v18.09.9<=docker<v19.03.8
业界公告修复版本 v19.03.1 紧急修复
实际修复版本 v19.03.8 完整修复
CVSS 9.8 CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H redhat
发现人 docker官方

二、docker cp简介

在容器和宿主机的文件系统直接复制文件。执行cp命令的入口有两个,分别是docker container cp和docker cp, 两者作用相同。命令格式为docker cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH|-docker cp [OPTIONS] SRC_PATH|- CONTAINER:DEST_PATH,分别表示从容器内复制文件到宿主机,或从宿主机复制文件到容器内。不可以在容器间复制文件,也不可以在容器外之间复制文件。

三、漏洞详情

1. 介绍

根据docker cp 源码分析, docker cp命令在容器中打包和解包文件时,分别会调用docker-tardocker-untar命令。为了防止这两个命令的写操作影响到宿主机,在执行具体操作前,进行了chroot。根据docker-tar,docker-untar源码分析,我们知道,这些函数是不受capbility,seccompLSM的限制的。

受CVE-2019-14271漏洞影响的docker在执行docker cp命令时,docker-tar进程可能在chroot到容器rootfs后,加载nsswitch动态库。攻击者可以将容器内的nsswitch动态库修改为恶意文件,从而获取docker-tar进程权限,实现容器逃逸。

2. 影响

本节(2.影响)的分析较长,且需要对CVE-2019-14271已有了解,因此可以先跳过此节,阅读完全文后再回头阅读。

2.1 漏洞危害

攻击者可以将容器内的nsswitch动态库修改为恶意文件,从而获取docker-tar进程权限,实现容器逃逸。

2.2 受影响版本

  • 静态编译
    • 不受影响
  • 非静态编译
    • 19.03.0 <= docker < 19.03.1 受影响
    • docker=18.09.9, 19.03.1 <= docker < 19.03.8 有条件受影响(在宿主机加载nsswitch共享库失败)
    • 18.09.0 <= docker < 18.09.8 不受影响

docker=18.09.9, 19.03.1 <= docker < 19.03.8 有条件受影响

原因可能是在宿主机加载nsswitch共享库时失败,因此在chroot到容器rootfs后可以重新加载。

2.3 18.09是否受影响

TL;DR: 18.09.9未彻底修复; 18.09.0<= docker <=18.09.8不受影响,因其使用的archive/tar库,是从go源码中复制到vendor的代码,这段代码不会去加载nsswitch动态库;go1.11没有所谓的bug

在19.03.1的修复中,docker的开源项目经理thaJeztah在解答“为什么18.09不受影响”时这样解释:

current versions of 18.09 are not affected because they are still using Go 1.10, and a custom archive implementation. The 18.09 release branch was recently updated to Go 1.11 (which also removed the custom archive implementation), but no release was done yet with that code, but we had to backport the fix to prevent the next patch release being vulnerable https://github.com/moby/moby/pull/39612#issuecomment-517999360

他认为18.09不受影响,是因为18.09版本使用的go编译器版本是go1.10, 因此不受影响。

互联网上搜到的大部分分析文章,几乎都是 Yuval Avrahami的文章docker修复了最严重漏洞CVE-2019-14271的改编或重写。文章中提到:

Docker is written in Golang. Specifically, the vulnerable Docker version was compiled with Go v1.11. In this version, some packages that contained embedded C code (cgo) would dynamically load shared libraries at runtime.

他认为,在go1.11版本中,有一些因为cgo引入的代码会动态得加载共享库。因此,我们找到的其他文章几乎都是类似的观点,认为go1.11有一个bug。

thaJeztah与Yuval Avrahami的观点类似,他更进一步得认为,go1.10没有这样的bug。

而在实际的分析中,我发现go1.11没有所谓的bug

我花了相当长的时间,摸索清楚了其中真实的漏洞成因。

我发现,go1.10和go1.11的archive/tar库,都会调用user.LookupId函数, 这个函数会导致nsswitch动态库的加载。

跟进这个函数,我们最终定位到该函数会调用os/user库的mygetpwuid_r, mygetpwuid_r是nsswitch的getpwuid_r封装。

在以上的行为上,两者没有差异。与之相反的,go1.11在这个问题上,是做了改进的。

go1.11增加了osusergo的build tag,如果指定了osusergo, 则编译器不会包含这个文件,也不会将相关代码编译进二进制文件中。 https://github.com/golang/go/blob/go1.11/src/os/user/cgo_lookup_unix.go#L6

// +build cgo,!osusergo

这个改变,是在go1.11才引入的,这解决了历史版本的一个问题,即静态编译不会再因为nsswitch而被打破。

https://github.com/golang/go/commit/62f0127d81

这是一个提升,这个提升并不会导致CVE-2019-14271的产生,即, 使用go1.11的安全性不会比使用go1.10的安全性弱。

docker18.09使用go1.10,19.03使用go1.11,这个变化,在理论上不会对是是否加载nsswitch产生变化。但是为什么实际验证中,发现docker18.09不会加载nsswitch动态库呢?

在分析go1.10和go1.11没有发现区别后,我转而分析docker18.09和19.03的区别。

我在19.03分支vendor/archive/tar文件夹相关的commit记录中,发现一个奇怪的现象,即一开始是有vendor/archive/tar的(这表示,go在编译docker时,会优先选在vendor的库,而不是gosrc中的库)。vendor/archive/tar中的代码,不会调用上文提到的user.LookupId函数,也就不会加载nsswitch库。

在相当久之前,这个commit增加了vendor/archive/tar: https://github.com/moby/moby/commit/72df48d1ad417401a5ce0a7ee82a3c8ba33e091c#diff-63d9c33e601a452591d5b82832ad760af4d1b4994675659217058ff5638c77e8

代码如下:未调用user.LookupId函数 https://github.com/moby/moby/blob/72df48d1ad417401a5ce0a7ee82a3c8ba33e091c/vendor/archive/tar/stat_unix.go#L18

func statUnix(fi os.FileInfo, h *Header) error {
	sys, ok := fi.Sys().(*syscall.Stat_t)
	if !ok {
		return nil
	}
	h.Uid = int(sys.Uid)
	h.Gid = int(sys.Gid)
	h.AccessTime = statAtime(sys)
	h.ChangeTime = statCtime(sys)
	return nil
}

Kolyshkin参与的向go1.11中加入osusergo的build tag后不久,Kolyshkin在docker19.03的分支中增加了一个删除vendor/archive/tar的commit。

https://github.com/moby/moby/commit/10fd0516b9f9f04d0f0e2c0755e704303f1a487f#diff-63d9c33e601a452591d5b82832ad760af4d1b4994675659217058ff5638c77e8

删除vendor/archive/tar,意味着会使用go官方archive/tar库。根据上文的分析,如果使用静态编译,并使用osusergo tag,则不会加载nsswitch。如果使用非静态编译,则会调用user.LookupId函数。

就是这个commit直接导致了漏洞的。虽然osusergo的引入,使得静态编译的二进制文件,不会再加载nsswitch动态库。但是对于非静态编译的二进制文件,因为archive/tar的变化,由不加载变成了加载。

回答我们之前的疑问,为什么18.09不会加载nsswitch动态库呢?

因为18.09.8之前一直都有vendor/archive/tar, 因此,go版本的变化不会对其造成影响。

https://github.com/moby/moby/blob/v18.09.8/vendor/archive/tar/stat_unix.go

但v18.09.9,v18.09.9-rc1删除了vendor/archive/tar。 https://github.com/moby/moby/commit/ebf396050d6e977df2669d5e7d3f38098719f7c2#diff-fc3997a1697456150eac42ad2a2f77c420757ce451fdaf4f95a509cfe6e70cdb

因此18.09.9中,对于该漏洞只有如下一处修复,可能未修复彻底。 https://github.com/moby/moby/blob/v18.09.9/pkg/chrootarchive/archive.go#L16

func init() {
	// initialize nss libraries in Glibc so that the dynamic libraries are loaded in the host
	// environment not in the chroot from untrusted files.
	_, _ = user.Lookup("docker")
	_, _ = net.LookupHost("localhost")
}

2.4 为什么静态编译版本明确不受影响?

在上一个问题中我们有提到,尽管v19.03.0使用go官方的archive/tar库,因为静态编译使用了osusergo这个build tag,也不会将 可能导致加载nsswitch库的 代码编译进来。

https://github.com/moby/moby/blob/v19.03.0/hack/make.sh#L148

LDFLAGS_STATIC=''
EXTLDFLAGS_STATIC='-static'
ORIG_BUILDFLAGS=( -tags "autogen netgo osusergo static_build $DOCKER_BUILDTAGS" -installsuffix netgo )
BUILDFLAGS=( ${BUILDFLAGS} "${ORIG_BUILDFLAGS[@]}" )

https://github.com/golang/go/commit/62f0127d81

// +build cgo,!osusergo

docker在v18.09.0开始使用osusergo

https://github.com/moby/moby/commit/70cdb1c66429582ecfdc5abed67189dd90ab7572#diff-50020d1afbd04b368ec34bed9b221ddd792210aac8f130a50e70b74fcb4a7e0c

即使未使用osusergo, 使用go1.10前(不含)编译的二进制文件,也不会加载nsswitch。 https://github.com/golang/go/commit/0564e304a6ea394a42929060c588469dbd6f32af#diff-e0ee3deb6e5f67035f8a9808f8ec1af5e8b8926ff4d2de8fd664379fd3a85ae9

那么是否存在未使用osusergo,但是使用了go1.10的版本呢?

docker在v18.05.0-ce-rc1开始使用go1.10 https://github.com/moby/moby/blob/v18.05.0-ce/Dockerfile#L38

但vendor/archive/tar早在v17.06.1-ce-rc2就已经引入了,因此静态编译版本不受影响。 https://github.com/moby/moby/commit/89bacc278b3b6707d57b1b9f95a9221091bbde93

2.5 为什么需要osusergo?

不同版本glibc中的函数实现可能不同,所需的参数不同。静态编译的文件如果需要调用c代码,则可能只能在特定版本的glibc库上运行,这不符合docker设计的初衷。

2.6 是否影响docker以外的软件

// TODO

https://github.com/containers/buildah

https://github.com/containerd/containerd/pull/2476

四、漏洞复现

1. 环境

st0n3@yoga:~$ docker run -ti ssst0n3/docker_archive:CVE-2019-14271
... // wait for container starting up
Ubuntu 20.04.1 LTS ubuntu ttyS0

ubuntu login: root
Password: root
Welcome to Ubuntu 20.04.1 LTS (GNU/Linux 5.4.0-56-generic x86_64)
...

root@ubuntu:~# docker version
Client: Docker Engine - Community
 Version:           19.03.0
 API version:       1.40
 Go version:        go1.12.5
 Git commit:        aeac949
 Built:             Wed Jul 17 18:15:07 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.0
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.5
  Git commit:       aeac949
  Built:            Wed Jul 17 18:13:43 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683
root@ubuntu:~# 

2. 复现(19.03.0)

确认libnss_files.so版本相同,避免兼容性问题

root@ubuntu:~# ls -lah /lib/x86_64-linux-gnu/libnss_files.so.2 
lrwxrwxrwx 1 root root 20 Aug 17  2020 /lib/x86_64-linux-gnu/libnss_files.so.2 -> libnss_files-2.31.so

root@ubuntu:~# docker run -d -ti --name cve-2019-14271 swr.cn-southwest-2.myhuaweicloud.com/container_pentest/cve-2019-14271:v0.1
e4df8bf1c8594d57285d4670f9f9c6b7e578c0b963071a1b6541e2f16d64ba6e
root@ubuntu:~# docker exec -ti cve-2019-14271 ls -lah /lib/x86_64-linux-gnu/libnss_files.so.2
lrwxrwxrwx 1 root root 20 Aug 17  2020 /lib/x86_64-linux-gnu/libnss_files.so.2 -> libnss_files-2.31.so

模拟攻击过程,执行docker cp命令后,可以发现容器内挂载了host_fs

root@ubuntu:~# docker cp cve-2019-14271:/etc/hosts . 
root@ubuntu:~# docker exec -ti cve-2019-14271 ls -lahd /host_fs
drwxr-xr-x 19 root root 4.0K Apr  6 10:06 /host_fs

详细exp参考 https://bestwing.me/CVE-2019-14271-docker-escape.html

3. 复现(docker=18.09.9或19.03.1<=docker<=19.03.7)

区别在于需要通过删除/etc/nsswitch.conf和libnss_files.so.2等方式,使在宿主机上nsswitch相关动态库加载失败。

root@host $ docker run -ti ssst0n3/docker_archive:ubuntu-20.04_docker-ce-18.09.9_docker-ce-cli-18.09.9_containerd.io-1.2.2-3_runc-1.0.0-rc6
ubuntu login: root
Password: root
root@ubuntu:~# docker version
Client:
 Version:           18.09.9
 API version:       1.39
 Go version:        go1.11.13
 Git commit:        039a7df9ba
 Built:             Wed Sep  4 17:24:10 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.9
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.11.13
  Git commit:       039a7df
  Built:            Wed Sep  4 16:19:38 2019
  OS/Arch:          linux/amd64
  Experimental:     false

root@ubuntu:~# rm /lib/x86_64-linux-gnu/libnss_files.so.2
root@ubuntu:~# rm /etc/nsswitch.conf
root@ubuntu:~# docker run -d -ti --name cve-2019-14271 swr.cn-southwest-2.myhuaweicloud.com/container_pentest/cve-2019-14271:v0.1
root@ubuntu:~# docker cp cve-2019-14271:/etc/hosts .
root@ubuntu:~# docker exec -ti cve-2019-14271 ls -lahd /host_fs
drwxr-xr-x 19 root root 4.0K May 27 03:12 /host_fs
root@ubuntu:~# docker exec -ti cve-2019-14271 cat /host_fs/etc/hostname
ubuntu

五、漏洞分析

1. docker cp流程分析

参见: https://ssst0n3.github.io/post/%E7%BD%91%E7%BB%9C%E5%AE%89%E5%85%A8/%E5%AE%89%E5%85%A8%E7%A0%94%E7%A9%B6/%E5%AE%B9%E5%99%A8%E5%AE%89%E5%85%A8/%E8%BF%9B%E7%A8%8B%E5%AE%B9%E5%99%A8/%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%AE%B9%E5%99%A8/docker/docker%E6%BA%90%E7%A0%81%E5%AE%A1%E8%AE%A1/docker%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/docker-container/docker-container-cp/docker-cp-%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90.html

2. docker-tar与docker-untar

根据docker cp的源码分析,我们已经知道docker-tar, docker-untar的存在,也需要理解一下这两个命令的执行过程,参见:

https://ssst0n3.github.io/post/%E7%BD%91%E7%BB%9C%E5%AE%89%E5%85%A8/%E5%AE%89%E5%85%A8%E7%A0%94%E7%A9%B6/%E5%AE%B9%E5%99%A8%E5%AE%89%E5%85%A8/%E8%BF%9B%E7%A8%8B%E5%AE%B9%E5%99%A8/%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%AE%B9%E5%99%A8/docker/docker%E6%BA%90%E7%A0%81%E5%AE%A1%E8%AE%A1/docker%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/docker-tardocker-untar%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90.html

docker-tar和dockerd共用一个二进制,基于docker的reexec库实现,有一个简单的调试技巧,可以跳过docker cp的前序步骤,直接进入docker-tar的逻辑:

因为reexec是以cmdline选择命令代码的,所以可以通过复制或链接,得到一个docker-tar。

root@ubuntu:~# which dockerd
/usr/bin/dockerd
root@ubuntu:~# ln -sf /usr/bin/dockerd /usr/bin/docker-tar
root@ubuntu:~# docker-tar --help
Usage of docker-tar:

使用strace可以调试docker-tar执行时的系统调用

echo "{}" > /tmp/json
mkdir /tmp/test
cp /etc/hosts /tmp/test/
strace -r -f docker-tar </tmp/json /test/ /tmp/ > /tmp/test.tar
strace -r -f -k -e trace=open,openat,chdir,chroot docker-tar </tmp/json /test/ /tmp/ > /tmp/test.tar

docker-untar的调试类似

echo "{}" > /tmp/json
docker-tar </tmp/json /etc/hosts /tmp/ > /tmp/hosts.tar
strace -f docker-untar </tmp/hosts.tar  3</tmp/json /etc/ /tmp/

如果要gdb调试,可以将dockerd中的"docker-tar"字符串等长度替换,并dockerd复制到对应路径。这样gdb调用的进程的cmdline就一致了。

例如

sed -i s@docker-tar@/bin/r-tar@g dockerd
cp dockerd /bin/r-tar

在gdb内,使用set args指定参数

set args </tmp/json /test/ /tmp/ > /tmp/test.tar

3. docker-tar chroot

docker-tar进程是一个特权进程,为了避免对宿主机造成影响,会chroot到容器的rootfs内执行打包动作 https://github.com/moby/moby/blob/v20.10.6/pkg/chrootarchive/archive_unix.go#L135-L137

func tar() {
    ...
    if err := realChroot(root); err != nil {
        fatal(err)
    }
    ...
    rdr, err := archive.TarWithOptions(src, &options)

4. docker-tar 为什么要加载nsswitch?

上面的源码分析已经涉及到了,具体原因是:

docker-tar调用的archive/tar库在收集文件所属用户信息时,调用了glibc的mygetpwuid_r函数,这个函数是基于nsswitch框架开发的,在执行时会动态加载nsswitch相关库(不在文件头中列出,执运行时动态加载)。

https://github.com/golang/go/blob/go1.11/src/os/user/cgo_lookup_unix.go#L100

return syscall.Errno(C.mygetpwuid_r(C.int(uid),
			&pwd,
			(*C.char)(buf.ptr),
			C.size_t(buf.size),
			&result))

如果在容器的rootfs内加载nsswitch动态库,则有可能会加载恶意的文件,导致docker-tar进程权限被获取。

5. 如何判断是否编译了会加载nsswitch的代码?

strings /usr/bin/dockerd |grep mygetpwuid_r

注:编译了但不一定会执行

6. docker-untar为什么不受影响?

因为未执行会加载nsswitch的代码,通常只有在解析host和user,group信息时才会加载nsswitch。

六、漏洞修复分析

1. 修复分析

v19.03.1版本,在chroot前就主动执行了user.Lookup和net.Lookup,这样会提前加载nsswitch动态库,在chroot后就不需要再次加载。 https://github.com/moby/moby/pull/39612

func init() {
    // initialize nss libraries in Glibc so that the dynamic libraries are loaded in the host
    // environment not in the chroot from untrusted files.
    _, _ = user.Lookup("docker")
    _, _ = net.LookupHost("localhost")
}

但版本发布后,仍有用户反馈存在同样的问题,原因我们在上文有过分析。后续docker在v19.03.8进行了完整修复。

2. 完整修复

为了不打破静态编译,docker前期已经应用了名为osusergo的build tag, 这保证了静态编译的版本不会受此风险。 https://github.com/golang/go/commit/62f0127d81

https://github.com/moby/moby/commit/70cdb1c66429582ecfdc5abed67189dd90ab7572#diff-50020d1afbd04b368ec34bed9b221ddd792210aac8f130a50e70b74fcb4a7e0c

https://github.com/moby/moby/blob/v20.10.6/hack/make.sh#L115

静态编译的二进制文件,不会加载nsswitch动态库。

但是动态编译的版本,例如ubuntu的docker.iodocker-ce,可能还是会加载。

所以,又将不会加载nsswitch动态库的旧版本archive/tar放入了vendor中。 https://github.com/moby/moby/commit/aa6a9891b09cce3d9004121294301a30d45d998d#diff-630ba09448af522154f38ef7685ef1f44b0f3e9430f80829a03ce24f400f3754

至此可以认为彻底解决了此漏洞。

3. 当前修复局限性

尽管不再存在此漏洞,但是当前将archive/tar复制到vendor下,显然不够优雅。也是不可持续的——如果golang官方做了某关键更新,docker还需要重点监测并维护。

当然,这些都不是问题,但是作为go语言的明星项目,docker在未来很可能会考虑一种优雅的实现,届时可能会导致新的漏洞产生。正如,CVE-2019-14271的产生一样。

4. future

和我们预测得一样,docker已经把 将archive/tar从vendor中移除的工作,加入了 roadmap。

https://github.com/moby/moby/issues/42402

commit aa6a989 (19.03 branch) and #40672 (master / 20.10) re-introduced a local copy of go’s archive/tar package, with a patch applied patches/0001-archive-tar-do-not-populate-user-group-names.patch.

This patch was applied for the 19.03.8 release to improve mitigation for CVE-2019-14271 for some nscd configuration.

We should try to get rid of this fork again.

The discussion on #40672 (comment) mentioned we should open a ticket / pull request in upstream Go to make this functionality “optional”, but I think @tonistiigi also had alternatives in mind to address it.

六、总结

1. 漏洞产生经过

根据我们一连串的分析,像查案一样,我们似乎可以推测出事件发生的大致时间线:

  1. docker即将升级go至1.11,开发者开始做一些准备工作,其中移除vendor/archive/tar就是顺便要解决的目标
  2. 如果要使用go官方的archive/tar, 则必须要先推动golang实现osusergo,这样可以保证静态编译的二进制文件不会加载nsswitch
  3. golang增加了osusergo
  4. docker升级go至1.11,并移除vendor/archive/tar
  5. 忽略了非静态编译场景,glibc加载nsswitch的情况,导致漏洞产生
  6. 在chroot前主动加载nsswitch,规避漏洞
  7. 有众多issues报告问题仍然存在
  8. 恢复vendor/archive/tar

2. 受影响的条件

  1. 使用go1.10及以上版本编译
  2. 非静态编译,或静态编译但未使用osusergo tag (这要求编译环境和执行环境的glibc环境相同,否则可能glibc的函数实现和代码中的不一致)
  3. docker使用go官方archive/tar库

3. docker cp漏洞挖掘

我们在docker cp相关的漏洞中,多次发现同一个关键因素——特权进程。试想,如果docker-tar的权限与容器的进程一致,即使存在相关问题,也不会有利用价值。要彻底解决这类问题,应从设计角度,限制此类进程权限,否则未来可能仍然会出现类似漏洞。

例如: docker-tar进程是否会、何时会加载nsswitch,是glibc决定的,如果glibc的行为有变化,则可能会导致CVE-2019-14271重新生效。例如glibc2.33引入的reloadable nsswitch特性,即可支持在检测到nsswitch配置变化后,自动加载nsswitch,这样的功能是可能导致docker的漏洞的。

好在glibc社区已经提前发现了这个风险,做了限制,chroot后不主动加载nsswitch。

https://sourceware.org/bugzilla/show_bug.cgi?id=12459 https://github.com/bminor/glibc/commit/429029a73ec2dba7f808f69ec8b9e3d84e13e804

但是,这样的问题仍然让我们心有余悸。相信如果docker-tar等进程不改变这一关键性质,未来可能还会出现相关漏洞。

参考链接

git时间线