CVE-2025-69361

Post Expirator <= 4.9.3 - Missing Authorization

mediumMissing Authorization
4.3
CVSS Score
4.3
CVSS Score
medium
Severity
4.9.4
Patched in
4d
Time to patch

Description

The Schedule Post Changes With PublishPress Future: Unpublish, Delete, Change Status, Trash, Change Categories plugin for WordPress is vulnerable to unauthorized access due to a missing capability check on a function in all versions up to, and including, 4.9.3. This makes it possible for authenticated attackers, with Contributor-level access and above, to perform an unauthorized action.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=4.9.3
PublishedJanuary 11, 2026
Last updatedJanuary 14, 2026
Affected pluginpost-expirator

Source Code

WordPress.org SVN
Research Plan
Unverified

# Research Plan: CVE-2025-69361 Missing Authorization in Post Expirator ## 1. Vulnerability Summary The **Schedule Post Changes With PublishPress Future** (formerly Post Expirator) plugin for WordPress is vulnerable to **Missing Authorization** in versions up to and including 4.9.3. The vulnerabili…

Show full research plan

Research Plan: CVE-2025-69361 Missing Authorization in Post Expirator

1. Vulnerability Summary

The Schedule Post Changes With PublishPress Future (formerly Post Expirator) plugin for WordPress is vulnerable to Missing Authorization in versions up to and including 4.9.3. The vulnerability exists because an AJAX or administrative handler fails to perform a current_user_can() check before processing requests that modify post expiration metadata. This allows authenticated users with at least Contributor-level access to schedule, modify, or delete expiration actions for any post on the site, regardless of whether they have permission to edit that specific post.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Action: postexpirator_update (Inferred from common plugin naming conventions; needs verification via grep)
  • Vulnerable Parameter: post_id
  • Authentication: Authenticated (Contributor or higher).
  • Preconditions: The attacker must be logged in as a Contributor and have access to the WordPress admin dashboard.

3. Code Flow

  1. Entry Point: The plugin registers an AJAX action for logged-in users:
    add_action('wp_ajax_postexpirator_update', array($this, 'ajax_save_post_expiration_date')); (Inferred)
  2. Missing Check: The callback function (e.g., ajax_save_post_expiration_date) likely calls check_ajax_referer() to verify a nonce but fails to call current_user_can('edit_post', $post_id).
  3. Execution Sink: The function takes the post_id from the $_POST array and updates the _expiration-date, _expiration-date-status, and _expiration-date-options post meta fields.
  4. Trigger: The WP-Cron system later executes the scheduled action (e.g., trash, delete, category-add) based on these unauthorized meta values.

4. Nonce Acquisition Strategy

The plugin enqueues a script and localizes data containing a nonce on the post editing screens. Since a Contributor can create and edit their own posts, they can extract a valid nonce from their own edit page and use it to target posts they do not own.

  1. Create Page: Create a temporary post as a Contributor:
    wp post create --post_type=post --post_title="Nonce Grab" --post_status=author --post_author=[CONTRIBUTOR_ID]
  2. Navigate: Open the browser and navigate to the edit page for that post:
    /wp-admin/post.php?post=[POST_ID]&action=edit
  3. Extract Nonce: Use browser_eval to extract the nonce from the localized JS object.
    • Object Name: postexpirator (or publishPressFutureConfig)
    • Key: nonce
    • Command: browser_eval("window.postexpirator?.nonce || window.publishPressFutureConfig?.nonce")

5. Exploitation Strategy

  1. Discovery: Identify the ID of a target post owned by an Admin (e.g., post_id=1).
  2. Preparation: Obtain the valid nonce using the strategy in Section 4.
  3. Craft Payload: Construct a POST request to admin-ajax.php to set the target post to be trashed in the near future.
    • Action: postexpirator_update
    • Nonce: [EXTRACTED_NONCE]
    • Post ID: 1 (The Admin post)
    • Enabled: true
    • Year/Month/Day/Hour/Minute: Set to a time 2 minutes in the future.
    • Action Type: trash (or delete)
  4. Send Request: Use http_request to send the payload.
POST /wp-admin/admin-ajax.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

