CVE-2026-32421

Post Timeline <= 2.4.1 - Missing Authorization

mediumMissing Authorization
5.3
CVSS Score
5.3
CVSS Score
medium
Severity
2.4.2
Patched in
49d
Time to patch

Description

The Post Timeline plugin for WordPress is vulnerable to unauthorized access due to a missing capability check on a function in versions up to, and including, 2.4.1. This makes it possible for unauthenticated attackers to perform an unauthorized action.

CVSS Vector Breakdown

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

Technical Details

Affected versions<=2.4.1
PublishedFebruary 26, 2026
Last updatedApril 15, 2026
Affected pluginpost-timeline

What Changed in the Fix

Changes introduced in v2.4.2

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This analysis covers **CVE-2026-32421**, a Missing Authorization vulnerability in the **Post Timeline** plugin (<= 2.4.1). The vulnerability allows unauthenticated attackers to perform unauthorized actions, likely modifying plugin settings or data via AJAX. ### 1. Vulnerability Summary The **Post T…

Show full research plan

This analysis covers CVE-2026-32421, a Missing Authorization vulnerability in the Post Timeline plugin (<= 2.4.1). The vulnerability allows unauthenticated attackers to perform unauthorized actions, likely modifying plugin settings or data via AJAX.

1. Vulnerability Summary

The Post Timeline plugin fails to implement proper capability checks and nonce verification on one or more AJAX handlers registered via wp_ajax_nopriv_. In versions up to and including 2.4.1, certain sensitive functions (most likely ptl_save_settings or ptl_update_order) are accessible to unauthenticated users. This allows an attacker to modify the plugin's global configuration or manipulate timeline data.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Action: ptl_save_settings (inferred based on plugin architecture and CVSS) or ptl_update_post_order.
  • Authentication: None required (Unauthenticated).
  • Parameter: action, nonce, and the data payload (e.g., ptl_settings[]).
  • Preconditions: The plugin must be active. A valid nonce may be required if the handler calls check_ajax_referer, but because the nonce is typically exposed on the frontend for timeline functionality, it can be easily retrieved.

3. Code Flow

  1. Entry Point: The attacker sends a POST request to admin-ajax.php with the action parameter set to a vulnerable hook (e.g., ptl_save_settings).
  2. Hook Registration: In the plugin's initialization (likely includes/plugin.php), a hook is registered:
    add_action('wp_ajax_nopriv_ptl_save_settings', 'save_settings_callback');
  3. Vulnerable Callback: The callback function (e.g., save_settings_callback) is executed.
  4. Missing Check: The function lacks a current_user_can('manage_options') check. If a nonce check exists, it likely uses a nonce that is also available to unauthenticated users on the frontend.
  5. Sink: The function eventually calls update_option('post_timeline_global_settings', ...) or performs a database modification using user-supplied data from $_POST.

4. Nonce Acquisition Strategy

The plugin enqueues scripts for the frontend timeline which often include a nonce for AJAX operations (like "Load More").

  1. Identify Shortcode: The main shortcode is [post-timeline], as seen in includes/frontend/app.php.
  2. Create Trigger Page: Create a public page containing this shortcode.
    wp post create --post_type=page --post_title="Timeline Test" --post_status=publish --post_content='[post-timeline]'
    
  3. Retrieve Nonce: Navigate to the page and extract the nonce from the localized JavaScript object. Based on the script registration in includes/frontend/app.php, the handle is post-timeline-public-script. The object name is likely ptl_vars.
    • Action: browser_navigate to the new page.
    • Action: browser_eval("window.ptl_vars?.nonce || window.ptl_ajax_obj?.nonce").

5. Exploitation Strategy

We will attempt to modify the post_timeline_global_settings option, specifically targeting a benign setting like ptl-font-family to demonstrate the vulnerability without breaking the site.

  1. Step 1: Discover Action and Nonce Key
    Search the plugin code for the AJAX registration:
    grep -rn "wp_ajax_nopriv_" .
    Identify the action that looks like a settings update or data modification.
  2. Step 2: Get Nonce
    Follow the strategy in Section 4 to obtain the nonce from the frontend.
  3. Step 3: Execute Unauthorized Action
    Send the malicious request via http_request.
    • URL: http://localhost:8080/wp-admin/admin-ajax.php
    • Method: POST
    • Body:
      action=ptl_save_settings&nonce=[EXTRACTED_NONCE]&ptl_settings[ptl-font-family]=ExploitText
      
    • Note: If the action name differs (e.g., ptl_update_settings), adjust accordingly.
  4. Step 4: Verify Success
    Check the WordPress database for the modified option.

6. Test Data Setup

  1. Install/Activate: Ensure post-timeline version 2.4.1 is active.
  2. Create Content: Create at least one post and assign it to a category so the [post-timeline] shortcode renders content.
    wp post create --post_type=post --post_title="Sample Post" --post_status=publish
    
  3. Create Page:
    wp post create --post_type=page --post_title="Exploit Page" --post_status=publish --post_content='[post-timeline]'
    

7. Expected Results

  • The admin-ajax.php request should return a successful status code (e.g., 200 OK or a JSON {"success":true}).
  • The post_timeline_global_settings option in the database should be updated with the attacker-controlled value.

8. Verification Steps

After the HTTP request, use WP-CLI to confirm the change:

# Get the current settings
wp option get post_timeline_global_settings --format=json

Verify that the key (e.g., ptl-font-family) matches the payload value (ExploitText).

9. Alternative Approaches

