PoC Archive PoC Archive
High CVE-2026-2576 patched

Business Directory Plugin for WordPress — Unauthenticated Time-Based Blind SQL Injection (CVE-2026-2576)

by SowatKheang · 2026-07-05

CVSS 7.5/10
Severity
High
CVE
CVE-2026-2576
Category
web
Affected product
Business Directory Plugin (Easy Listing Directories) for WordPress
Affected versions
All versions <= 6.4.21 (patched in 6.4.22)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-03
Author / ResearcherSowatKheang
CVE / AdvisoryCVE-2026-2576
Categoryweb
SeverityHigh
CVSS Score7.5 (NVD) / 9.3 (Wordfence)
StatusWeaponized
Tagswordpress, sqli, time-based-blind, business-directory-plugin, unauthenticated, cwe-89, wpdb, docker-lab
RelatedN/A

Affected Target

FieldValue
Software / SystemBusiness Directory Plugin (Easy Listing Directories) for WordPress
Versions AffectedAll versions <= 6.4.21 (patched in 6.4.22)
Language / PlatformPython 3 (PoC) targeting PHP/WordPress + MySQL
Authentication RequiredNo
Network Access RequiredYes

Summary

The Business Directory Plugin’s ORM query builder (class-db-query-set.php) safely parameterizes scalar filter values with $wpdb->prepare(), but falls back to raw string concatenation whenever a filter value is an array. The plugin’s checkout controller reads the payment request parameter and forwards it into this ORM layer, and because PHP automatically turns a payment[]=value query string into an array, an attacker can force the vulnerable array branch simply by using bracket notation. This allows an unauthenticated attacker to inject a boolean/time-based SQL payload into the payment_key filter and infer database contents through response timing. The included PoC is a full extraction tool (detection, table listing, table dumping, and arbitrary sub-query extraction) plus a Dockerized vulnerable WordPress lab for safe reproduction.


Vulnerability Details

Root Cause

In includes/db/class-db-query-set.php::filter_args(), array-typed filter values are concatenated directly into the SQL IN (...) clause ("$f IN ('" . implode("','", $v) . "')") without escaping, whereas scalar values go through $wpdb->prepare(). The checkout controller passes the payment request parameter directly into this filter.

Attack Vector

  1. Attacker sends GET /?page_id=<bd_page>&wpbdp_view=checkout&payment[]=<payload> — the [] notation forces PHP to parse payment as an array.
  2. The checkout controller passes the array value to WPBDP_Payment::objects()->get(['payment_key' => $payment_id]).
  3. filter_args() detects the array type and builds the query via raw string concatenation instead of $wpdb->prepare().
  4. A payload such as key') AND IF((<condition>),SLEEP(N),0)-- - closes the string/parenthesis, introduces a conditional SLEEP, and comments out the trailing SQL, creating a boolean/time oracle. Because the ORM executes the built query twice per request, delays are doubled (SLEEP(1) ≈ 2s observed).
  5. Repeating the request with varying conditions/character positions (as automated by poc.py) extracts arbitrary database content, including the wp_users table.

Impact

Unauthenticated remote extraction of arbitrary database contents (WordPress credentials/password hashes, payment records, PII) via blind SQL injection.


Environment / Lab Setup

Target:   WordPress + Business Directory Plugin 6.4.21 (Docker: WordPress, MySQL, phpMyAdmin — see docker-compose.yml / setup.sh / init-db.sql)
Attacker: Python 3.9+, `pip3 install requests colorama`

Proof of Concept

PoC Script

See poc.py (extraction tool) and patch_diff.py (vulnerable vs. patched version diff helper) in this folder. Lab bring-up files: docker-compose.yml, init-db.sql, setup.sh, uploads.ini.

1
2
3
python3 poc.py --target http://localhost:8080 --page-id 4 --payment-key seed-pay-001 --detect

python3 poc.py --target http://localhost:8080 --page-id 4 --payment-key seed-pay-001 --dump-table wp_users --sleep 1 --threads 8

The script confirms the time-based oracle, then uses parallelized character-by-character boolean extraction (via IF(...,SLEEP(N),0)) to enumerate tables and dump arbitrary table contents or custom SQL sub-query results from the target database.


Detection & Indicators of Compromise

Signs of compromise:

  • Repeated checkout-page requests with payment[]= parameters containing SQL keywords (SLEEP, IF, UNION, --)
  • Consistent, incrementally-delayed response times from the same source IP against the checkout endpoint
  • WAF/IDS alerts for SQL injection signatures on WordPress wpbdp_view=checkout requests

