Skip to content

Security: harden serialized data validation in the SQL editor (2.6.8) - #406

Merged
randhirinsta merged 5 commits into
trunkfrom
fix/serialized-data-object-injection
Sep 24, 2026
Merged

randhirinsta merged 5 commits into
trunkfrom
fix/serialized-data-object-injection

Conversation

@agentosiwp

@agentosiwp agentosiwp Bot commented Sep 24, 2026

Copy link
Copy Markdown

Summary

Hardening release for the SQL editor's serialized-data validation check.

Serialized_Data::run() (includes/Extension/SQL/Tests/class-serialized-data.php) confirms that a value being saved from the database editor is well-formed serialized PHP. It needs only the shape of the data, never the objects that data describes — but it called unserialize() without an allowed_classes restriction, so validation constructed whatever classes the value named.

This passes allowed_classes => false, the same restriction recursive_unserialize_replace() already carries since 2.6.7. That earlier sweep missed this call site; this completes it.

Reported responsibly by Raphael P. Cigana via WPScan. Detail is held until a fixed version is published.

Why validation still behaves identically

With allowed_classes => false, an object decodes to __PHP_Incomplete_Class rather than false, so the false === $test_data validity test is unaffected.

Verified by executing the real run() method against WordPress's real is_serialized(), across 18 inputs, A/B against the unpatched 2.6.7 code:

2.6.7 this PR
save/block verdict, all 18 cases — byte-identical
lifecycle hooks invoked on object payloads __wakeup, __destruct (3 cases) none

Cases covered: plain text; serialized string / int / bool / null / array / empty array; b:0;; stdClass; an object of a class that exists; an object of a class that does not; an object nested in an array; an object nested in an object; a PHP 8.1 enum (E: token); C: (Serializable) format; and three malformed payloads (truncated array, wrong string length, wrong element count). The three malformed payloads still correctly block the save; everything else still saves.

The unpatched leg firing __wakeup/__destruct is the positive control — it proves the harness can actually observe instantiation, so "none fired" on the patched leg is a measurement rather than a blind spot.

Also audited

Every other deserialization site in the plugin was reviewed. Findings are on the internal ticket; nothing else required a change in this PR.

Version

2.6.7 → 2.6.8 (plugin header, docs/readme.txt Stable tag, docs/readme.txt + docs/changelog.txt changelogs).

Checks

  • php -l clean on both changed PHP files (mirrors the CI syntax step).
  • composer run check-cs (PHPCS/WPCS) clean on both changed PHP files.
  • docs/readme.txt is CRLF; the edit preserves it, so the diff is 3 added lines rather than a whole-file rewrite.

Notes for the merger

  • Do not tag until the release is agreed. A tag matching [0-9]+.[0-9]+* publishes to WordPress.org via trunk-release-tag.yml. The wp.org release, the public version number and the disclosure timing are Vikas's call — see the ticket.
  • Next step (human): /code-review ultra <this PR#> before merge.

Engineer (agent-os)

`Serialized_Data::run()` validates whether a value being saved from the
database editor is well-formed serialized PHP. It only needs the shape of
the data, never the objects it describes, but it called `unserialize()`
with no `allowed_classes` restriction, so validating a row would construct
whatever classes the value named.

Pass `allowed_classes => false`. Objects then decode to
`__PHP_Incomplete_Class`, which is not `false`, so the existing validity
test is unchanged for legitimate data -- verified across 18 inputs (valid
scalars, arrays, objects, nested objects, enums, `b:0;`, and three
malformed payloads): every save/block verdict is identical to 2.6.7, and
the only behavioural difference is that no constructor, `__wakeup()` or
`__destruct()` runs.

This completes the sweep started in 2.6.7, which hardened
`recursive_unserialize_replace()` but missed this call site.

Reported by Raphael P. Cigana via WPScan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kilo-code-bot

kilo-code-bot Bot commented Sep 24, 2026

Copy link
Copy Markdown

Kilo Code Review could not run — your account is out of credits.

Add credits or switch to a free model to enable reviews on this change.

@agentosiwp
agentosiwp Bot marked this pull request as draft September 24, 2026 08:45
@agentosiwp

agentosiwp Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Author

Updated at 775c536 — removed the Upgrade Notice section from docs/readme.txt per owner instruction (it only existed to carry the "Security release. Updating is recommended for all sites." line, now dropped). No other section changed; Changelog still reads Security: hardened serialized data processing.

Docs-only again — the fix file and its two QA PASS + code-review PASS verdicts above are unaffected.

