~/blog/event-plugin-booking-attack-surfacePOSTED
by Shady Nathan Tawfik · June 13, 2026 · 5 min

The Booking Plugin Is The Product, And The Product Is An Admin Surface

A training provider with a booking plugin exposed to the internet exposed its entire administrative surface with it: exports, attendee records, file uploads and an unauthenticated cron.

Interactive[blog-2.0]

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.

TL;DR: A training provider with a booking plugin exposed to the internet exposed its entire administrative surface with it: exports, attendee records, file uploads and an unauthenticated cron.

Findings

SeverityFindingEvidence
HIGHAttendee export endpoints reachable without authenticationFull participant list including contact details
HIGHUnauthenticated scheduled task endpointPlugin code executed on a timer with no operator
MEDIUMFile upload directory inside the web rootUploaded material served directly if the extension check fails
MEDIUMNo frame protection on administrative viewsAdministrative actions frameable by a third party
MEDIUMPayment handoff without signed callback verificationOrder state derivable from client-supplied data
LOWPlugin and core version exposed in asset pathsExact build identifiable

The attack path

Public course pageRegistration formScheduled task endpointPlugin code runs unattendedExport and report routinesParticipant records written to diskUpload of course materialFile stored inside the web root

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

~/code/bash bash
# 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

~/code/bash bash
$ 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

~/code/bash bash
$ 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

~/code/nginx nginx
# 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

~/code/apache apache
# 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

~/code/nginx nginx
add_header Content-Security-Policy "frame-ancestors 'none'" always;

Once an attacker gains access, they can perform the following actions:

Attendee export endpoints reachable without authentication
Unauthenticated scheduled task endpoint
File upload directory inside the web root
No frame protection on administrative views
Payment handoff without signed callback verification

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.

For educational purposes only. Every reproducible command targets my own lab environment (localhost), never a live system. © 2026