Remediation

ActionDetail
Primary fixUpgrade Business Directory Plugin to version 6.4.22 or later, which applies esc_sql() to array-typed filter values in filter_args()
Interim mitigationBlock/alert on payment[]= (array-notation) query parameters at the WAF; disable or restrict the plugin’s checkout view until patched

References


Notes

Mirrored from https://github.com/SowatKheang/CVE_2026_2576_PoC on 2026-07-05.

patch_diff.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
#!/usr/bin/env python3
"""
patch_diff.py — Download and diff Business Directory Plugin
    v6.4.21 (vulnerable) vs v6.4.22 (patched)

Pinpoints the exact lines changed to fix CVE-2026-2576
Requires: svn OR wget/curl (falls back to direct zip download)
"""

import os
import sys
import subprocess
import shutil
import difflib
import urllib.request
import zipfile
import io
from pathlib import Path

try:
    from colorama import Fore, Style, init
    init(autoreset=True)
except ImportError:
    class Fore:
        RED=GREEN=YELLOW=CYAN=WHITE=BLUE=""
    class Style:
        RESET_ALL=""

VULNERABLE = "6.4.21"
PATCHED    = "6.4.22" 
SLUG       = "business-directory-plugin"

# WordPress.org SVN
SVN_BASE  = f"https://plugins.svn.wordpress.org/{SLUG}/tags"
# WordPress.org direct zip
ZIP_BASE  = f"https://downloads.wordpress.org/plugin/{SLUG}"

# Files most likely to contain the fix, in priority order
TARGET_FILES = [
    "includes/class-db-query-set.php",
    "views/class-checkout.php",
    "core/class-payment.php",
    "includes/helpers/class-payment-query.php",
    "app/models/class-payment.php",
]

WORK_DIR = Path("/tmp/wpbdp_diff")


# Try to download using SVN first (faster if available), 
# otherwise fall back to direct zip download
def download_zip(version: str) -> Path:
    dest = WORK_DIR / version
    if dest.exists():
        print(f"  {Fore.CYAN}[cache]{Style.RESET_ALL} Using cached {version}")
        return dest

    url = f"{ZIP_BASE}.{version}.zip"
    print(f"  {Fore.BLUE}[dl]{Style.RESET_ALL} Downloading {url} ...")
    try:
        with urllib.request.urlopen(url, timeout=30) as resp:
            data = resp.read()
        zf = zipfile.ZipFile(io.BytesIO(data))
        zf.extractall(WORK_DIR)
        # Zip extracts to e.g. business-directory-plugin/
        extracted = WORK_DIR / SLUG
        if extracted.exists():
            extracted.rename(dest)
        else:
            # Some zips use version-suffixed folder
            for item in WORK_DIR.iterdir():
                if item.is_dir() and SLUG in item.name:
                    item.rename(dest)
                    break
        print(f"  {Fore.GREEN}[ok]{Style.RESET_ALL} Extracted to {dest}")
        return dest
    except Exception as e:
        print(f"  {Fore.RED}[!]{Style.RESET_ALL} Download failed: {e}")
        return None

# Note: SVN export is faster and cleaner than downloading and extracting zips,
# but it requires SVN to be installed and may be blocked in some environments.
# This function is not used in the main flow but can be enabled if desired.
def diff_file(path_a: Path, path_b: Path) -> list:
    """Return unified diff lines between two files."""
    try:
        lines_a = path_a.read_text(errors="replace").splitlines(keepends=True)
        lines_b = path_b.read_text(errors="replace").splitlines(keepends=True)
    except FileNotFoundError:
        return []
    return list(difflib.unified_diff(
        lines_a, lines_b,
        fromfile=str(path_a),
        tofile=str(path_b),
        lineterm="",
    ))

# Simple color-coding for diff output
def color_diff(lines: list) -> str:
    out = []
    for line in lines:
        if line.startswith("+++") or line.startswith("---"):
            out.append(f"{Fore.WHITE}{line}{Style.RESET_ALL}")
        elif line.startswith("+"):
            out.append(f"{Fore.GREEN}{line}{Style.RESET_ALL}")
        elif line.startswith("-"):
            out.append(f"{Fore.RED}{line}{Style.RESET_ALL}")
        elif line.startswith("@@"):
            out.append(f"{Fore.CYAN}{line}{Style.RESET_ALL}")
        else:
            out.append(line)
    return "\n".join(out)

# Recursively find all PHP files in a directory, returning relative paths
def find_all_php(root: Path) -> list:
    return sorted(root.rglob("*.php"))