Still human: /code-review ultra 406, merge, tag 2.6.8 (= wp.org publish, Vikas's call), confirm wp.org serving it, reply to WPScan (credit still owed there per their disclosure policy — see prior comment).

— Engineer (agent-os)

Two review findings, neither affecting behaviour:

- `allowed_classes => false` does NOT gate PHP 8.1 enums: an `E:` value
  still resolves to the real case, and WP's `is_serialized()` accepts that
  token. Measured, and confirmed harmless -- an enum may not declare
  __wakeup/__destruct/__construct/__serialize/__unserialize, so it cannot
  carry a gadget. The comment said the gate was total; it now names the
  exception so the next reader does not reason from a false guarantee.

- Added an `== Upgrade Notice ==` section, which wp.org surfaces in the
  update nag. Worth having on a security release.

Token stream of the PHP file is unchanged (307 tokens before and after), so
the code edit is comment-only. All 31 harness verdicts still identical to
2.6.7; php -l and PHPCS clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@janakkapadia

Copy link
Copy Markdown

QA: PASS ✅
Env: throwaway docker WordPress 7.1 · PHP 8.2.33 · String Locator 2.6.7 from wp.org, with only includes/Extension/SQL/Tests/class-serialized-data.php replaced by the PR head blob b2daf3be (verified byte-identical in-container; trunk blob 4b2c5b52) · PR #406 @734c9d0
Criteria: the five database-editor save cases, plus no regression to the file editor.

# case (validate ✔ unless noted) result
1 serialized array containing an object — unknown class O:8:"SLQA_Obj" and stdClass saves — "The database entry has been updated", new value in the DB ✅
2 ordinary serialized array saves ✅
3 corrupted serialized data still blocked — "The data is meant to be serialized with PHP, but is no longer valid", row unchanged ✅
4 plain non-serialized text saves ✅
5 corrupted data, checkbox unticked saves ✅

Repro-before-fix: yes. Every case was driven on stock 2.6.7 first and behaved identically. On top of the UI legs I ran a 44-case serialized corpus (nested/deep arrays, objects of known + unknown classes, private/protected props, references, DateTime, ArrayObject/SplStack (C:), enums, __wakeup/__unserialize, 5 corruption shapes, plain text, scalars, 500-key array) through the real Serialized_Data instance on both file versions: 44/44 identical verdicts, while the instrumented counters went __wakeup 1 → 0 and inner unserialize 1 → 0 — the check no longer builds objects, and no input changed side.

Also: plugin activates cleanly, editor + checkbox render, file editor (hello.php) still saves, and zero PHP notices/warnings/fatals in the container log across the whole run. Known whitespace quirk reproduced on both versions — not a regression.

⚠️ One fixture note for anyone re-running: a corrupt value must keep its braces balanced, or a separate always-on brace check blocks the save instead (true on 2.6.7 too) and masks case 3/5.

— QA

@agentosiwp

agentosiwp Bot commented Sep 24, 2026

Copy link
Copy Markdown
Author

QA: PASS ✅ — independent second run, corroborating the verdict above, with one release-gating finding it did not cover.
Env: throwaway docker WordPress 7.1 · PHP 8.2.33 · wp.org 2.6.7 with only includes/Extension/SQL/Tests/class-serialized-data.php replaced by head blob b2daf3b (shipped 2.6.7 file verified byte-identical to base blob 4b2c5b5) · #406 @734c9d0

All five save cases reproduced independently (stock-first baseline, then head): object rows save, array rows save, corrupt data still blocked with the notice and the row unchanged, plain text saves, unticked checkbox saves. A 42-case corpus through the real Serialized_Data::run() gave 0/42 changed verdicts, with the __wakeup counter going 1 → 0. Editor + checkbox render, file editor unaffected, 0 PHP notices/warnings/fatals.

⚠️ The one action before release — declare a PHP floor. unserialize()'s second argument is PHP 7.0+. On older PHP the call raises "unserialize() expects exactly 1 parameter, 2 given" and returns false — the exact value run() treats as invalid. Measured on php:5.6.40: every valid serialized value flips SAVE → BLOCK (3/3), i.e. the database editor refuses to save any serialized row. Control on PHP 7.4: 0/16 differing. Verified 0/16–18 differing on 7.4, 8.2, 8.3.

This is reachable because the plugin declares no Requires PHP — not in docs/readme.txt, not in the plugin header — and I found no version_compare guard, nor any PHP-7-only syntax in the shipped build, so nothing else keeps it off PHP 5.6. Narrow population (such a site is on WP ≤ 5.x), but wp.org would offer it the update, and this is the release users are urged to take.

Suggested: add Requires PHP: 7.0 to docs/readme.txt (and the plugin header). Branch untouched.

— QA

InstaWP Engineer (bot) and others added 3 commits September 24, 2026 10:17
unserialize()'s allowed_classes argument (added in this PR for the
security fix) needs PHP 7.0+; on PHP 5.6 the call errors and run()
treats that as invalid data, so the database editor would refuse to
save any legitimately-serialized row. Measured 3/3 save->block flip
on PHP 5.6.40, 0/16 on 7.4/8.2/8.3. Nothing previously declared a PHP
floor, so wp.org would still offer this update to a PHP-5.6 site.

Also updates readme Tested up to to 7.1, the WordPress version QA
verified this release against.

QA finding on PR #406.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner instruction: keep the public changelog generic (no mechanism
details) and drop the reporter credit line from the public entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner instruction. It only existed to carry the removed
'Security release' line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@randhirinsta
randhirinsta marked this pull request as ready for review September 24, 2026 10:32
@randhirinsta
randhirinsta merged commit 73301e6 into trunk Sep 24, 2026
1 of 2 checks passed
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.

2 participants