The Exploit
An unauthenticated attacker can inject time-based SQL via the order_by parameter in any LearnPress API endpoint or frontend handler that accepts course filtering.
GET /wp-json/lp/v1/courses?order_by=post_date AND SLEEP(5) HTTP/1.1
Host: target.local
User-Agent: curl/7.85.0
Accept: */*
On vulnerable versions (≤4.2.5.7), the server pauses for 5 seconds before responding, confirming blind SQL injection. The attacker observes a delayed HTTP 200 response with course data; on fixed versions, the request either returns immediately with an error or a 400 Bad Request if the order_by value fails stricter validation.
What the Patch Did
Before
$filter->order_by = LP_Helper::sanitize_params_submitted( ! empty( $param['order_by'] ) ? $param['order_by'] : 'post_date' );
$filter->order = LP_Helper::sanitize_params_submitted( ! empty( $param['order'] ) ? $param['order'] : 'DESC' );
After
$filter->order_by = LP_Helper::sanitize_params_submitted( ! empty( $param['order_by'] ) ? $param['order_by'] : 'post_date', 'key' );
$filter->order = LP_Helper::sanitize_params_submitted( ! empty( $param['order'] ) ? $param['order'] : 'DESC', 'key' );
The patch adds a second parameter 'key' to both LP_Helper::sanitize_params_submitted() calls. This parameter instructs the helper function to validate the input as a SQL identifier (column name or sort direction) rather than as generic text. The security control being enforced is whitelist validation of SQL column identifiers — the function now rejects any value that does not match a predefined set of safe column names (e.g., post_date, post_title, post_modified) or sort keywords (e.g., ASC, DESC). Without the 'key' parameter, the old code performed only basic escaping, which is insufficient to prevent ORDER BY clause injection.
Root Cause
CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').
The vulnerable dataflow begins when a user-controlled order_by parameter arrives via the HTTP request (GET or POST). The parameter is extracted into $param['order_by'] by the request handler. It is then passed to LP_Helper::sanitize_params_submitted() without the 'key' type hint. The function applies only generic sanitization (likely stripslashes or basic escaping), which does not prevent SQL metacharacters from being interpreted as query syntax. The unsanitized value flows directly into a SQL ORDER BY clause, crossing the query builder trust boundary unchecked. Because ORDER BY accepts column identifiers without quotes, an attacker can inject boolean operators (AND, OR), timing functions (SLEEP(), BENCHMARK()), or subqueries to leak data or exhaust resources.
Why It Works
The load-bearing line is the addition of the 'key' parameter to the sanitize_params_submitted() call. Removing it restores the vulnerability because the helper function requires this hint to know which validation ruleset to apply. Without it, the function falls back to a permissive text sanitizer that allows most characters. The patch adds the parameter to both order_by and order because SQL injection can occur in either position — an attacker might inject via order=post_date UNION SELECT … just as easily as via order_by. The engineer added both fixes to ensure defence-in-depth across all ORDER-related parameters; patching only one would leave a second injection point open. The fix is effective because it replaces output escaping (insufficient for identifiers) with input validation against a whitelist — the only reliable defence for SQL syntax elements that cannot be quoted.
Hardening Checklist
-
Enumerate SQL column names at class initialization and validate
order_byagainst an explicit whitelist (e.g.,$allowed_order_by = array('post_date', 'post_title', 'post_modified')andin_array($param['order_by'], $allowed_order_by, true)). Do not rely on helper functions without verifying their implementation. -
Use parameterized queries or query builder APIs for all ORDER BY and GROUP BY clauses if the underlying database library supports them (e.g., Doctrine, Eloquent, or WordPress's
$wpdb->prepare()for the WHERE clause, though ORDER BY still requires manual whitelisting). -
Add a unit test that confirms malicious SQL is rejected. For example, test that
order_by=post_date AND SLEEP(5)returns 400 Bad Request, not a delayed 200 response. Include tests for common injection vectors: boolean operators, aggregate functions, subqueries. -
Audit all other user-supplied parameters that touch SQL syntax (LIMIT offset, GROUP BY, HAVING, JOIN ON). If any parameter influences SQL structure rather than values, apply whitelist validation with the same rigor as
order_by. -
Document which parameters are safe and which are risky in inline code comments. Mark any parameter that reaches
$wpdb->query()or custom query construction as "must be whitelisted" to prevent future developers from making the same mistake.
References
- https://nvd.nist.gov/vuln/detail/CVE-2023-6567