Summary
How an Unpatched N-Day Let Any Anonymous Visitor Rewrite WooCommerce Prices in ND Booking This is a write-up of a vulnerability I independently discovered in ND Booking, a WordPress hotel/room booking plugin by Nicdark with WooCommerce integration. The vulnerability allows any unauthenticated visitor to permanently overwrite the price of any WooCommerce product linked to a bookable room, with no login, no capability check, and no ownership validation. I built and verified a full working proof-of-concept before submitting it — only to find out during the submission process that the exact same root cause had already been assigned CVE-2025–63001. The catch: that advisory lists the plugin as vulnerable only “up to and including version 3.8.” I tested this against version 7.0.1, the current release on WordPress.org, and it is still fully exploitable. This write-up documents the full process — discovery, exploitation, and what happened when a “new” finding turned out to be an old, never-actually-patched CVE — because the technical detail is still useful to anyone auditing booking/e-commerce plugins, even though I don’t get credit for a fresh CVE here. Background: Why ND Booking My WordPress plugin research methodology targets a curated list of plugins across several categories — booking/reservation, access-control, form builders, membership systems — chosen because they tend to implement their own authentication and state-changing logic on top of WordPress, rather than relying purely on WordPress core’s built-in mechanisms. Plugins that integrate with WooCommerce are a particular focus for me, because they combine two attack surfaces: the plugin’s own AJAX/REST layer, and WooCommerce’s product/pricing data model. ND Booking caught my attention for a simple reason: it registers a WooCommerce-integration AJAX action directly, rather than routing bookings through WooCommerce’s own cart/checkout security model. Any time a plugin builds a custom bridge into WooCommerce’s write path — inserting into the cart, or worse, mutating product data — I want to see exactly how that bridge is authenticated. Before touching any code, I did the usual passive pass: checked the plugin’s WPScan history, skimmed the changelog, and looked at how many other plugins in the same “room/booking + WooCommerce” family I had already ruled out. Several sibling plugins I had audited in the same session — a GloriaFood connector, a Beds24 connector, Ticket Tailor — all turned out to be pure SaaS-widget wrappers with no server-side logic of their own, and were dead ends. ND Booking was different: it had its own PHP-side AJAX handlers doing real WooCommerce write operations. Understanding the Architecture ND Booking exposes its booking search results through a shortcode, [nd_booking_search_results] , registered in inc/shortcodes/nd_booking_search_result.php . This shortcode is meant to be dropped onto any public-facing page — a hotel’s “search availability” page, for example — and it renders a JavaScript-driven search UI. To support “book this room” functionality when WooCommerce is active, the plugin registers a dedicated AJAX action: add_action( ‘wp_ajax_nd_booking_woo_php’, ‘nd_booking_woo_php’ ); add_action( ‘wp_ajax_nopriv_nd_booking_woo_php’, ‘nd_booking_woo_php’ ); The nopriv variant means this action is reachable by anyone — no login required by design. That much is expected: a booking search page has to work for anonymous visitors. The question is what authorization model protects the handler once it’s reachable. Here is the full handler, nd_booking_woo_php() : function nd_booking_woo_php() { check_ajax_referer( ‘nd_booking_woo_nonce’, ‘nd_booking_woo_security’ ); //get datas GET[‘nd_booking_trip_price’]); GET[‘nd_booking_rid’]); nd_booking_rid, ‘nd_booking_meta_box_room_woo_product’, true ); //clear cart WC()→cart→empty_cart(); //add to cart the product WC()→cart→add_to_cart(product = wc_get_product(product→set_regular_price(product→set_price(product→save(); nd_booking_book_room_woo_id = 'nd_booking_book_room_'.nd_booking_rid; echo esc_attr(nd_booking_book_room_woo_id); die();} The only gate here is check_ajax_referer( 'nd_booking_woo_nonce', 'nd_booking_woo_security' ) . There is no current_user_can() call, no check that the requester owns or has any relationship to the room being booked, and — critically — no validation that nd_booking_trip_price is a legitimate price for that room. The value is taken directly from product→save() , which writes it permanently to the database. The natural question is: how hard is it to get a valid nonce for check_ajax_referer ? The answer is: not hard at all, because the nonce isn’t scoped to a logged-in session in any way. It’s generated and localized to the page for every visitor, logged in or not: function nd_booking_shortcode_search_results() { // … nd_booking_woo_params = array( 'nd_booking_ajaxurl_woo' => admin_url('admin-ajax.php'), 'nd_booking_ajaxnonce_woo' => wp_create_nonce('nd_booking_woo_nonce'), ); wp_enqueue_script( 'nd_booking_search_woo', esc_url( plugins_url( 'woo.js', __FILE__ ) ), array( 'jquery' ) ); wp_localize_script( 'nd_booking_search_woo', 'nd_booking_my_vars_woo', nd_booking_woo_params ); // … } nd_booking_shortcode_search_results() is the callback for the public shortcode itself — no capability check gates it, because it’s meant to render for anonymous visitors. That means wp_create_nonce(‘nd_booking_woo_nonce’) runs on every page load of the booking search page, for every visitor, and the resulting token is embedded directly in the page’s HTML via wp_localize_script . A WordPress nonce verified this way is not proof of identity or authorization — it’s only proof that the requester loaded a public page at some point in the last 12–24 hours. In this handler, it’s the only thing standing between an anonymous visitor and a permanent write to WooCommerce product pricing. Put together: any visitor loads the booking search page, reads the nonce out of the page source, and can then call nd_booking_woo_php with any nd_booking_rid (room post ID) and any nd_booking_trip_price they like. The handler doesn’t check whether the price they’re submitting bears any relation to the room’s actual rate — it accepts and saves whatever value arrives. Confirming the Nonce Is Truly Public Before building the exploit, I wanted to rule out any possibility that the nonce was somehow gated behind a login wall in a way the source code didn’t make obvious — for instance, if the shortcode were only ever embedded on a page that itself required authentication in some site configurations. I created a plain WordPress page containing only [nd_booking_search_results] , opened it in a fresh private/incognito browser window with no cookies at all, and viewed the page source: var nd_booking_my_vars_woo = { “nd_booking_ajaxurl_woo”:“http://10.0.2.5:8080/wp-admin/admin-ajax.php”, “nd_booking_ajaxnonce_woo”:“9b7c2c7f80” }; The nonce was sitting in plain view, before any interaction, before any login prompt, in a browser session that had never touched the site before. That settled it: there was no session-scoping happening anywhere in this flow. Live Proof of Concept I reproduced this against a local Docker environment running WordPress 6.9 with WooCommerce 9.4.2 and ND Booking active, all fully patched to the current release. Setup: a “Victim Room” custom post (post type nd_booking_cpt_1 , ID 55) linked via the nd_booking_meta_box_room_woo_product meta field to a WooCommerce product (ID 56) with a legitimate admin-set price of ): 500 I then simulated the attacker’s exact vantage point — an anonymous, never-logged-in visitor — by generating the nonce the same way WordPress would generate it for user ID 0: NONCE=NONCE” And sent the exploit request with no cookies, no authentication headers, nothing but the nonce, the target room ID, and the desired price: curl -v “http://10.0.2.5:8080/wp-admin/admin-ajax.php?action=nd_booking_woo_php&nd_booking_woo_security=$NONCE&nd_booking_rid=55&nd_booking_trip_price=0.01” The response: HTTP/1.1 200 OK Set-Cookie: woocommerce_items_in_cart=1; path=/ Set-Cookie: woocommerce_cart_hash=6d38ed3e1401b8003d1ef78b00a6d031; path=/ Set-Cookie: wp_woocommerce_session…; path=/ … nd_booking_book_room_55 Notice what’s absent from those Set-Cookie headers: there is no wordpress_logged_in* cookie anywhere in the exchange, before or after. Every cookie present is a WooCommerce cart/session cookie, which any anonymous shopper gets automatically. This is conclusive evidence, at the HTTP protocol level, that the request required zero authentication. After state, re-checked in the same WooCommerce admin product editor without touching anything else: Regular price (500 to one cent — by a request that never presented a single credential. Impact Assessment The practical impact here is financial manipulation at scale, with essentially no barrier to entry. Any unauthenticated visitor who can view a site’s public booking page can:
- Rewrite the price of any room’s linked WooCommerce product to any value — including $0.01, or, just as easily, an inflated price designed to drive customers away from a competitor’s promotional listing.
- Do this repeatedly, for every room on the site, without rate limiting or any server-side sanity check on the submitted price.
- Do this without creating an account, without solving a CAPTCHA, and without ever presenting a session cookie tied to a real identity — the entire attack surface is reachable from a single unauthenticated GET request once a valid (public) nonce is read from the page source. For any business running this plugin with live WooCommerce checkout enabled, this is a direct path to revenue loss: an attacker (or a competitor) can silently mark every room down to a cent, let real customers book at that price, and the site owner may not notice until reconciling payments. It also enables the inverse abuse — inflating prices to make a competing property look cheaper by comparison, or simply to grief the site. The required access level is unauthenticated — the lowest possible bar. There is no plugin configuration that closes this off; the vulnerable code path is reached the moment the booking search shortcode is placed on any public page, which is the plugin’s entire intended use case. The N-Day Twist I built the full PoC above, took the screenshots, drafted the WPScan submission, and — treating this as a fresh, previously-unreported finding — submitted it under my own name (Shikhali Jamalzade), requesting a CVE. WPScan rejected the submission with the following note: “This is a duplicate of CVE-2025–63001 (nd-booking, unauthenticated Missing Authorization, all versions up to and including 3.8), which is already public and in our database. The nd_booking_woo_php price-modification action is the same unauthenticated missing-authorization issue in the same plugin covered by that advisory; a wider or different affected version range does not make it a distinct vulnerability. Closing as a duplicate of CVE-2025–63001.” This was a genuinely useful piece of information, and it changes the framing of this write-up in an important way. CVE-2025–63001 states the affected range as “up to and including 3.8.” I tested this against version 7.0.1 — the current release on WordPress.org at the time of writing — pulled directly from the plugin’s SVN trunk. The vulnerability is fully present and exploitable in that version. In other words: whatever advisory or vendor communication led to CVE-2025–63001 being filed against versions up to 3.8, the underlying flaw was never actually fixed in any release since. This isn’t a regression that reappeared after a patch — as far as I can tell from the current source, the vulnerable code was simply never touched. I flagged this discrepancy back to WPScan, requesting that the affected version range on CVE-2025–63001 be corrected to reflect that it’s still present in the current release, rather than treated as resolved at 3.8. I also re-contacted the vendor (Nicdark) directly, referencing the existing CVE and providing my full PoC, since a known, publicly-tracked CVE that’s still live in the shipped version is arguably a more urgent thing for a vendor to act on than a brand-new report — the “we already knew about this” excuse doesn’t apply here. The honest summary: I did not discover a new vulnerability. I independently rediscovered, fully weaponized, and empirically confirmed the continued presence of an existing CVE that the ecosystem had incorrectly filed as historical. I don’t get a new CVE number out of this, and I think that’s the correct outcome — but the practical security value of “this old CVE is not actually fixed, here’s proof” is real, and worth publishing on its own merits. Disclosure Timeline
- Discovery and full proof-of-concept built and verified: 2026–07–11
- Submitted to WPScan requesting a CVE, under the assumption this was a new finding: 2026–07–11
- WPScan identified this as a duplicate of the existing CVE-2025–63001, noting the advisory’s affected range as “up to and including 3.8”: 2026–07–11
- Vendor (Nicdark) contacted via official contact form, referencing the existing CVE and providing full PoC and version-currency evidence (tested on 7.0.1): 2026–07–11
- WPScan re-contacted requesting the CVE-2025–63001 affected-version range be corrected to reflect the issue is unresolved in current releases: 2026–07–11
- Write-up published: 2026–07–12
- Status at time of publication: unpatched in the current release (7.0.1); vendor response pending Because this vulnerability remains unpatched at the time of writing, I have deliberately kept the target in all commands as a local test environment (10.0.2.5:8080 ) rather than any live site, and I am not naming any real deployment. The technical detail here is already public via the existing CVE record, which is the basis for publishing before a fix ships. What This Teaches A few things stood out during this research that are worth naming explicitly. Always check whether “fixed” actually means fixed, at the version you’re testing. A CVE record with a bounded affected-version range (“up to and including X”) is a claim, not a guarantee, especially for smaller plugins without a rigorous internal QA process around security fixes. If you have a working exploit, it costs almost nothing to point it at the plugin’s current release and check whether the range in the advisory still holds. In this case it very much didn’t — a two-year-old-style “fixed at 3.8” claim was still fully broken four major-version-number jumps later, at 7.0.1. WooCommerce integration bridges are worth auditing on their own, separate from the booking logic itself. The actual booking/reservation flow in ND Booking wasn’t where the bug lived — the bug lived specifically in the glue code connecting a booking action to a WooCommerce product write. Any plugin that calls wc_get_product() , set_regular_price() , set_price() , or save() from inside a custom AJAX handler deserves a dedicated look, independent of how well-defended the rest of the plugin’s booking flow is, because these calls bypass WooCommerce’s own cart/checkout validation entirely. A nonce localized on a public, unauthenticated page is not an authorization mechanism — full stop. This is a pattern I keep finding across different plugins, and it’s worth stating plainly for other researchers and for plugin developers reading this: check_ajax_referer() / wp_verify_nonce() verifies that a request originated from a page the visitor loaded, and nothing else. If that page is reachable by anyone, the nonce provides zero authentication and only marginal CSRF protection (since the token itself is trivially readable in the page source by the same anonymous visitor). Any state-changing action gated only by a publicly-issued nonce — especially one that writes to pricing, inventory, or user data — should be treated as unauthenticated by default when threat-modeling a plugin. Rejected-as-duplicate is not the end of the story if the “original” was never actually fixed. It would have been easy to treat the WPScan rejection as a dead end and move on to the next candidate plugin. Instead, cross-checking the claimed affected-version range against the actual current release turned a “no new CVE” outcome into a legitimate, actionable disclosure: an existing advisory needed its metadata corrected, and a vendor needed a second, more forceful nudge referencing a CVE they may not even realize is still open against their current shipping version.