Schedule deserialization hardening#1344
Conversation
When a scheduled action is read back, its schedule is stored as a native
PHP-serialized blob and passed to unserialize() with no allowed_classes
restriction. An attacker able to influence those stored bytes could make PHP
instantiate an arbitrary class, running its __wakeup()/__destruct() magic
methods (PHP object injection). The existing "is this an ActionScheduler_Schedule?"
check in validate_schedule() runs only AFTER unserialize(), i.e. after the
damage is done.
Move that trust decision to before instantiation using a two-phase
deserialization in the new ActionScheduler_ScheduleDeserializer:
1. Parse the blob with allowed_classes => false. This is inert: every object
becomes a __PHP_Incomplete_Class placeholder, so no constructor, __wakeup(),
__destruct() or autoloading runs.
2. Inspect the class names present. Only if the outermost class implements
ActionScheduler_Schedule, and every nested class is on a small vetted
allow-list (the bundled CronExpression family, filterable, plus any class
implementing the schedule interface), re-run unserialize() restricted to
exactly that verified set.
The allow-list is derived, not declared: any loaded schedule class -- including
third-party ones -- is trusted automatically, so extenders need to do nothing.
This also means no backward-compatibility window: a schedule class that is not
loaded already produced a broken action before this change, so rejecting it here
regresses nothing that previously worked. Legacy blobs carrying the full
CronExpression object graph continue to deserialize unchanged.
Rejections reuse the existing corrupt-blob path (NullSchedule for canceled
actions, ActionScheduler_InvalidActionException otherwise), so no new failure
mode is introduced. An action_scheduler_unexpected_schedule_class hook fires for
observability, and a shadow mode
(action_scheduler_enforce_schedule_allowed_classes filter, default enforce) lets
site owners report-only for a release before enforcing -- without touching any
stored data.
Both the default custom-table store (ActionScheduler_DBStore) and the legacy
post store (ActionScheduler_wpPostStore, which reads the raw serialized meta to
bypass the meta API's implicit unserialize) are covered.
No storage-format change and no migration: existing rows are read exactly as
before, only more safely.
Refs #1318
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two hardening fixes surfaced in code review of the two-phase deserializer: 1. Cyclic/deep object graphs (DoS). The class-name discovery walk recursed through the inert object graph with no cycle or depth guard. A tampered blob can use PHP's r:/R: references to encode a self-referential graph, sending the walk into unbounded recursion and exhausting the stack before the blob was ever rejected. We now track visited objects by spl_object_id (short-circuiting cycles and shared references) and cap recursion depth well above any real schedule (cron's CronExpression graph is the deepest at ~6 levels). A graph we cannot fully walk is treated as untrustworthy and rejected. 2. Log parity on re-hydration. The restricted phase-2 unserialize() was not silenced, whereas the stores historically used @unserialize() for schedule blobs. Restored the @ so corrupt data cannot start emitting warnings/notices into logs or output that it did not before this change. Adds a regression test covering the self-referential-blob case. Refs #1318 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three small correctness/clarity fixes from code review: - Resolve is_enforced() once in handle_rejection() instead of calling it twice. A non-deterministic or expensive action_scheduler_enforce_schedule_allowed_classes filter could otherwise make the value reported to the observability action disagree with the branch actually taken. - Correct get_schedule_from_post_meta()'s documented return type to mixed: when the stored meta is not a serialized object it is returned as-is (a scalar) for validate_schedule() to reject, matching the prior get_post_meta() behaviour. - Annotate the intentional direct, uncached postmeta read with a DirectDatabaseQuery/NoCaching phpcs:ignore, matching this file's convention for its other direct queries and documenting why the meta cache is bypassed. Refs #1318 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
is_allowed_nested_class() called get_allowed_nested_classes() for every nested class, so the action_scheduler_allowed_nested_schedule_classes filter ran once per nested object on every schedule fetch -- around seven times for a cron schedule (CronExpression, its field factory and five field objects). The list is invariant across the loop. Resolve it once before the loop and pass it into is_allowed_nested_class(). The parameter is optional and defaults to resolving the list, so a standalone call is unaffected. Refs #1318 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A blob referencing only Action Scheduler's own schedule classes and vetted support classes -- the common case -- is now hydrated in one restricted unserialize() instead of the inert-then-rehydrate two-phase walk. The two-phase path remains for third-party, filtered, or tampered blobs. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds DateTime, DateTimeImmutable, DateTimeZone and DateInterval to the default nested allow-list so third-party schedules that store one as a property keep deserializing. They define no exploitable magic-method sink. Also switches both allow-lists to ::class references. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shadow (report-only) mode routed every rejection through an unrestricted unserialize(), so it re-instantiated a bare top-level gadget -- the exact issue #1318 PoC. Rejections are now split: a top-level non-schedule or an unwalkable graph is always blocked, and only an unexpected nested support class under an otherwise-valid schedule may pass through in shadow mode. The observability action now reports the outcome actually taken. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the inert-then-rehydrate two-phase parse with a single restricted unserialize(): classes outside the trusted set become __PHP_Incomplete_Class placeholders that double as the list of unexpected classes to vet, so one walk of the graph yields both the hydrated object and everything still needing a trust decision. The separate all-inert parse and its walk are gone. A static unserialize() facade drives a short-lived instance -- the constructor takes the allow-lists, __invoke takes the blob, and per-blob walk state is reset on each invocation. The nested allow-list is resolved through its filter once per call (never process-cached) so a late- or context-registered filter is honoured. The top-level schedule gate runs before the walk, so a bare top-level gadget is rejected without walking a hostile graph. Marked @internal. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds coverage for the paths the hardening introduced: shadow mode still blocks a top-level gadget but lets an unexpected nested class through; the observability action fires on rejection; the nested allow-list filter is honoured per call; a third-party schedule nesting built-in date/time objects round-trips; an overly deep graph is rejected; a reused deserializer instance does not leak state; the injected nested allow-list is authoritative; and a non-serialized post-store meta value is treated as an invalid action. Adds two third-party schedule fixtures. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
as_get_datetime_object() returns an ActionScheduler_DateTime (a DateTime subclass), so a third-party schedule that stores that idiomatic helper's result as a property nests one rather than a plain DateTime, and was rejected because only DateTime itself was on the default nested allow-list. It is a trivial subclass with no added magic methods, so it is safe to trust. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the one attack shape the existing nested-gadget test did not: the outer object is a valid third-party schedule (a placeholder in the safe parse, not a core schedule), so the walk must recurse into that placeholder to find the nested gadget. Asserts the blob is rejected and the gadget's __wakeup() never runs. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A tampered blob can keep a trusted schedule class as the outer object but replace a scalar property (e.g. scheduled_timestamp) with an object; the schedule's __wakeup() then feeds it to a DateTime constructor and throws a TypeError. That is an Error, so @ does not suppress it and the stores' catch (Exception) guards do not catch it -- one poisoned row could fatal a read (500 the admin list, stall a queue run). Wrap the two-pass deserialization in a catch (Throwable) that returns false, routing such failures into the existing corrupt-data path (null/cancelled). Not a regression (trunk ran the same __wakeup on the same bytes), but squarely within this change's goal of handling corrupt schedule data gracefully. Refs #1318 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kalessil
left a comment
There was a problem hiding this comment.
LGTM overall. Leaving some feedback in the comments below.
| * @param string[] $default List of allowed nested class names. | ||
| */ | ||
| $allowed_nested = (array) apply_filters( 'action_scheduler_allowed_nested_schedule_classes', self::DEFAULT_NESTED_CLASSES ); | ||
| $deserializer = new self( self::KNOWN_SCHEDULE_CLASSES, $allowed_nested ); |
There was a problem hiding this comment.
There is potential to use the singleton pattern here - better for performance.
There was a problem hiding this comment.
Here also replying to this earlier comment, I'm a little reluctant to implement these ideas (but may be overthinking it, so may as well talk it out) because of scenarios like this:
foreach ( $blog_ids as $blog_id ) {
switch_to_blog( $blog_id );
if ( ! as_next_scheduled_action( 'custom_job' ) ) {
warn_job_not_scheduled();
}
}Obviously the above snippet is a bit contrived, but I just mean to illustrate that it will (potentially) lead to schedule deserialization taking place multiple times within the same request, but across different and changing contexts (for which the range of acceptable/unacceptable classes may also change).
For example, instances of Plugin_A_Schedule_Data may be allowed on the first iteration, when we are inside Blog 1, but not on the next when we are inside Blog 2 and Plugin A is not even active. And/or, there may be plugin classes that are acceptable within Blog 2 but which were unknown when this first runs, within Blog 1.
This probably isn't very common, and I can think of supporting mechanics to work around it, but am basically feeling a little wary.
There was a problem hiding this comment.
Given your prior work on A-S, I'd trust your gut feeling.
"Just" drop in a comment there clarifying that the new instance is by design to accommodate context switching (as multisite and more) - so future people have this context and don't challenge this decision because of a lack of context.
There was a problem hiding this comment.
That's a good call. There is an existing comment alluding to this, specifically in the sense of multisite network context changes (in the docblock for the action_scheduler_allowed_nested_schedule_classes hook), but I think it can be promoted to class level and made clearer.
I'd trust your gut feeling.
I'm definitely up for this work being challenged. It's a tricky line we're having to follow between full-on correctness and keeping existing sites working.
There was a problem hiding this comment.
Weighing out complexity vs. maintainability aspects, $deserializer = new self( self::KNOWN_SCHEDULE_CLASSES, $allowed_nested ); is a clear winner.
What I read from this thread is that my proposal is actually low ROI: micro-optimization with potential risk to multi-site (and likely more hidden scenarios). Let's ignore this one.
| * @return bool | ||
| */ | ||
| private function is_schedule_implementation( string $class_name ): bool { | ||
| if ( '' === $class_name || ! class_exists( $class_name ) ) { |
There was a problem hiding this comment.
Nit: I'd add a second parameter as false:
- Line ~328 — is_schedule_implementation(): class_exists( $class_name ) - Line ~332 — is_schedule_implementation(): class_implements( $class_name )
- Line ~359 — is_allowed_nested_class(): class_exists( $class_name )
- Line ~360 — is_allowed_nested_class(): class_implements( $class_name )
Effectively, it's sealing the auto-loading attack channel once and for all (I know we have a prior array check, but extra hardening, if feasible, will not hurt).
This requires constructor patching to keep the hierarchy investigation working (it'll load the class definition):
$this->allowed_nested_classes = array_filter( $allowed_nested_classes, 'class_exists' );One thing to investigate, though, is when a custom class is not directly extending A-S classes (adds multiple inheritance layers).
There was a problem hiding this comment.
Re multiple inheritance layers, I think this is fine:
class Baz implements ActionScheduler_Schedule {
public function next( ?DateTime $after = null ) {}
public function is_recurring() {}
}
class Bar extends Baz {}
class Foo extends Bar {}
var_dump( class_implements( Foo::class ) );
# array(1) {
# ["ActionScheduler_Schedule"]=>
# string(24) "ActionScheduler_Schedule"
# }(...but let me know if I've misunderstood.)
There was a problem hiding this comment.
I'd add a second parameter as
false:
The thing is that the current mechanism relies on this being true.
This does indeed mean there is potential for autoloader side-effects (ie, when a file contains both a class definition and other top-level code, then the top-level code will be executed), but that's one of the trade-offs we're reckoning with here.
If false, then we will fail to load legitimate (third party) schedule implementations that rely on an autoloader for discovery.
I'm not closed to the idea, but it increases the risk of breakages (in the sense that up-until-now perfectly valid scheduled actions would risk being cancelled) in production.
There was a problem hiding this comment.
I see, let's keep as it is. We can take this risk if security reports point to this attack vector again after this solution is merged. We'll have some options then if this scenario materializes.
|
✍🏼 This was discussed elsewhere, but it's possibly worth dropping a note in here, too, to make some of the background thinking a little clearer. An obvious alternative or supplement to this approach would be to adopt JSON-based (de)serialization of scheduling information. The problem with that is that, unless we also continue to write serialized blobs in their current form and current location — and we could, but it raises bigger performance questions that the current approach alone — then a version rollback would potentially render a large number of scheduled actions invalid (because older versions could not read that information). That's a particular concern for this library, because multiple plugins may ship different (and sometimes quite old) versions of Action Scheduler, so a rollback can happen indirectly simply by a plugin that is no longer required being deactivated. |
Both of the default action stores persist schedule information as serialized PHP blobs. However, there are currently no protections during the deserialization process to prevent PHP object injection attacks.
The core concept here (originally introduced by #1341, thank you @vladolaru) is to handle deserialization in one or more passes. High-level (slightly simplified) summary of the algorithm in this PR:
allowed_classeslist covering the library's own schedule classes and the small set of support classes they legitimately nest (theCronExpressionfamily, and the built-in date/time value objects). Normal data hydrates fully in this pass — if the outermost object is a schedule and nothing decoded to__PHP_Incomplete_Class, we return it with no further work.__PHP_Incomplete_Classis then vetted: acceptable if it's on the (filterable) support allow-list or implementsActionScheduler_Schedule. If all pass, weunserialize()once more restricted to the expanded set and return the result; if any fails, we return false (which the caller treats as corrupt).The idea is to minimize any additional overhead when we are dealing with perfectly normal and expected schedule data, while still filtering out potentially dangerous class references.
Testing
The test suite should provide reasonable coverage, but we can also perform some manual testing and exploration. For instance, you can try creating conventional scheduled actions of various sorts via WP CLI:
Then, visit Tools ‣ Scheduled Actions. You should see the created actions in the queue of pending actions and, if you run them manually, they should successfully complete or fail as expected.
Tip
When is it acceptable for a test action like
testcronaction, created above, to fail? When we haven't registered a callback. You can workaround this by adding something likeadd_action( 'testcronaction', fn () => 0 );to a mu-plugin, or just look at the failed action log and verify it says something like, 'Scheduled action for testcronaction will not be executed as no callbacks are registered.' This is fine and expected.Second, we can simulate a perfectly valid custom schedule that happens to contain an instance of an unknown class:
Run the above with
wp eval-file /path/to/script.php, then head over to Tools ‣ Scheduled Actions. Depending on how quickly you get there it will probably be sitting in either the past-due queue or else will have moved to cancelled (which is how these particularl cases are handled).Feel free to explore other permutations 🙂
Notes/questions
Problematic_Custom_Scheduleillustrates how some third party or custom schedules, that until now were considered valid, will be handled. This is potentially problematic (by default, cancelled actions cannot be re-run and will be deleted after one month); so we need to decide if that risk is acceptable.suspended(right now I'm less interested in what we name it and more the concept as a whole) which would then give admins a chance to review and force run the actions. An existing status likefailedmight also work for this, but we would need to add the ability to forcibly re-run.action_scheduler_allowed_nested_schedule_classesfilter to make it easier for custom schedule implementations to expand the allow-list.action_scheduler_enforce_schedule_allowed_classesessentially allows for these new mechanics to be turned off (while still making it possible to observe any problems). Of course, this also requires the ability to add custom code. We again need to ask if that is acceptable by itself, or should be supported with some sort of settings UI.trunk, but we could also separate it out.