Search by

jay-tank / unserializeslip

jaytank_dev

Static analyzer that flags PHP Object Injection: a call to the bare global unserialize(...) on a value traceable (directly, or via a same-function local-variable assignment) to $_GET/$_POST/$_REQUEST/$_COOKIE/$_SERVER['HTTP_*']/Laravel request()->input()/$request->get()/$request->cookie(), with no [

Package info

github.com/jay-tank/unserializeslip

pkg:composer/jay-tank/unserializeslip

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-10-23 03:19 UTC

This package is not auto-updated.

Last update: 2026-10-24 11:19:29 UTC


README

Static analyzer that flags PHP's version of the same deserialization-RCE bug class this portfolio already covers for Java (unsafe YAML Constructor) and .NET (Newtonsoft TypeNameHandling): a call to the bare global unserialize(...) on a value traceable -- directly, or via a same-function local-variable assignment -- to request/user-controlled input ($_GET, $_POST, $_REQUEST, $_COOKIE, $_SERVER['HTTP_*'], Laravel's request()->input(...)/$request->get(...)/ $request->cookie(...)), with no ['allowed_classes' => false] (or an explicit class allowlist) passed as the second argument.

This is PHP Object Injection: unserialize() on attacker-controlled data lets the attacker construct an instance of any class already loaded (or autoloadable) in the application, with attacker-chosen property values. If any such class has a "magic method" (__wakeup(), __destruct(), __toString(), ...) with a dangerous side effect, the attacker can trigger it purely by crafting a malicious serialized string -- often with no other vulnerability needed. PHP's own unserialize() manual page explicitly recommends allowed_classes as the primary defense, and recommends json_decode() instead whenever the data doesn't actually need to carry PHP object state. See DETAILS.md for the full mechanism, the detection heuristic, and honest limitations.

Quick start

composer require jay-tank/unserializeslip --dev
vendor/bin/unserializeslip check app
$ vendor/bin/unserializeslip check demo/Bad.php
demo/Bad.php:14: USS001 unserialize(...) is called on a value traceable to user/request-controlled input without ['allowed_classes' => false] (or an explicit class allowlist) as the second argument -- this is PHP Object Injection: an attacker can construct an instance of ANY class already loaded by the application with attacker-chosen property values, and if any such class has a magic method (__wakeup(), __destruct(), __toString(), ...) with a dangerous side effect, a crafted serialized string alone can trigger it. Pass ['allowed_classes' => false] (or an explicit array of allowed class names) as the second argument, or use json_decode() instead if the data does not need to carry PHP object state.

unserializeslip: 1 finding(s).
$ vendor/bin/unserializeslip check demo/Good.php
unserializeslip: 0 finding(s).

See docs/USAGE.md for the CLI, library API, and full rule reference.

The only correct patterns

// Bad -- any class already loaded can be instantiated with attacker-
// chosen properties, and a dangerous magic method can fire on it.
$state = unserialize($_POST['session_blob']);

// Bad -- 'allowed_classes' => true explicitly allows every class,
// which defeats the entire protection.
$state = unserialize($_POST['session_blob'], ['allowed_classes' => true]);

// Good -- no class can ever be instantiated; scalars/arrays still decode.
$state = unserialize($_POST['session_blob'], ['allowed_classes' => false]);

// Good -- only the named, deliberately reviewed classes can be instantiated.
$state = unserialize($_POST['session_blob'], ['allowed_classes' => ['App\Dto\ImportRecord']]);

// Best -- if the data doesn't need to carry PHP object state at all,
// PHP's own manual recommends this instead of unserialize() entirely.
$state = json_decode($_POST['session_blob'], true);

Prior art, honestly

Checked (2026-10) before building this. Real, overlapping prior art exists, but each has a real gap this package fills:

  • Exakat's Security/UnserializeSecondArg rule is genuine, Critical- severity prior art for the core idea ("unserialize() calls missing the second argument are dangerous") -- but it's shipped only in Exakat's Enterprise Edition/Cloud product (not the free CLI), and from its own documentation it flags any unserialize() call missing the argument, with no taint/data-flow requirement -- it doesn't distinguish a call on genuinely request-controlled input from one on the application's own internal cache file.
  • Psalm's TaintedUnserialize is real, closer prior art -- it uses Psalm's full taint-analysis engine to trace untrusted input all the way to an unserialize() sink. But it only runs under Psalm's taint-analysis mode (--taint-analysis, not Psalm's default static analysis), requires Psalm's broader type/taint infrastructure to be set up across the whole codebase, and (per Psalm's own docs) still flags tainted input reaching unserialize() regardless of allowed_classes being set at all -- it does not specifically verify whether allowed_classes is present, false, an allowlist, or true the way unserializeslip does.
  • Datadog's (commercial) static analysis product has a php-security/avoid-unserialize rule with the same core idea and a compliant example matching allowed_classes -- real prior art, but a paid, hosted product rather than a standalone, zero-config Composer CLI.
  • PHPStan's default rule set has nothing here -- no built-in rule about unserialize() or allowed_classes was found.

What this means honestly: the core idea isn't new -- Exakat, Psalm, and Datadog all have real, overlapping coverage. unserializeslip's specific niche is combining (1) a standalone, zero-config, free Composer CLI, (2) same-function taint tracing from the concrete request-input sources used in this portfolio's other packages (not full project-wide taint analysis), and (3) an exact, narrow check of allowed_classes's value (false/allowlist = safe, missing/true/non-literal = unsafe) rather than only its presence. Anyone already running Exakat Enterprise, Psalm with taint-analysis enabled, or Datadog's static analysis should treat this as a narrow, complementary check, not a replacement.

What it is not

Not a secrets scanner, not a full data-flow/taint analysis engine (like Psalm's taint mode), and not semantic/type resolution. It's a syntax-only AST pass with same-function-only variable tracing -- see DETAILS.md before wiring this into a blocking CI gate.

License

MIT (c) 2026 Jay Tank