If ptl_save_settings is not the vulnerable action:

  1. Check for Term Creation: Look for an AJAX action like ptl_add_tag. If found, attempt to create a new tag in the post_timeline_tags taxonomy.
    • Payload: action=ptl_add_tag&tag_name=HackedTag&nonce=[NONCE]
    • Verification: wp term list post_timeline_tags
  2. Check for Order Manipulation: Look for ptl_update_post_order.
    • Payload: `action=ptl_
Research Findings
Static analysis — not yet PoC-verified

Summary

The Post Timeline plugin for WordPress is vulnerable to unauthorized data disclosure due to a missing capability check and post status verification in the ptl_popup_gallery AJAX handler. This allows unauthenticated attackers to retrieve gallery content and metadata from posts that may be private, in draft status, or password-protected by supplying the target post ID.

Vulnerable Code

// includes/frontend/app.php around line 352
    public function ptl_popup_gallery()
    {
        $response          = new \stdclass();
        $response->success = false;
        $html              = '';
        $post_id           = isset($_POST['post_id']) ? sanitize_text_field(wp_unslash($_POST['post_id'])) : '';
        $post_thumbnail    = get_post_thumbnail_id($post_id);
        //	Get the Custom Meta
        $post_meta 	     = get_post_custom($post_id);

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/post-timeline/2.4.1/includes/frontend/app.php	2025-12-01 10:57:22.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/post-timeline/2.4.2/includes/frontend/app.php	2026-02-26 06:53:04.000000000 +0000
@@ -354,7 +354,17 @@
         $response          = new \stdclass();
         $response->success = false;
         $html              = '';
-        $post_id           = isset($_POST['post_id']) ? sanitize_text_field(wp_unslash($_POST['post_id'])) : '';
+        $post_id           = isset($_POST['post_id']) ? absint(wp_unslash($_POST['post_id'])) : 0;
+        $post              = ($post_id > 0) ? get_post($post_id) : null;
+
+        if (
+            !$post ||
+            ('publish' !== $post->post_status && !current_user_can('read_post', $post_id)) ||
+            post_password_required($post)
+        ) {
+            wp_send_json($response);
+        }
+
         $post_thumbnail    = get_post_thumbnail_id($post_id);
         //	Get the Custom Meta
         $post_meta 	     = get_post_custom($post_id);
@@ -372,16 +382,16 @@
             $gallery = explode(',', $gallery);
             $gallery = array_filter($gallery);
             if (!empty($gallery)) {
-                $html = '<div class="owl-carousel owl-theme ptl-media-gallery-' . $post_id . '  ptl-media-post-gallery-popup">';
+                $html = '<div class="owl-carousel owl-theme ptl-media-gallery-' . esc_attr($post_id) . '  ptl-media-post-gallery-popup">';
 
                 if ($img) {
-                    $img_src = 'src="' . $img . '"';
+                    $img_src = 'src="' . esc_url($img) . '"';
 
                     if ($this->settings['ptl-lazy-load'] == 'on') {
-                        $img_src = 'src="' . $placeholder_img . '" data-src="' . $img . '"';
+                        $img_src = 'src="' . esc_url($placeholder_img) . '" data-src="' . esc_url($img) . '"';
                     }
 
-                    $html .= '<div><img ' . $img_src . ' alt="' . $image_alt . '"/></div>';
+                    $html .= '<div><img ' . $img_src . ' alt="' . esc_attr($image_alt) . '"/></div>';
                 }
 
                 foreach ($gallery as $image) {
@@ -389,34 +399,33 @@
                         $url       = wp_get_attachment_image_src($image, 'full')[0];
                         $image_alt = (!empty(get_post_meta($image, '_wp_attachment_image_alt', true))) ? get_post_meta($image, '_wp_attachment_image_alt', true) : get_the_title($image);
 
-                        $img_src = 'src="' . $url . '"';
+                        $img_src = 'src="' . esc_url($url) . '"';
 
                         if ($this->settings['ptl-lazy-load'] == 'on') {
-                            $img_src = 'src="' . $placeholder_img . '" data-src="' . $url . '"';
+                            $img_src = 'src="' . esc_url($placeholder_img) . '" data-src="' . esc_url($url) . '"';
                         }
 
-                        $html .= '<div><img ' . $img_src . '  alt="' . $image_alt . '" /></div>';
+                        $html .= '<div><img ' . $img_src . '  alt="' . esc_attr($image_alt) . '" /></div>';
                     }
                 }
 
                 $html .= '</div>';
             } else {
                 if ($img) {
-                    $img_src = 'src="' . $img . '"';
+                    $img_src = 'src="' . esc_url($img) . '"';
 
                     if ($this->settings['ptl-lazy-load'] == 'on') {
-                        $img_src = 'src="' . $placeholder_img . '" data-src="' . $img . '"';
+                        $img_src = 'src="' . esc_url($placeholder_img) . '" data-src="' . esc_url($img) . '"';
                     }
 
-                    $html .= '<img ' . $img_src . '  alt="' . $image_alt . '" />';
+                    $html .= '<img ' . $img_src . '  alt="' . esc_attr($image_alt) . '" />';
                 }
             }
             $response->gallery = $html;
             $response->success = true;
         }
 
-        echo json_encode($response);
-        die;
+        wp_send_json($response);
     }

Exploit Outline

1. Identify a target Post ID that is either private, a draft, or password-protected. 2. Access the WordPress AJAX endpoint at `/wp-admin/admin-ajax.php`. 3. Send a POST request with the `action` parameter set to `ptl_popup_gallery` and the `post_id` parameter set to the target ID. 4. No authentication is required for this request as the plugin registers the action with `wp_ajax_nopriv_`. 5. The server will respond with a JSON object containing the rendered HTML of the gallery and images associated with the restricted post, effectively bypassing intended access controls.

Check if your site is affected.

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