In Depth: From Serialised String to RCE!

[In Depth #41] unserialize() looks harmless - it just rebuilds your data - but feed it the wrong string and it'll rebuild an attacker's object, quietly turning Laravel's own code into remote code execution. Let's pull a real RCE apart. 😈

Share
In Depth: From Serialised String to RCE!

In my last In Depth article, we looked at a vulnerability that involved abusing missing CSRF protection within an unprotected session context to gain access to an API, and I finished by saying "issues like this pop up in our code all the time" with a reference to PHP Deserialisation attacks. Which felt like the perfect introduction to focusing on them today.

I've covered them before, the first as a generic warning "this is bad":

Security Tip: Encoding/Serialising Data
[Tip#36] Encoding/serialising data can be risky if you’re not using the correct functions.

And followed up with a "here's how you do it safely":

Security Tip: Can You Safely Unserialise Classes?
[Tip #95] While you really shouldn’t unserialise anything you get from a user, occasionally you have no choice... so how do you do it safely?

But now I think it's time to explain exactly how they work and why you need to be so careful, plus we'll look at some real examples within the Laravel ecosystem (including my favourite demo attack).

How Does serialize()/unserialize() Work?

serialize()

The PHP docs describe serialize() as:

function serialize(mixed $value): string
Generates a storable representation of a value.
This is useful for storing or passing PHP values around without losing their type and structure.
To make the serialized string into a PHP value again, use unserialize().

Let's take a look at some basic examples:

> serialize(null);
= N;

> serialize(true);
= b:1;

> serialize(123);
= i:123;

> serialize("hello world");
= s:11:"hello world";

> serialize([1, 2, 3]);
= a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}

> serialize(["one", "two", "three"]);
= a:3:{i:0;s:3:"one";i:1;s:3:"two";i:2;s:5:"three";}

> serialize(["key" => "one", "foo" => "two", "bar" => "three"]);
= a:3:{s:3:"key";s:3:"one";s:3:"foo";s:3:"two";s:3:"bar";s:5:"three";}

The serialised string starts with a character that indicates the type, such as N, b, i, s, and a, and a colon (:) to separate the type prefix from value, with a semi-colon (;) after the value.

In the case of null, there is no value and the entire thing is N;. Booleans and Integers contain a single value after the colon, either 1 or 0 for boolean true and false, and the integer value for the integer.

Strings are a bit more complicated and follow the format: s:<length>:"<value>";, which first defines the string length (in bytes), before presenting the value - which is wrapped in quotes. This provides a rather elegant way to prevent injection attacks by avoiding the need to escape the value. For example, hello"world simply becomes s:11:"hello"world";, without the need to escape or encode the " in the middle. Compare this to JSON or XML, where you need to escape or encode quotes and/or brackets to avoid breaking the structure.

Arrays add some more complexity, and follow the format: a:<length>:{<key1>;<value1>;<key2>;<value2>;...} - with the semi-colon (;) used after each key and value . Since PHP's arrays can support anything placed inside of them, this format is recursive, and the array length at the start prevents injection or corruption. It's verbose but solid.

Now, if that was all serialize() did, it'd be a brilliant format to use. However, it also supports objects, and this is where things get dangerous...

Let's take a look at a very simple example:

> serialize((object) ["key" => "value"]);
= O:8:"stdClass":1:{s:3:"key";s:5:"value";}

Objects start out with the O prefix, followed by the length of the class name and the class name itself: O:8:"stdClass": - which identifies which class has been serialised. Following that is the same format as arrays, with the property count and key=value pairs inside the brackets: 1:{s:3:"key";s:5:"value";}.

Objects can also be nested:

> serialize((object) [(object) ["key" => "value"]]);
= O:8:"stdClass":1:{s:1:"0";O:8:"stdClass":1:{s:3:"key";s:5:"value";}}

As a more realistic example, here's what I get when I serialise a User in an app I'm working on:

> serialize(User::find(1));

= O:15:"App\Models\User":35:{
    s:13:"\0*\0connection";s:6:"sqlite";
    s:8:"\0*\0table";s:5:"users";
    s:13:"\0*\0primaryKey";s:2:"id";
    s:10:"\0*\0keyType";s:3:"int";
    s:12:"incrementing";b:1;
    s:7:"\0*\0with";a:0:{}
    s:12:"\0*\0withCount";a:0:{}
    s:19:"preventsLazyLoading";b:0;
    s:10:"\0*\0perPage";i:15;
    s:6:"exists";b:1;
    s:18:"wasRecentlyCreated";b:0;
    s:28:"\0*\0escapeWhenCastingToString";b:0;
    s:13:"\0*\0attributes";a:14:{
        s:2:"id";i:1;
        s:4:"name";s:19:"Stephen Rees-Carter";
        s:5:"email";s:27:"stephen@valorinsecurity.com";
        s:17:"email_verified_at";s:19:"2026-03-30 02:40:19";
        s:8:"password";N;
        s:5:"admin";i:1;
        s:14:"remember_token";s:60:"...";
        s:10:"created_at";s:19:"2026-03-30 02:39:02";
        s:10:"updated_at";s:19:"2026-08-03 02:18:15";
        ...
        }
    s:11:"\0*\0original";a:14:{...}
    s:10:"\0*\0changes";a:0:{}
    s:11:"\0*\0previous";a:0:{}
    s:8:"\0*\0casts";a:7:{
        s:5:"admin";s:7:"boolean";
        s:6:"system";s:7:"boolean";
        s:17:"email_verified_at";s:8:"datetime";
        s:22:"scan_notification_mode";s:30:"App\Enums\ScanNotificationMode";
        s:19:"last_digest_sent_at";s:8:"datetime";
        s:14:"personal_notes";s:9:"encrypted";
        s:22:"personal_notes_enabled";s:7:"boolean";
    }
    s:17:"\0*\0classCastCache";a:0:{}
    s:21:"\0*\0attributeCastCache";a:0:{}
    s:13:"\0*\0dateFormat";N;
    s:10:"\0*\0appends";a:0:{}
    s:19:"\0*\0dispatchesEvents";a:0:{}
    s:14:"\0*\0observables";a:0:{}
    s:12:"\0*\0relations";a:0:{}
    s:10:"\0*\0touches";a:0:{}
    s:27:"\0*\0relationAutoloadCallback";N;
    s:26:"\0*\0relationAutoloadContext";N;
    s:10:"timestamps";b:1;
    s:13:"usesUniqueIds";b:0;
    s:9:"\0*\0hidden";a:3:{
        i:0;s:8:"password";i:1;
        s:14:"remember_token";i:2;
        s:14:"personal_notes";
    }
    s:10:"\0*\0visible";a:0:{}
    s:11:"\0*\0fillable";a:3:{
        i:0;s:4:"name";i:1;
        s:5:"email";i:2;
        s:22:"scan_notification_mode";
    }
    s:10:"\0*\0guarded";a:1:{i:0;s:1:"*";}
    s:19:"\0*\0authPasswordName";s:8:"password";
    s:20:"\0*\0rememberTokenName";s:14:"remember_token";
}

I've removed the sensitive and repeated data to make it smaller, but I want you to note just how much information is in there. It's not just the public properties, but also protected (the keys prefixed with \0*\0), and private (none in this example, but they have a \0ClassName\0 prefix). Also consider, this is now a string - so any part of it can be modified while it's a string.

πŸ’‘
Note, protected and private properties are prefixed with \0*\0 or \0ClassName\0 in the example, however in the actual serialised string \0 is a null-byte - a special non-typable character that cannot be represented on the page directly.

I will leave you with those thoughts as we look into what unserialize() does.

unserialize()

It should be no surprise what happens with serialized nulls, booleans, strings, integers, and arrays:

> unserialize("N;");
= null

> unserialize("b:1;");
= true

> unserialize("i:123;");
= 123

> unserialize("s:11:\"hello world\";");
= "hello world"

> unserialize("a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}");
= [
    1,
    2,
    3,
  ]

> unserialize("a:3:{i:0;s:3:\"one\";i:1;s:3:\"two\";i:2;s:5:\"three\";}");
= [
    "one",
    "two",
    "three",
  ]

> unserialize("a:3:{s:3:\"key\";s:3:\"one\";s:3:\"foo\";s:3:\"two\";s:3:\"bar\";s:5:\"three\";}");
= [
    "key" => "one",
    "foo" => "two",
    "bar" => "three",
  ]

The only interesting thing I think I can add here is that unserialize() will throw a warning and return false if it is given a string that it cannot unserialise:

> unserialize("i:\"abc\";");
   WARNING  unserialize(): Error at offset 0 of 8 bytes.
= false

> unserialize("s:11:\"hello world!\";");
   WARNING  unserialize(): Error at offset 17 of 20 bytes.
= false

We can follow the logical next step over to our objects, and see them revived:

> unserialize("O:8:\"stdClass\":3:{s:3:\"key\";s:3:\"one\";s:3:\"foo\";s:3:\"two\";s:3:\"bar\";s:5:\"three\";}");
= {#9229
    +"key": "one",
    +"foo": "two",
    +"bar": "three",
  }

> unserialize("O:8:\"stdClass\":1:{s:1:\"0\";O:8:\"stdClass\":1:{s:3:\"key\";s:5:\"value\";}}");
= {#9176
    +"0": {#8508
      +"key": "value",
    },
  }

> $user = serialize(User::find(1));
= "O:15:\"App\\Models\\User\":35:{...}"

> unserialize($user);
= App\Models\User {#9225
    id: 1,
    name: "Stephen Rees-Carter",
    email: "stephen@valorinsecurity.com",
    ...
  }

Again, that's what we'd expect to happen. PHP creates a new instance of the object, and hydrates all of the properties within the serialised string back onto the object.

On face value this sounds amazing for storing data in cookies or cache, encrypting values, transferring data between PHP apps, etc. It's clearly far superior to JSON in a lot of ways.

So what's the catch??? 🀨

Remember how I mentioned above that it's trivial to modify the serialised string? Well, unserialise() doesn't just hydrate properties, it can also trigger some magic methods...