Skip to content

fix(command): restrict allowed_classes in ClosureJob unserialize() to prevent RCE#41583

Open
DeepDiver1975 wants to merge 3 commits into
masterfrom
security/fix-closurejob-unserialize
Open

fix(command): restrict allowed_classes in ClosureJob unserialize() to prevent RCE#41583
DeepDiver1975 wants to merge 3 commits into
masterfrom
security/fix-closurejob-unserialize

Conversation

@DeepDiver1975

Copy link
Copy Markdown
Member

Summary

  • ClosureJob::run() called bare unserialize() on DB-sourced data; same gadget-chain RCE risk as CommandJob
  • The post-deserialization method_exists check doesn't help — __wakeup()/__destruct() already fired
  • Fix adds allowed_classes restricted to the 6 laravel/serializable-closure classes that legitimately appear in serialized closure payloads

Security Impact

High (defense-in-depth) — requires prior DB write access to exploit

Test plan

  • Legitimate closure still executes correctly after fix
  • Malicious gadget payload's __wakeup() is NOT triggered (test would fail without fix)
  • Run make test TEST_PHP_SUITE=lib/Command

🤖 Generated with Claude Code

@update-docs

update-docs Bot commented Jun 5, 2026

Copy link
Copy Markdown

Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes.

@DeepDiver1975 DeepDiver1975 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — fix(command): restrict allowed_classes in ClosureJob unserialize()

Overview: Adds allowed_classes restriction to unserialize() in ClosureJob::run(), limiting deserialization to the SerializableClosure class family. Prevents PHP Object Injection via gadget chains from attacker-controlled data in oc_jobs.

Correctness

The ALLOWED_CLASSES list is correct for SerializableClosure (the Laravel package used here). It includes:

  • SerializableClosure and UnsignedSerializableClosure — the top-level wrappers
  • Native and Signed — the underlying serializer implementations
  • ClosureScope and SelfReference — support classes required for reconstruction

All six are necessary for the getClosure() call to succeed on a legitimately serialized closure. ✅

Test — excellent

The ClosureJobTest is particularly well-designed:

  • testLegitimateClosureIsExecuted — confirms the happy path still works end-to-end using a real SerializableClosure and file-based flag (avoids $this binding issues in serialized closures)
  • testMaliciousPayloadDoesNotTriggerGadget — the key security regression test: creates a real MaliciousGadget with __wakeup() and __destruct(), serializes it, passes it to run(), and asserts the gadget was never triggered. This directly proves the fix works.

The try/catch catching both \InvalidArgumentException and \Error covers both PHP 8 (method_exists on __PHP_Incomplete_Class throws Error) and PHP 7 behaviour. ✅

Summary

Aspect Assessment
Security fix ✅ PHP Object Injection prevented
Allowed list ✅ Minimal and complete for SerializableClosure
Tests ✅ Both happy path and gadget-chain regression covered

Verdict: Ready to merge.

… prevent RCE

ClosureJob::run() called unserialize() without allowed_classes restriction
on data from the oc_jobs.argument database column. An attacker with DB
write access could inject a gadget chain payload. The post-deserialization
method_exists check provides no protection since __wakeup()/__destruct()
fire during unserialize() before any check is reached.

Add an explicit allowed_classes list covering the six Laravel
SerializableClosure classes that legitimately appear in a serialized
closure payload. All other classes are rejected before instantiation.

Signed-off-by: Thomas Müller <thomas.mueller@owncloud.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…on of test class

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
@DeepDiver1975 DeepDiver1975 force-pushed the security/fix-closurejob-unserialize branch from b556b77 to 298bc7d Compare June 12, 2026 09:37

@DeepDiver1975 DeepDiver1975 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Overview: Security fix — restricts unserialize() in ClosureJob::run() to an explicit allowlist of SerializableClosure classes, preventing PHP Object Injection via arbitrary gadget chains.

The vulnerability: The original code called bare unserialize($serializedCallable) on data read from the oc_jobs database table. An attacker with database write access could inject a crafted PHP object payload to trigger __wakeup() or __destruct() methods on any class in the autoloader, potentially achieving RCE via gadget chains from bundled libraries (e.g. Symfony, Laravel).

The fix: ALLOWED_CLASSES constant enumerates exactly the six classes needed for SerializableClosure to reconstruct itself:

  • SerializableClosure
  • UnsignedSerializableClosure
  • Laravel\SerializableClosure\Serializers\Native
  • Laravel\SerializableClosure\Serializers\Signed
  • Laravel\SerializableClosure\Support\ClosureScope
  • Laravel\SerializableClosure\Support\SelfReference

Any object not in this list becomes __PHP_Incomplete_Class during deserialization — its __wakeup() and __destruct() are never called.

Test coverage in ClosureJobTest:

  • testLegitimateClosureIsExecuted — verifies the happy path still works after the restriction
  • testMaliciousPayloadDoesNotTriggerGadget — direct security regression test: serializes a MaliciousGadget object (with __wakeup() and __destruct() that set a static flag), runs it through the restricted unserialize, and asserts the flag was never set. This directly proves the gadget is blocked.

The use of a temp file for the legitimate closure test avoids $this binding issues across the serialize/unserialize boundary — good approach.

One observation: CommandJob (PR #41582, still at known SHA) applies the same fix to a parallel class. It's worth confirming the allowlist there is identical once that PR is also updated. The current #41582 SHA in state is unchanged, so it will be reviewed when it next updates.

Changelog: changelog/unreleased/41583 correctly categorises this as Security and accurately describes the RCE risk vector.

No concerns. Well-executed fix with direct security regression tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant