fix(command): restrict allowed_classes in CommandJob unserialize() to prevent RCE#41582
fix(command): restrict allowed_classes in CommandJob unserialize() to prevent RCE#41582DeepDiver1975 wants to merge 2 commits into
Conversation
… prevent RCE CommandJob::run() called unserialize() without the allowed_classes option, enabling PHP Object Injection via POP gadget chains (Guzzle, Symfony, Doctrine) if an attacker can write to the oc_jobs table. Replace bare unserialize() with a two-pass strategy: Pass 1 uses allowed_classes=false to safely extract the stored class name without instantiating anything. After verifying the class implements ICommand, Pass 2 permits only that specific class, blocking all gadget chains. Signed-off-by: Thomas Müller <thomas.mueller@owncloud.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
|
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. |
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
DeepDiver1975
left a comment
There was a problem hiding this comment.
Code Review — fix(command): restrict allowed_classes in CommandJob unserialize()
Overview: Implements a two-pass deserialization strategy in CommandJob::run() to prevent PHP Object Injection. First pass uses allowed_classes: false to safely extract the stored class name without triggering any magic methods, then validates it implements ICommand before a second pass with only that class permitted.
Correctness
The two-pass approach is the correct pattern for this scenario (unlike ClosureJob where a fixed allowlist works, here the class name is dynamic). Key points:
- First pass with
allowed_classes: falseproduces__PHP_Incomplete_Class— no__wakeup()or__destruct()fires. ✅ - Class name extraction via
((array) $incomplete)['__PHP_Incomplete_Class_Name']is the standard PHP idiom for this. ✅ is_a($className, ICommand::class, true)— the third argumenttrueallows checking without instantiating, safe for not-yet-loaded classes. ✅- Second pass restricts to exactly
[$className]— a concrete ICommand subclass — so no other classes in the gadget chain can be deserialized. ✅
One subtle concern: class_exists() with autoloading
\class_exists($className) will trigger the autoloader for the named class. An attacker-supplied class name that doesn't exist will invoke autoloading for that name. On a standard Composer autoloader this is harmless (file not found → false). However, if a class with a malicious name existed on the filesystem and matched the autoload map, it would be loaded. This is a theoretical concern only in unusual configurations — in practice, the is_a(..., ICommand::class, true) guard immediately follows and would reject anything that doesn't implement ICommand. Not blocking.
Tests
CommandJobTest is thorough:
testRunExecutesLegitimateICommand— happy path works ✅testMagicMethodsNotTriggeredOnUntrustedPayload— key security assertion:__wakeup()not called,InvalidArgumentExceptionthrown ✅testRunRejectsNonObjectPayload— scalar input rejected ✅
The TestableCommandJob subclass exposing run() as runPublic() is the right pattern for testing protected methods without reflection.
Summary
| Aspect | Assessment |
|---|---|
| Security fix | ✅ PHP Object Injection prevented via two-pass strategy |
| Class validation | ✅ Interface check before any instantiation |
| Magic method protection | ✅ First-pass uses allowed_classes=false |
| Tests | ✅ All three key cases covered |
Verdict: Ready to merge.
Summary
CommandJob::run()called\unserialize($data)withoutallowed_classes, allowing PHP Object Injection if an attacker writes a crafted payload tooc_jobs.argument(e.g. via SQL injection or shared-hosting DB access)__wakeup()/__destruct()gadget chains from Guzzle, Symfony, and Doctrine could achieve RCE as the web-server userSecurity Impact
High (defense-in-depth) — exploitation requires prior DB write access; escalates that to full RCE
Test plan
ICommandobjects still execute correctlymake test TEST_PHP_SUITE=lib/Command🤖 Generated with Claude Code