The Trojan Horse You Didn’t Check: PHP Session Unserialization as an Attack Vector

File uploads are often considered a risky surface in web applications. To reduce this risk, developers apply restrictions to allow only specific file types, most often images and documents. The goal is to prevent users from uploading files like .php, .js, or .html that could lead to server side code execution or client side attacks.

Over the years, attackers have developed several techniques to bypass these restrictions. Some rely on double extensions, such as uploading a file named shell.php.jpg, hoping Apache is misconfigured and executes it as PHP. Others abuse MIME type validation, where the uploaded file is labeled as an image but contains executable code. In older versions of PHP, tricks like using a null byte (%00) in filenames, file.php%00.pngcould bypass extension checks, which has been fixed in PHP 5.3.4. Even uncommon extensions like .phtml, it has been used to bypass poorly maintained blacklists. These methods focus on executing the uploaded file on the server.

In this blog post, we’ll take a slightly different perspective by exploring how attackers can abuse file uploads without relying on extensions and how this can lead to critical vulnerabilities when combined with PHP session handling behavior.

How PHP Sessions Work

In PHP, sessions are stored as serialized data in files on the server. When session_start() is called, PHP reads the corresponding session file and unserializes its contents to populate $_SESSION Superglobal.

PHP Session Load — High Level

The diagram above shows a high level overview of how session data is loaded in PHP, from triggering session_start() to fully prepare the $_SESSION array.

To trigger the session loading flow, the request must include a PHPSESSID cookie. For example, PHPSESSID={id} tells PHP to load the session file sess_{id}. Its location depends on the session.save_path setting, commonly /var/lib/php/sessions/ or /tmp/.

Session Journey

Let’s take a journey with this session file and see exactly how PHP brings it to life.

username|s:8:"Abdullah";email|s:12:"me@d3caff.io";

The session flow begins when session_start() is called. PHP initializes the session system, sets the session handler(Files, memcached, etc), and locates the session file based on the session ID.

session.save_path + /sess_{id}

It then reads the file content as a raw string using the session handler.

ext/session/session.c

Once the raw session string is read, PHP passes it to php_session_decode(). This function doesn’t decode the data, but it delegates the task to the serializer.

ext/session/session.c

After reaching the serializer, PHP calls the configured decode() function to process the raw session string. The decode formats are php which is the default, php_binary, and php_serialize.

php (default PHP session format)

username|s:8:"Abdullah";email|s:12:"me@d3caff.io";

Splits the session string at |, treats the left side as the key, and unserializes the right side using php_var_unserialize().

php_binary

\x08username s:8:"Abdullah"; \x05email s:12:"me@d3caff.io";

Binary version of the default format, the first byte is the key length.

php_serialize

a:2:{s:8:"username";s:8:"Abdullah";s:5:"email";s:12:"me@d3caff.io";}

Uses PHP regular serialize() and unserialize() format, the same mechanism developers use to serialize and unserialize objects.

If decoding fails because of bad structure, PHP calls php_session_cancel_decode() which deletes the session and reinitializes an empty one to avoid loading corrupted data.

ext/session/session.c

PHP splits the raw session into key|value pairs like username|s:8:"Abdullah"; and email|s:12:"me@d3caff.io";, then calls php_var_unserialize() to rebuild each value as a PHP variable and store it in $_SESSION via php_set_session_var(). And finally, the best programmer in the world would be able to access:

echo $_SESSION["username"]; // Abdullah
echo $_SESSION["email"]; // me@d3caff.io

There is a small point I need to mention: what if the value isn’t a string or an integer, but an object? And what if that object belongs to a class that has __wakeup() or __unserialize() magic methods? Will they be triggered? And where?

While the terms insecure unserialization, insecure deserialization, and object injection often refer to the same idea, we'll stick with unserialization here, since that’s the function name PHP actually uses.

ext/standard/var_unserializer.re

Let’s take a look at these two flags and see where they are used.

ext/standard/var_unserializer.re

We will find that it is used in object_common, and object_common is called in php_var_unserialize_internal. So, they get flagged here, and the magic method trigger occurs in another place.

ext/standard/var_unserializer.re

PHP marks the object during unserialization, but the actual invocation happens in var_destroy() during the cleanup phase. You can see the two call places: line 49 uses zend_call_function to invoke the delayed __wakeup(), and line 66 uses zend_call_known_instance_method_with_1_params to invoke __unserialize(). So the object is only tagged earlier, and the real execution happens at those two lines in var_destroy().

GOAT

The Moment the Harmless File Stops Being Harmless

In this example, we’ll try to achieve RCE through session unserialization.

index.php

First, the code defines a class named CatchMeIfYouCan our unserialization gadget, and a handle_file_upload($_FILES) function. That function defines the target path, calls urldecode() on the filename (some browsers url-encode filenames), and then uses an if statement that requires two checks to pass before an upload is allowed, it verifies that pathinfo($filename, PATHINFO_EXTENSION) is not empty, and the file extension equals "png".

And here is where the flow happens. If we provide a filename with no extension at all, pathinfo($filename, PATHINFO_EXTENSION) it returns an empty value. That causes the first check to fail, which means we never reach the die()function. Instead of blocking the upload, the code moves forward and uploads the file.

We still can’t upload a .php or .htaccess file, but because the code calls urldecode() and never sanitizes the filename afterward, an attacker can perform a path traversal using a url-encoded filename by encoding ../ to %2e%2e%2f. Most people would stop here and report this as a simple path traversal issue. But this scenario is a perfect example to show our attack vector.

RCE Time

What we want to do now is upload a file named sess_l33t into the sessions directory, in this case /var/lib/php/sessions/. This file will contain a malicious object like the one below:

exploit|O:15:"CatchMeIfYouCan":1:{s:3:"cmd";s:20:"touch /tmp/Flint.txt";}

Once this object is unserialized, it will trigger the __wakeup() magic method in the way we discussed earlier, and creates an empty file named Flint.txt in the /tmp/ directory.

So to upload a file in/var/lib/php/sessions/ named sess_l33t, we’ll craft the filename using a path traversal payload like this:

%2e%2e%2f%2e%2e%2f%2e%2e%2fvar%2flib%2fphp%2fsessions%2fsess_l33t

And its content will be the malicious object that we wrote before.

Once the file is in place, we trigger the unserialization process by sending a GET request to the same page, this time including the header:

cookie: PHPSESSID=l33t

Now the server will load the session file at /var/lib/php/sessions/sess_l33t and attempt to unserialize it to populate$_SESSION. If the object structure is valid, the unserialization process will invoke __wakeup() magic method in var_destroy, triggering the payload.

Demo:

References

https://bugs.php.net/bug.php?id=39863

https://www.php.net/manual/en/session.configuration.php#ini.session.save-handler

https://github.com/php/php-src

Last updated