# Main execution flow
def main():
    print(f"""
    {Fore.YELLOW}══════════════════════════════════════════════════════
    CVE-2026-2576 Patch Differ
    {SLUG}  {VULNERABLE}{PATCHED}
    ══════════════════════════════════════════════════════{Style.RESET_ALL}
    """)

    WORK_DIR.mkdir(parents=True, exist_ok=True)

    print(f"{Fore.BLUE}[*]{Style.RESET_ALL} Downloading plugin versions...")
    dir_vuln   = download_zip(VULNERABLE)
    dir_patched = download_zip(PATCHED)

    if not dir_vuln or not dir_patched:
        print(f"{Fore.RED}[-]{Style.RESET_ALL} Failed to download one or both versions.")
        print("     Try manually:")
        print(f"     svn export {SVN_BASE}/{VULNERABLE}/ {WORK_DIR}/{VULNERABLE}/")
        print(f"     svn export {SVN_BASE}/{PATCHED}/ {WORK_DIR}/{PATCHED}/")
        sys.exit(1)

    # Targeted diff on known relevant files
    print(f"\n{Fore.YELLOW}[1] Diffing target files (most likely to contain fix):{Style.RESET_ALL}")
    found_diffs = []

    for rel in TARGET_FILES:
        fa = dir_vuln   / rel
        fb = dir_patched / rel
        if not fa.exists() and not fb.exists():
            continue
        diff = diff_file(fa, fb)
        if diff:
            print(f"\n{Fore.WHITE}{'═'*60}{Style.RESET_ALL}")
            print(f"{Fore.YELLOW}FILE: {rel}{Style.RESET_ALL}")
            print(f"{Fore.WHITE}{'═'*60}{Style.RESET_ALL}")
            print(color_diff(diff))
            found_diffs.append(rel)

    # Full scan: any changed PHP file
    print(f"\n{Fore.YELLOW}[2] Full scan — all changed PHP files:{Style.RESET_ALL}")
    all_vuln    = {f.relative_to(dir_vuln):    f for f in find_all_php(dir_vuln)}
    all_patched = {f.relative_to(dir_patched): f for f in find_all_php(dir_patched)}

    all_keys = set(all_vuln.keys()) | set(all_patched.keys())
    changed = []

    for rel in sorted(all_keys):
        fa = all_vuln.get(rel)
        fb = all_patched.get(rel)
        if fa is None:
            print(f"  {Fore.GREEN}[NEW]{Style.RESET_ALL} {rel}")
            changed.append(str(rel))
            continue
        if fb is None:
            print(f"  {Fore.RED}[DEL]{Style.RESET_ALL} {rel}")
            changed.append(str(rel))
            continue
        if fa.read_bytes() != fb.read_bytes():
            changed.append(str(rel))
            print(f"  {Fore.CYAN}[MOD]{Style.RESET_ALL} {rel}")

    print(f"\n{Fore.GREEN}[+]{Style.RESET_ALL} {len(changed)} file(s) changed between versions")

    # Print full diffs for all changed files
    if "--full" in sys.argv:
        print(f"\n{Fore.YELLOW}[3] Full diffs (--full mode):{Style.RESET_ALL}")
        for rel in sorted(all_keys):
            fa = all_vuln.get(rel)
            fb = all_patched.get(rel)
            if fa and fb:
                diff = diff_file(fa, fb)
                if diff:
                    print(f"\n{'═'*60}\nFILE: {rel}\n{'═'*60}")
                    print(color_diff(diff))

    # Summary and patterns to look for in the diffs
    print(f"""
{Fore.YELLOW}══════════════════════════════════════════════════════
    Diff Analysis Complete
══════════════════════════════════════════════════════{Style.RESET_ALL}

    Vulnerable version : {VULNERABLE}{dir_vuln}
    Patched version    : {PATCHED}{dir_patched}

    Files changed: {len(changed)}
    {chr(10).join('    ' + f for f in changed[:15])}

    Look for these patterns in the diff to find the fix:
    {Fore.GREEN}+  $wpdb->prepare(){Style.RESET_ALL}        <= parameterised query added
    {Fore.GREEN}+  absint(){Style.RESET_ALL}                <= integer sanitisation
    {Fore.GREEN}+  intval(){Style.RESET_ALL}                <= integer cast
    {Fore.GREEN}+  sanitize_text_field(){Style.RESET_ALL}   <= string sanitisation
    {Fore.RED}-  $payment_id{Style.RESET_ALL}               <= raw variable removed from query

    Re-run with --full to see complete diffs for all files.
""")

if __name__ == "__main__":
    main()