Gitea 1.4.0 Unauthenticated Remote Code Execution

Homepage:

https://gitea.io/en-US/

Description:

Introduction

This document is also available on GitHub.

This is part 1 of 3 about bugs inside Gitea and Gogs.

You can also watch explanation video on YouTube: Race condition and git hooks vs Gitea server

Gitea is a simple git server written in Go language.

It’s easy to install and has many interesting options.

The exploit demonstrated here consists of several elements that, when connected together, lead to a complete takeover of the server.

Gitea exploitation overview

First, we use the error in the GIT LFS implementation to get the contents of the app.ini file.

Then, from this file we read SECRET that can be used to sign JWT tokens.

Thanks to that, we are able to send a falsified session file of a user.

We create a new repository using our newly created administrator session. We need an administrator account because only the administrator can modify git hooks.

The update hook is going to contain our malicious code to be executed on the server.

Then, all that is needed to run this code is to just push any source code changes to the repository.

We already know how in theory the server attack will look like. Now I will discuss its individual elements in detail.

Table of contents

Missing return

PostHandler function is responsible for creating new LFS objects.

Seemingly everything looks fine. If the user does not have the proper permissions, the requireAuth function is called and it sets the appropriate WWW-Authenticate header as well as the 401 status.

However, when we dig a bit deeper in the source code, it turns out that a correct usage of this function looks a bit different.

The vulnerable code lacks the word return, which would terminate the PostHandler function in case of failure.

Without this word, the requireAuth function is going to be executed and then the program will proceed to the next actions, in this case creation of the LFS object.

In such way, we bypassed the mechanism that validates user permissions. We are now able to create any LFS object for any repository.

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)
		# !!!!! MISSING RETURN HERE
	}

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

Arbitrary File Read

The getContentHandler function responsibility is to retrieve content of a file from LFS repository based on its Oid.

Firstly, it checks if the current user has read access to the repository. Thats the reason why we need to use publicly available repository buceause, any user (even not logged in) can download any file from it.

Then the path to the file is retrieved using ContentStore.

LFS_CONTENT_PATH directory is concatenated with oid parameter.

Function named transformKey generates new path to the file.

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

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

It is created on the basis of the first two characters, then backslash, then the next two characters, then backslash again and then the rest of the identifier.

abcdefgh -> ab\cd\efgh

By replacing our oid parameter with dots we get:

gitea\data\lfs\..\..\custom\conf\app.ini

On Windows, the ../ means move up to parent directory. Thanks to that we are able to read contents of the app.ini file.

Signing JWT tokens

The configuration file contains LFS_JWT_SECRET.

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

[security]
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
INSTALL_LOCK   = true
SECRET_KEY     = 79jOlo4qSO

[database]
DB_TYPE  = sqlite3
HOST     = 127.0.0.1:3306
NAME     = gitea
USER     = gitea
PASSWD   = 
SSL_MODE = disable
PATH     = data/gitea.db

Its value is used as a key to sign JWT tokens. These tokens are checked when sending LFS GIT files to the server.

Thanks to the fact that we know its value, we can send any file to any repository with any oid.

We create new LFS object using 4 dots trick again. This time however, we use the path to the sessions directory as oid.

....data/sessions/1/1/11customsession

This directory contains files with information about currently logged in users.

Using Gitea we send to the server a cookie named i_like_gitea.

The server checks if the file with name from the cookie exists in this directory.

If so, it reads the information about the current user stored in the session.

We are going to send here our own session file with fake administrator account.

Why? When we take a look at the function that allows saving files on the server, it turns out it works exactly like the function used to download files.

There is only one difference between them. The .tmp string is added to the name of the file being created.

Get and put function difference

For us, attackers, it means that we can send the file to any place. However, it will always have the .tmp extension.

Race condition

Unfortunately, it turns out that we can’t use the session we send because it’s immediately removed from the server.

The keyword defer is responsible for this - it removes the created file as soon as the Put function finishes its operation

Defer

To bypass this restriction, we are going to make use of a behaviour called race condition.

When a POST request is sent to the server, the Content-Length header is passed along with the data you want to send.

It tells the server how much data the user intends to transfer. Thanks to this the server knows at what stage of data transmission the user is currently.

The trick here is to set the header value to a large number.

The data that the server receives from the user is saved in the file immediately.

The function, however, waits for its completion until its size is equal to the number given in the header.

Thanks to that the file is not removed immediately.

This gives us few dozens of seconds during which we can make use of our session.

Race condition

Git hooks

We create a new repository using our fake administrator account.

Next we go to the repository settings. Because we are the administrator, we have the access to the Git hooks option.

The hooks are scripts located in .git/hooks directory in every repository.

They are executed when actions are performed on the repository.

For example, the update script is executed by Git in response to the git push command.

Malicious update hook

In the body of the update hook we enter our command for execution. Its result is passed to the objects/info/exploit file.

Now we just need to add a new file to our repository and send it to the server using git push command.

At this moment, the server will execute update hook and write the result of our command in the file named exploit.

We can display the result of that command by downloading the object:

http://localhost:3000/root/test/objects/info/exploit

We managed to execute the code on the remote server without having a login and password.

POC

Gitea 1.4.0 Unauthenticated Remote Code Execution

Timeline: