Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix insecure deserialization in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Do not rebuild objects from the token: base64-decode it and json_decode the bytes, then copy only the fields you expect; unserialize instantiates whatever class the string names and runs its __wakeup. When a typed object is genuinely required, call unserialize with an allowed_classes allowlist. Blocklisting class names, or allowed_classes => true, does not work.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 with PHP unserialize (allowed_classes) and json_decode
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 52 KB
On this page

Steps

  1. Find every unserialize() whose input is request data: a cookie, a token, a body, a header, or a cache or queue payload a client can influence. Laravel signs its own cookies, but your own tokens may not be signed.
  2. Change the wire format to data, not objects: send the profile as JSON and read it with json_decode($raw, true). json_decode never instantiates a class.
  3. Validate the decoded data into a fixed shape: copy only the fields you expect, each checked for its type, and reject anything else.
  4. When you genuinely need a typed object back, call unserialize($raw, ['allowed_classes' => [Type::class]]); a class not on the list becomes an inert __PHP_Incomplete_Class and its __wakeup is not called. Never pass allowed_classes => true on untrusted input.
  5. Do not try to make unserialize safe with a blocklist of class-name substrings; the next gadget is a class you did not list.
  6. Add regression tests that send a gadget whose only effect is a marker (a file written in __wakeup) to every deserialization endpoint and require the marker never appears.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/Session/VulnerableDeserializer.phpPHP
public function restoreProfile(string $token): array
{
    $raw = base64_decode($token, true);
    if ($raw === false) {
        throw new InvalidPayload('invalid token');
    }
    // unserialize builds any class the bytes name and runs its __wakeup.
    $profile = unserialize($raw);

    return is_array($profile) ? $profile : (array) $profile;
}

With IMPL=vulnerable, 2 of 7 tests fail (2 exploit tests).

The fix: JSON into a fixed shape

Fixedapp/Session/FixedDeserializer.phpPHP
public function restoreProfile(string $token): array
{
    $raw = base64_decode($token, true);
    $data = $raw === false ? null : json_decode($raw, true);
    if (! is_array($data)) {
        throw new InvalidPayload('invalid token');
    }
    $profile = [];
    foreach (self::PROFILE_FIELDS as $field) {
        if (! isset($data[$field]) || ! is_string($data[$field])) {
            throw new InvalidPayload("profile.$field must be a string");
        }
        $profile[$field] = $data[$field];
    }

    return $profile;
}

With IMPL=fixed, 7 of 7 tests pass.

The fix: unserialize with an allowed_classes allowlist

Fixedapp/Session/FixedDeserializer.phpPHP
public function loadPreferences(string $document): array
{
    // Only Preferences may be constructed; any other class is refused, not built.
    $prefs = @unserialize($document, ['allowed_classes' => [Preferences::class]]);
    if (! $prefs instanceof Preferences) {
        throw new InvalidPayload('invalid preferences document');
    }

    return $prefs->toArray();
}

With IMPL=fixed, 7 of 7 tests pass.

A fix that looks safe but is not

Wrong fixapp/Session/WrongDeserializer.phpPHP
public function restoreProfile(string $token): array
{
    $raw = base64_decode($token, true);
    if ($raw === false) {
        throw new InvalidPayload('invalid token');
    }
    foreach (self::BLOCKED as $class) {
        if (str_contains($raw, $class)) {
            throw new InvalidPayload('blocked class in token');
        }
    }
    $profile = unserialize($raw, ['allowed_classes' => true]); // every class is still allowed

    return is_array($profile) ? $profile : (array) $profile;
}

With IMPL=wrong, 2 of 7 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Feature/SessionTest.phpPHP
// Exploit: each payload's only job is to fire the harmless marker during deserialization. The
// fix must leave the marker untouched.

public function test_exploit_object_injection_in_the_token_is_not_executed(): void
{
    $response = $this->restore(base64_encode($this->gadgetPayload()));
    $this->assertFileDoesNotExist($this->marker, 'insecure deserialization: the gadget __wakeup ran');
    $response->assertStatus(400);
}

public function test_exploit_object_injection_in_the_preferences_is_not_executed(): void
{
    $response = $this->loadPreferences($this->gadgetPayload());
    $this->assertFileDoesNotExist($this->marker, 'insecure deserialization: the gadget __wakeup ran');
    $response->assertStatus(400);
}

