# 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.png`could 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](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2FBMz6olxQhCoxP8k2NGIb%2FPHP%20Session%20Load%20%E2%80%94%20High%E2%80%91Level%20\(6\).png?alt=media\&token=e176fb60-c24e-4dfa-88de-003eff419bf9)

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.

```jsx
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.

```jsx
session.save_path + /sess_{id}
```

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

<figure><img src="https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2FL4pX6CATuIhRWVfthWz6%2F-%20(7).png?alt=media&#x26;token=9029ad60-b9ef-482f-88f4-5d5b391f19ba" alt=""><figcaption><p>ext/session/session.c</p></figcaption></figure>

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](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2F62c8HLWCyYzlyClXgyjK%2F-.png?alt=media\&token=09454dad-afe8-420d-96bf-34543e604b61)

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)

```jsx
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

```jsx
\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

```jsx
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](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2F0X4JtlsqRpILDx6iO7Og%2F-_\(2\).png?alt=media\&token=f59ac3d7-a53c-4717-a94c-8664470e1bd8)

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:

```jsx
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](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2FagKaWjgYZQgq9nTuY6KC%2F-_\(3\).png?alt=media\&token=f5e9fd47-7bb3-4327-8648-97d65e95aaae)

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

![ext/standard/var\_unserializer.re](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2FVczm7rXJKfVWpIAVILM3%2F-_\(4\).png?alt=media\&token=3465ef78-b5f9-4f3f-b3e2-f073bc63da4a)

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](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2FlmUbb65xoPEBpEUIGYj0%2F-_\(5\).png?alt=media\&token=544903d2-586d-4957-92a8-8726f9597947)

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](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2FSYkhyl96TCUevpWpKO0C%2Fprogrammerhumor-io-php-memes-backend-memes-41fb615c670ab5f.jpeg?alt=media\&token=a70a895d-0692-45d9-ac93-c8d217f125d5)

## **The Moment the Harmless File Stops Being Harmless**

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

![index.php](https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2Fa4YgU8vnIfeFlpMwjXU9%2F-_\(2\)%201.png?alt=media\&token=05d2df4e-8ef3-4311-bd35-1281496c0e8c)

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:

```jsx
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:

```java
%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:

```makefile
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:

<figure><img src="https://106893772-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS1OLpvmEOkDKYmBx5NsS%2Fuploads%2FnDcWkAF3eTlTMeMTpZey%2Fdemo%20(5).gif?alt=media&#x26;token=98f9502b-ae5d-4fea-b48c-a7bfa5874e42" alt=""><figcaption></figcaption></figure>

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


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://d3caff.gitbook.io/me/blog-posts/the-trojan-horse-you-didnt-check-php-session-unserialization-as-an-attack-vector.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
