Introduction
This document is also available on GitHub.
This is part 1 of 3 about bugs inside Gitea and Gogs.
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.
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.
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 appropriateWWW-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.
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
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.
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.
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
- 04-07-2018: Release