The Exploit
An unauthenticated attacker can inject SQL into the LearnPress courses REST API by crafting a malicious c_only_fields parameter that breaks out of a comma-separated list context and appends arbitrary SQL.
curl -X GET 'http://vulnerable-site.local/wp-json/learnpress/v1/courses?c_only_fields=ID,post_title/**/UNION/**/SELECT/**/user_login,user_pass/**/FROM/**/wp_users--' \
-H 'Content-Type: application/json'
The attacker observes the response includes user login credentials and password hashes in fields that should only contain course metadata. No authentication or capability check is required; the endpoint accepts requests from any origin.
What the Patch Did
Before
$fields = explode( ',', $fields_str );
$filter->fields = $fields;
and
// c_only_fields parameter was not being processed at all
After
$fields = explode( ',', $fields_str );
foreach ( $fields as $key => $field ) {
$fields[ $key ] = LP_Database::getInstance()->wpdb->prepare( '%i', $field );
}
$filter->fields = $fields;
and
$fields_only_str = LP_Helper::sanitize_params_submitted( urldecode( $param['c_only_fields'] ?? '' ) );
if ( ! empty( $fields_only_str ) ) {
$fields_only = explode( ',', $fields_only_str );
foreach ( $fields_only as $key => $field ) {
$fields_only[ $key ] = LP_Database::getInstance()->wpdb->prepare( '%i', $field );
}
$filter->only_fields = $fields_only;
}
The patch introduced two security controls: first, it applies wpdb->prepare() with the %i placeholder (integer identifier) to every field name in both the c_fields and c_only_fields parameters. Second, it wraps the untrusted parameter value in LP_Helper::sanitize_params_submitted() before parsing, providing defence in depth. The %i placeholder tells WordPress to treat the string as a column identifier, not a literal SQL fragment, escaping special characters and preventing breakout.
Root Cause
This is a CWE-89 SQL Injection vulnerability. The attack path is: an unauthenticated HTTP request carries a c_only_fields query parameter → the REST controller at /wp-json/learnpress/v1/courses accepts it with minimal validation → the parameter value is split by commas but never escaped or parameterized → the field names are interpolated directly into a SQL query string built in inc/Models/Courses.php → an attacker injects SQL operators and SELECT clauses that the database interprets as valid SQL commands. The c_only_fields parameter explicitly landed in the codebase without sanitization logic, creating an unguarded entry point to the database layer.
Why It Works
The load-bearing line is LP_Database::getInstance()->wpdb->prepare( '%i', $field ). Without it, field names remain raw user input; with it, WordPress's prepared statement mechanism escapes SQL metacharacters and enforces identifier context. If you removed only this line but kept LP_Helper::sanitize_params_submitted(), the exploit would still work because basic HTML sanitization (likely htmlspecialchars() or strip_tags()) does not prevent SQL syntax. The engineer added the helper function call for defense in depth—to catch obvious attack signatures early—but the real blocker is prepare(). The foreach loop is necessary because prepare() operates on individual strings, not arrays; without the loop, the unsanitized array would pass through unchanged.
Hardening Checklist
-
Whitelist field names before query construction. Before splitting user input into field names, validate each against a hardcoded array of allowed columns using
in_array( $field, $allowed_columns, true ). Do not rely on sanitization alone to prevent injection. -
Use parameterized queries for all dynamic SQL. Always call
$wpdb->prepare()with appropriate placeholders (%ifor identifiers,%dfor integers,%sfor strings) instead of string concatenation, even for column names that seem "safe." -
Apply nonce verification on REST endpoints. Add
check_ajax_referer()or a custom nonce check in the REST controller callback to prevent CSRF and establish an authenticated context, even if the endpoint is public. -
Escape and validate at REST controller entry. Use
sanitize_text_field()orsanitize_key()on all query parameters in the REST controller before passing them to model/database layers. Do not rely on downstream sanitization as a security boundary. -
Audit field-name handling across all REST endpoints. Search the codebase for
explode()calls on user input, especially in REST callbacks. Any parameter that controls column selection, table names, or field lists must be whitelisted or parameterized.
References
- https://nvd.nist.gov/vuln/detail/CVE-2024-8522