The Exploit
An unauthenticated attacker can inject arbitrary SQL into LearnPress course queries via the term_id parameter in the course filter API by passing a non-integer value that bypasses esc_sql() validation.
GET /wp-json/learnpress/v1/courses?filter={"term_ids":["1' AND SLEEP(5)-- -"]} HTTP/1.1
Host: target-site.com
User-Agent: curl/7.68.0
Accept: */*
The server will hang for 5+ seconds as the injected SLEEP(5) query executes within the SQL IN() clause. An attacker observes the delay in response time, confirming code execution. By replacing SLEEP(5) with UNION-based or time-blind exfiltration queries, the attacker extracts user credentials, course enrollment data, and other sensitive database records without authentication.
What the Patch Did
Before:
$term_ids_format = esc_sql( join( ',', $filter->term_ids ) );
$filter->where[] = $this->wpdb->prepare( 'AND tx.term_id IN (' . $term_ids_format . ')' );
After:
$filter->term_ids = array_map( 'absint', $filter->term_ids );
$term_ids_format = join( ',', $filter->term_ids );
$filter->where[] = "AND tx.term_id IN ($term_ids_format)";
The patch added input validation using array_map( 'absint', ... ) to convert all array elements to absolute integers before concatenating them into SQL. The absint() WordPress function strips all non-numeric characters and ensures type safety at the application layer, guaranteeing that only integers can ever reach the SQL string. The removal of esc_sql() and wpdb::prepare() follows because these escaping methods are ineffective against pre-concatenated arrays — the real defence is type coercion, not string escaping.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').
The vulnerability exists in class-lp-course-db.php lines 507–516 and 522–530. User-supplied course filter parameters arrive from the REST API endpoint /wp-json/learnpress/v1/courses with query parameters like filter={"term_ids":["1","2"]}. These values are deserialized into PHP arrays and passed directly to $filter->term_ids. The code then joins the array into a comma-separated string using join() and wraps the result in esc_sql(), intending to escape SQL syntax. However, esc_sql() operates on the final string (e.g., "1,2' OR '1'='1"), escaping quotes at the character level rather than validating the array structure. An attacker inserts SQL metacharacters into individual array elements (e.g., ["1' AND SLEEP(5)-- -"]), which survive esc_sql() because commas inside quoted strings are not escaped. The concatenated SQL statement reaches wpdb::prepare() with the injection payload already embedded, and prepare() cannot undo the concatenation. The query executes with attacker SQL injected into the IN() clause syntax.
Why It Works
The load-bearing line is $filter->term_ids = array_map( 'absint', $filter->term_ids );.
Without this line, an attacker's payload string (e.g., "1' AND SLEEP(5)-- -") passes through unchanged, reaches the SQL concatenation, and injects commands. The absint() function converts "1' AND SLEEP(5)-- -" to the integer 1 by stripping all non-numeric characters — a guaranteed type conversion, not a filter that can be bypassed by encoding or escaping tricks.
The subsequent removal of esc_sql() and wpdb::prepare() is a consequence, not the core defence. Once all values are integers via absint(), string escaping becomes redundant; in fact, retaining it would suggest a false confidence in escaping that does not apply to arrays. The patch engineers correctly recognized that escaping is the wrong tool for array input validation and replaced it with type coercion — the only method that guarantees injection is impossible when the sink expects only numeric literals.
Hardening Checklist
- Validate array elements at the point of deserialization, not after concatenation. Use
array_map( 'absint', ... ),array_filter( ... , FILTER_VALIDATE_INT ), or equivalent type coercion before any string operations. - Never concatenate user input into SQL strings, even after escaping. Always use
wpdb::prepare()with placeholders (%d,%s) before concatenation, and ensure all user values are passed as theprepare()arguments, not baked into the query string. - Audit all uses of
esc_sql()on arrays or loop-joined values.esc_sql()is intended for single scalar strings; it provides no protection when applied post-concatenation to delimited lists. Replace with type validation at the array level. - Use
absint()orintval()for numeric REST API parameters before any SQL context. Many WordPress REST endpoints default to string parameters; explicitly cast course IDs, term IDs, and user IDs to integers immediately after deserialization. - Enable SQL query logging in development and test injected payloads (e.g.,
term_ids[]=1' OR '1'='1) to verify they do not alter the query structure. Review the actual SQL inwp-config.phpdebug logs before merging.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-4434
- LearnPress plugin repository and changelog on WordPress.org