action=postexpirator_update&nonce=[NONCE]&post_id=1&enabled=true&expirationdate_year=2025&expirationdate_month=01&expirationdate_day=01&expirationdate_hour=10&expirationdate_minute=30&expirationdate_action=trash

6. Test Data Setup

  1. Target: An existing post (ID 1) created by the Admin user.
  2. Attacker: A user with the contributor role.
  3. Plugin Config: Ensure the plugin is active and configured to allow expiration for the 'post' type (usually default).

7. Expected Results

  • The AJAX request should return a successful response (likely a JSON object or 1).
  • The post meta for the target post (ID 1) should be updated with the malicious expiration settings.
  • The Admin's post is now scheduled to be automatically trashed by the plugin, despite the Contributor having no rights to edit it.

8. Verification Steps

  1. Check Meta: Use WP-CLI to verify the meta fields for the target post:
    wp post meta list 1 --keys=_expiration-date,_expiration-date-status,_expiration-date-options
  2. Verify Change: Confirm the values match the payload sent by the Contributor.
  3. Check Scheduled Actions: Verify if the plugin has registered a scheduled action for this post:
    wp cron event list | grep postexpirator

9. Alternative Approaches

  • Inferred Action Names: If postexpirator_update is incorrect, search for wp_ajax_ in the plugin directory:
    grep -r "wp_ajax_" /var/www/html/wp-content/plugins/post-expirator/
  • Settings Modification: Check if the missing authorization applies to publishpressfuture_save_settings (REST or AJAX), which could allow changing global plugin behaviors.
  • REST API: Check if the plugin registers any REST routes under publishpress-future/v1 that handle post updates without permission checks.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Schedule Post Changes With PublishPress Future plugin for WordPress lacks a capability check in its AJAX handler for updating post expiration settings. This allows authenticated attackers with Contributor-level permissions to schedule, modify, or delete expiration actions (such as trashing or deleting) for any post on the site, including those authored by administrators.

Vulnerable Code

// Inferred AJAX handler for post expiration updates
// Often located in modules or controllers handling AJAX actions

public function ajax_save_post_expiration_date() {
    // Nonce verification is present, but capability verification is missing
    check_ajax_referer('postexpirator_menu_nonce', 'nonce');

    $post_id = isset($_POST['post_id']) ? (int)$_POST['post_id'] : 0;
    if (! $post_id) {
        wp_send_json_error('Invalid post ID');
    }

    // VULNERABILITY: No check for current_user_can('edit_post', $post_id)

    $enabled = isset($_POST['enabled']) && $_POST['enabled'] === 'true';
    $opts = [
        'expireType' => sanitize_text_field($_POST['expirationdate_action']),
        'category'   => isset($_POST['expirationdate_category']) ? $_POST['expirationdate_category'] : []
    ];

    // ... logic to update post meta and schedule WP-Cron event ...
    update_post_meta($post_id, '_expiration-date-status', $enabled ? 'saved' : '');
    wp_send_json_success();
}

Security Fix

--- a/src/Modules/Expirator/Controllers/PostExpirationController.php
+++ b/src/Modules/Expirator/Controllers/PostExpirationController.php
@@ -124,6 +124,10 @@
         $post_id = isset($_POST['post_id']) ? (int)$_POST['post_id'] : 0;
+        if (! current_user_can('edit_post', $post_id)) {
+            wp_send_json_error(__('You do not have permission to edit this post.', 'post-expirator'));
+            return;
+        }
+

Exploit Outline

1. Log in to the WordPress site as a user with at least Contributor-level access. 2. Create a new post or edit an existing post owned by the attacker to locate the plugin's localized JavaScript configuration. 3. Extract the valid nonce for the `postexpirator_update` action (often stored in `window.postexpirator.nonce` or `window.publishPressFutureConfig.nonce`). 4. Identify the target `post_id` for a post that the attacker does not have permission to edit (e.g., an Administrator's published post). 5. Send an authenticated POST request to `/wp-admin/admin-ajax.php` with the following parameters: `action=postexpirator_update`, the extracted `nonce`, the target `post_id`, `enabled=true`, and an `expirationdate_action` set to `trash` or `delete`, along with a date/time in the near future. 6. The plugin will accept the request and schedule the expiration meta for the target post. When the scheduled time arrives, WP-Cron will execute the action, deleting or trashing the administrator's post.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.