Skip to content

fix(security): close unauthenticated RCE chain (Patchstack, CVSS 9.8) - #658

Merged
arifulhoque7 merged 1 commit into
weDevsOfficial:developfrom
arifulhoque7:fix/security-unauth-rce-chain
Aug 19, 2026
Merged

arifulhoque7 merged 1 commit into
weDevsOfficial:developfrom
arifulhoque7:fix/security-unauth-rce-chain

Conversation

@arifulhoque7

Copy link
Copy Markdown
Contributor

Closes weDevsOfficial/pm-pro#455

Fixes the Patchstack report (CVSS 9.8, unauthenticated RCE) against WP Project Manager <= 4.0.6, plus everything the same sweep turned up in the Free plugin.

The reported chain

The report chained three defects into unauthenticated remote code execution:

  1. an unauthenticated attacker creates a working WordPress account,
  2. that account (a project co_worker) injects a serialized PHP object,
  3. the plugin deserializes it, and any POP gadget in the loaded class set turns that into code execution.

What was wrong and what changed

1. The router failed open

core/Router/WP_Router.php::check_permission()

if ( empty( $permissions ) ) {
    $permitted = true;   // a route with no ->permission() was public
}

Any route that forgot its permission chain was reachable without authentication. It now returns a WP_Error with rest_authorization_required_code(). Endpoints that genuinely have to stay public opt in explicitly through the new WeDevs\PM\Core\Permissions\Public_Access class instead of relying on omission — none in Free need it today, but the Pro webhook does.

2. routes/trello.php declared 16 routes with no permission

trello, trello/test, trello/get_user, trello/get_boards, trello/get_lists, trello/get_cards, trello/get_subcards, trello/get_users — GET and POST each. They drive the Trello import: the caller supplies their own Trello app_key/app_token, so the plugin happily imports an attacker's board on an unauthenticated request.

All 16 now require Settings_Page_Access, matching the AdminRoute guard already on the Tools screen that calls them.

GET get-mime-type-icon in routes/file.php was also ungated; it now requires Authentic.

3. Core\Permissions\Administrator never checked anything

class Administrator extends Abstract_Permission {
    public function check() {
        return true;
    }
}

Every route using it was public too (pusher/test). It now checks manage_options, mirroring the Pro class of the same name.

4. Hard-coded password

src/Imports/Helpers/Import_helper.php::save_imported_user() created accounts with md5('123456#') = dcf877b18896c5fa70fbbd4dc9c8adb0 (CWE-798) and added them to the imported project as co_worker. It worked even with "Anyone can register" disabled.

Imported users now get wp_generate_password( 24, true, true ) and the standard new-user notification so they set their own password. wp_insert_user() errors are skipped instead of being passed on as a user id.

5. PHP object injection

Write side — Task_Controller::privacy() and Milestone_Controller::privacy() stored the raw is_private request param in pm_meta. Both now intval() it, matching what Discussion_Board_Controller already did.

Read side — Task::include_metas() ran @unserialize() on that stored value, so the attacker chose the class and property values of an object the plugin instantiated. Every unserialize() of stored plugin data now goes through the new wedevs_pm_safe_unserialize(), which passes allowed_classes => false:

$unserialized = @unserialize( $value, [ 'allowed_classes' => false ] );

A serialized object degrades to __PHP_Incomplete_Class, which has no magic methods to fire — so even if a new write path leaks in later, there is no gadget to reach. Applied to Task/Helper/Task.php, Common/Models/Meta.php, Activity/Models/Activity.php, Activity/Helper/Activity.php (7 call sites), Comment/Models/Comment.php, My_Task/Controllers/MyTask_Controller.php, and Settings/Models/Settings.php.

Proof

Unauthenticated, before → after (local we-pm.test, WP 6.x, PHP 7.4):

GET /wp-json/pm/v2/trello/get_boards                 200 → 401 rest_forbidden
GET /wp-json/pm/v2/trello/get_users                  200 → 401 rest_forbidden
GET /wp-json/pm/v2/get-mime-type-icon                200 → 401 rest_forbidden

Authenticated admin, still working:

GET pm/v2/projects/1/tasks?per_page=2       200  (12,924 bytes, meta reads intact)
GET pm/v2/projects/1/task-lists?per_page=2  200  (8,908 bytes)
GET pm/v2/trello/get_boards                 200

Object injection: a task's privacy meta can no longer hold anything but an int, and even a pre-existing poisoned row now deserializes to __PHP_Incomplete_Class rather than instantiating the gadget.

Not broken

  • Trello import still runs for an admin (the Tools screen is already AdminRoute, so the gate matches the UI).
  • Task/task-list/milestone privacy toggles unchanged for callers that send 0/1 — which is all of views/assets/src.
  • wedevs_pm_safe_unserialize() returns arrays and scalars exactly as maybe_unserialize() did, and hands back the raw string when the payload is not serialized, so stored settings, activity meta and comment mentions keep working.
  • No frontend change needed in Free.
  • vendor/bin/phpcs clean on every touched file; php -l clean under PHP 7.4 (the version in readme.txt).

Patchstack report (CVSS 9.8) chained three defects into unauthenticated
remote code execution.

Router fails closed
    check_permission() treated a route with no ->permission() as public, so
    every forgotten permission chain was an unauthenticated endpoint. It now
    denies by default; genuinely public endpoints opt in through the new
    Public_Access permission class.

Trello import routes are gated
    routes/trello.php declared 16 routes with no permission at all. They drive
    the admin Tools import, so they now require Settings_Page_Access, matching
    the AdminRoute guard on the ToolsPage screen. get-mime-type-icon now
    requires Authentic.

Administrator permission actually checks
    Core\Permissions\Administrator::check() returned true unconditionally,
    which made every route using it public as well. It now mirrors the Pro
    class and checks manage_options.

No hard-coded password
    Import_helper::save_imported_user() created WordPress accounts with
    md5('123456#'). Imported users now get a random password and the standard
    new-user notification so they set their own.

PHP object injection
    Task_Controller::privacy() and Milestone_Controller::privacy() wrote the
    raw is_private request param into pm_meta, and Task::include_metas() read
    it back through unserialize(). Both controllers now cast to int, and every
    unserialize() of stored plugin data goes through wedevs_pm_safe_unserialize()
    which passes allowed_classes => false, so a serialized object can never
    instantiate a POP gadget.
@arifulhoque7
arifulhoque7 merged commit fc4f470 into weDevsOfficial:develop Aug 19, 2026
3 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.

1 participant