TL;DR: A booking plugin is a public form, a records database and an admin back office at once. I found the export endpoints answering before authentication and an unattended scheduled task, and I explain how I fixed both.
The scenario
I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.
A vocational training provider operating a public course catalogue with online registration, payment handoff and downloadable materials. The assessment covered the plugin administrative surface, unauthenticated endpoints, upload handling, scheduled task exposure and header posture.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
The pattern I recognised — and that I keep finding in similar estates:
A booking plugin is three things at once: a public form, a records database and an administrative back office. Installations optimise for the first, because that is what sells. The other two inherit whatever authorisation the plugin author felt was reasonable, and the back office is where the value sits.
The characteristic exposure is the export and reporting surface. If a plugin offers attendee lists, invoices or CSV exports, those endpoints exist, they are reachable, and they frequently answer before authentication is checked because the check lives in the page controller rather than the endpoint. Add an unauthenticated scheduled task and you have a mechanism that runs plugin code on a timer with no operator present.
The upload path deserves separate attention. Course materials are a legitimate reason to accept files, which makes the endpoint a permanent feature rather than something that can be switched off during an incident. The interesting questions are not whether uploads work but what happens to the parsed result, whether the file lands inside the web root, and whether the extension list is enforced on the server or only in the browser.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| HIGH | Attendee export endpoints reachable without authentication | Full participant list including contact details |
| HIGH | Unauthenticated scheduled task endpoint | Plugin code executed on a timer with no operator |
| MEDIUM | File upload directory inside the web root | Uploaded material served directly if the extension check fails |
| MEDIUM | No frame protection on administrative views | Administrative actions frameable by a third party |
| MEDIUM | Payment handoff without signed callback verification | Order state derivable from client-supplied data |
| LOW | Plugin and core version exposed in asset paths | Exact build identifiable |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Enumerate the plugin surface on a lab instance
# target: a lab WordPress with the same plugin family
# 1. find the plugin routes the plugin registers
$ curl -s http://localhost:8080/wp-json/ \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print([r for ns in d["namespaces"] if "book" in ns or "event" in ns or "tribe" in ns])'
# 2. ask every discovered route for an unauthenticated response
$ for r in $(curl -s http://localhost:8080/wp-json/ | python3 -c '
import json,sys
d=json.load(sys.stdin)
for ns in d["namespaces"]:
if any(k in ns for k in ("book","event","tribe")): print(ns)')
do
printf '%s -> %s\n' "$r" "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8080/wp-json/$r")"
done
# 3. the scheduled task endpoint is the quiet one
$ curl -so /dev/null -w '%{http_code}\n' 'http://localhost:8080/wp-cron.php?doing_wp_cron'
Detection in your own estate
Sweep your own REST namespaces for unauthenticated data routes
$ curl -s https://example.org/wp-json/ | python3 -c '
import json,sys
d = json.load(sys.stdin)
for ns in d["namespaces"]:
print(ns)'
# then fetch each route listing and look for anything that returns records
$ curl -s https://example.org/wp-json/ | python3 -c '
import json,sys
d = json.load(sys.stdin)
for ns in d["namespaces"]:
print(ns)' | while read -r ns; do
curl -s "https://example.org/wp-json/$ns" | python3 -c '
import json,sys
try:
for r,v in json.load(sys.stdin).get("routes",{}).items():
if not any(m in v for m in ("POST","PUT","DELETE")) and "permission" in str(v):
print(r)'
done
Check the upload directory and the extension enforcement
$ curl -s -o /dev/null -w '%{http_code}\n' https://example.org/wp-content/uploads/2026/
$ curl -so /dev/null -w '%{http_code}\n' \
'https://example.org/wp-content/uploads/2026/06/probe.php'
$ curl -so /dev/null -w '%{http_code}\n' \
'https://example.org/wp-content/uploads/2026/06/probe.phtml'
Remediation
Answer unauthenticated routes with a refusal, not with data
# refuse the plugin namespaces outright until they are needed
location ~* ^/wp-json/(book|event|tribe)[a-z_-]*/ {
if ($remote_user = "") { return 403; }
proxy_pass http://backend;
}
# never let the scheduler be triggered from the outside
location = /wp-cron.php { deny all; return 403; }
location = /wp-cron.php/ { deny all; return 403; }
Disable script execution in the upload tree
# uploads are content, never code
<Directory /var/www/html/wp-content/uploads>
php_flag engine off
<FilesMatch "\.(?i:php|phtml|php[0-9]|phar|cgi|pl|py|sh)$">
Require all denied
</FilesMatch>
</Directory>
Frame the administrative views
add_header Content-Security-Policy "frame-ancestors 'none'" always;
Once an attacker gains access, they can perform the following actions:
Takeaways
- Audit the plugin back office, not just the plugin form.
- An unauthenticated scheduled task is unattended code execution by design.
- If uploads are a product feature, treat the upload directory as public and hostile.
- Payment callbacks must be signed by the provider, not trusted from the browser.