Pitfalls

  • unserialize() instantiates the class the string names and runs its __wakeup and, at the end of the request, its __destruct; a gadget chain reaches a dangerous effect through a class already loaded in the app or a dependency. Scanning the string for bad class names (this card's wrong implementation) misses any class you did not name.
  • allowed_classes => true (the PHP default when the option is omitted) allows every class, so it is not a fix; allowed_classes => false allows none, and an allowlist allows exactly the ones you name.
  • A class on the allowlist still has its __wakeup and __destruct run, so only allowlist classes whose construction is safe; a plain value object such as Preferences is fine.
  • json_decode($raw) returns stdClass by default; pass true for an associative array, and validate the result, because json_decode of a non-object still returns scalars you must check.
  • Laravel's encrypted and signed cookies protect their own payloads, but a token you mint and read yourself is only as safe as how you decode it; sign it and still json_decode rather than unserialize.
  • The same trap is in any unserialize-backed cache, session or queue driver fed untrusted data, and in libraries that call unserialize for you.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs deserialization/php-laravel does this for you, in Docker if you do not have PHP).
  2. The exploit tests send a serialized MarkerGadget to the token and the preferences endpoints; its __wakeup writes a marker file. The fix must leave the marker untouched and refuse the request.
  3. Static analysis finds the obvious sink: Psalm flags request data reaching unserialize; it does not see that the blocklist is not a sanitizer. Each card's scannerRules records what was run and what it found.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix insecure deserialization in PHP and Laravel
ToolRuleWhat it found here
Semgrepphp.lang.security.unserialize-use.unserialize-use (within r/php)5 findings, all php.lang.security.unserialize-use: both unserialize calls in the vulnerable deserializer, both in the wrong one, and the one in the fixed deserializer. The rule is syntactic: it flags every unserialize() call, so it catches the vulnerable and wrong code but also the fixed code's allowed_classes allowlist, and it reports nothing about json_decode. json_decode in the fixed token path is not flagged.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes config (47 rules on 12 files)
Psalmtaint analysis (not executed this round)Psalm taint analysis with the Laravel pluginPsalm's TaintedUnserialize would flag a request value reaching unserialize; it does not distinguish allowed_classes => [Type] (the fixed code) from an unrestricted call, and it does not see that the wrong code's blocklist is not a sanitizer.Checked: Not executed in this round; the other Laravel cards run Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6 under --taint-analysis

CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript. The regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 with PHP unserialize (allowed_classes) and json_decode
laravel/framework13.34.0
phpunit/phpunit13.4.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/php-laravelfolder.

Shell
composer install --no-interaction --no-progress
Shell
# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xml

Without PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'composer install --no-interaction --no-progress'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=fixed \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=vulnerable \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=wrong \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs deserialization/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 52 KB

Verified

Machine
Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
Toolchain
  • php 8.5.11 (CLI)
  • laravel/framework 13.34.0
  • phpunit/phpunit 13.4.0
  • composer 2.10.3
  • semgrep 1.180.0
  • psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
Matrix
fixed: 7 of 7 tests pass; vulnerable: 2 of 7 tests fail (2 exploit tests); wrong: 2 of 7 tests fail (2 exploit tests)
Test results
fixed 7/7 pass; vulnerable 2/7 fail (both exploit tests: the serialized MarkerGadget runs its __wakeup on the token and the preferences endpoints); wrong 2/7 fail (both exploit tests: the class-name blocklist never names MarkerGadget and allowed_classes => true builds it anyway). Each test run in a container with --network=none after composer install.
Dependency freshness
Same lock as the other Laravel cards: laravel/framework 13.34.0 (2026-09-29) with laravel/prompts held at 0.3.24 and phpunit/phpunit 13.4.0 (2026-10-02), because the 2026-10-06 releases were under seven days old and are not security releases. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-502: Deserialization of Untrusted Data
  2. OWASP Top 10:2025 A08 Software or Data Integrity Failures
  3. OWASP PHP Object Injection
  4. PHP manual: unserialize (the allowed_classes option)
  5. PHP manual: json_decode
  6. OWASP Deserialization Cheat Sheet

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.