Skip to content

Instantly share code, notes, and snippets.

@madblobfish
Created August 3, 2022 23:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save madblobfish/9f1e89a3b5847ab80dcef16c56a4c0f9 to your computer and use it in GitHub Desktop.
Save madblobfish/9f1e89a3b5847ab80dcef16c56a4c0f9 to your computer and use it in GitHub Desktop.
Plain Ruby Radius (experiment)

Plain Ruby RADIUS experiment

This is a very hacky radius implementation (please use for testing only). You can test the loginflow with eapol_test from wpa_supplicant package. Also required: ruby/openssl#530

In one terminal:

openssl req -nodes -x509 -newkey rsa:4096 -sha256 -subj '/CN=localhost/' -days 99999 -keyout server.key -out server.crt
ruby radius-eap-ttls-server.rb

In another terminal:

cat > radius-test.config <<EOF
network={
        eap=TTLS
        phase2="auth=PAP"
        identity="user"
        password="password"
}
EOF
eapol_test -c radius-test.config -s radsec -r 0

You should see SUCCESS at the end of terminal2 and ! killing connection, done in terminal1

require 'openssl'
require 'socket'
require 'stringio'
SECRET = 'radsec' # note: this is not radsec yet
USERS = {'user'=>'password'}
DEBUG = ARGV.include?('--debug')
tls_connections = {}
tls_context = OpenSSL::SSL::SSLContext.new
unless File.exists?('./server.crt')
raise "please run: openssl req -nodes -x509 -newkey rsa:4096 -sha256 -subj '/CN=localhost/' -days 99999 -keyout server.key -out server.crt"
end
tls_context.add_certificate(
OpenSSL::X509::Certificate.new(File.read('./server.crt')),
OpenSSL::PKey::RSA.new( File.read('./server.key'))
)
tls_context.min_version = OpenSSL::SSL::TLS1_2_VERSION
tls_context.max_version = OpenSSL::SSL::TLS1_3_VERSION
tls_context.ciphers = 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'
# hack because you got no patch yet :P
unless OpenSSL::SSL::SSLSocket.instance_methods.include?(:export_keying_material)
class OpenSSL::SSL::SSLSocket
def export_keying_material(label, len, context=nil)
raise 'install or wait for https://github.com/ruby/openssl/pull/530'
end
end
end
RADIUS_PACKET_CODE = {
1=> 'Access-Request',
2=> 'Access-Accept',
3=> 'Access-Reject',
11=> 'Access-Challenge',
}
RADIUS_PACKET_CODE_INVERT = RADIUS_PACKET_CODE.invert
# https://www.iana.org/assignments/radius-types/radius-types.xhtml#radius-types-2
RADIUS_ATTRIBUTE_TYPE = {
1=> 'User-Name',
2=> 'User-Password',
26=> 'Vendor-Specific',
79=> 'EAP-Message',
80=> 'Message-Authenticator',
}
RADIUS_ATTRIBUTE_TYPE_INVERT = RADIUS_ATTRIBUTE_TYPE.invert
VENDOR_IDS = {
311=>'Microsoft',
}
VENDOR_IDS_INVERT = VENDOR_IDS.invert
VENDOR_SPECIFIC = {
311=>{
16=>'MS-MPPE-Send-Key',
17=>'MS-MPPE-Recv-Key',
}
}
VENDOR_SPECIFIC_INVERT = VENDOR_SPECIFIC.transform_values(&:invert)
# https://www.iana.org/assignments/eap-numbers/eap-numbers.xhtml#eap-numbers-1
EAP_PACKET_CODE = {
1=>'Request',
2=>'Response',
3=>'Success',
4=>'Failure',
}
EAP_PACKET_CODE_INVERT = EAP_PACKET_CODE.invert
# https://www.iana.org/assignments/eap-numbers/eap-numbers.xhtml#eap-numbers-4
EAP_METHOD_TYPES = {
1=>'Identity',
21=>'EAP-TTLS',
}
EAP_METHOD_TYPES_INVERT = EAP_METHOD_TYPES.invert
def eap_parse(pkt)
# https://datatracker.ietf.org/doc/html/rfc2284#section-2.2
package_type = EAP_PACKET_CODE.fetch(pkt.read(1).unpack1('C')) rescue (raise 'unknown eap package type')
identifier = pkt.read(1).unpack1('C')
length = pkt.read(2).unpack1('S>')
raise 'length not in 4 and 4096' unless (4..4096).include?(length)
method = nil
data = nil
if %w(Request Response).include?(package_type)
if length - 4 > 0
method = EAP_METHOD_TYPES.fetch(pkt.read(1).unpack1('C')) rescue (raise 'unknown eap method')
if method == 'EAP-TTLS'
data = {}
flags = pkt.read(1).unpack1('C')
data['Length included'] = flags & (2<<6) != 0
data['More Fragments'] = flags & (2<<5) != 0
data['Start'] = flags & (2<<4) != 0
data['Version'] = flags & 7
puts "version not 0" unless data['Version'] == 0
if data['Length included']
data['message length'] = pkt.read(4).unpack1('L>')
end
data['data'] = pkt.read()
else
data = pkt.read()
end
else
raise 'hmm expected some eap method here'
end
end
return {type: package_type, id: identifier, method: method, data: data}
end
def eap_response_single(type, id, method=nil, data=nil)
return [EAP_PACKET_CODE_INVERT.fetch(type, type), id, 4].pack('CCS>').b if not method
return [EAP_PACKET_CODE_INVERT.fetch(type, type), id, 5, EAP_METHOD_TYPES_INVERT.fetch(method, method)].pack('CCS>C').b if not data
return [EAP_PACKET_CODE_INVERT.fetch(type, type), id, 5+data.size, EAP_METHOD_TYPES_INVERT.fetch(method, method), data].pack('CCS>Ca*').b
end
def eap_response(type, id, method=nil, data=nil, **fields)
ret = eap_response_single(type, id, method, eap_ttls_data_encode(data, data&.b&.size))
return ret.each_char.each_slice(253).map(&:join) if ret.length > 253
return [ret]
end
def eap_ttls_data_encode(data=nil, message_len=nil, **fields)
return nil if data.nil? || data.size == 0
fields.default = '0'
fields['Length included'] = '1' unless message_len.nil?
out = [fields.values_at('Length included', 'More Fragments', 'Start', 'r1','r2').join() + '000'].pack('B*')
out += [message_len].pack('L>') if fields['Length included'] == '1'
data.nil? ? out.b : (out + data).b
end
def eap_ttls_avp_parse(pkt)
attrs = {}
until pkt.eof?
old_pos = pkt.pos
if pkt.read().tr("\0", '') == ''
break
else
pkt.pos = old_pos
end
code = pkt.read(4).unpack1('L>')
code = RADIUS_ATTRIBUTE_TYPE.fetch(code) if code <= 256
flags = pkt.read(1).unpack1('C')
raise 'Unknown attribute' if (flags & 64) != 0 && code.nil?
length = ("\x00" + pkt.read(3)).unpack1('L>') - 8
if (flags & 128) != 0
vendor = pkt.read(4).unpack1('L>')
length -= 4
end
data = pkt.read(length)
data = data.b.sub(/\0+$/, '') if code == 'User-Password'
attrs[code] ||= []
attrs[code] << data
end
attrs
end
def radius_parse(pkt)
package_type = RADIUS_PACKET_CODE.fetch(pkt.read(1).unpack1('C')) rescue (raise 'unknown radius package type')
identifier = pkt.read(1).unpack1('C')
length = pkt.read(2).unpack1('S>')
raise 'length not in 20 and 4096' unless (20..4096).include?(length)
authenticator = pkt.read(16)
attrs = StringIO.new(pkt.read(length - 20)) rescue StringIO.new('')
attrs.binmode
attributes = {}
while not attrs.eof?
begin
attr_type = RADIUS_ATTRIBUTE_TYPE.fetch(attrs.read(1).unpack1('C'))
attr_length = attrs.read(1).unpack1('C')
attr_value = attrs.read(attr_length-2)
attributes[attr_type] ||= []
attributes[attr_type] <<
if attr_type == 'EAP-Message'
eap_parse(StringIO.new(attr_value).binmode)
elsif attr_type == 'Vendor-Specific'
radius_response_vendor_value_parse(StringIO.new(attr_value).binmode)
else
attr_value
end
rescue KeyError
puts 'ignoring attribute' if DEBUG
attrs.read(attrs.read(1).unpack1('C')-2) # throw away the data
end
end
return {type: package_type, id: identifier, auth: authenticator, attributes: attributes}
end
def radius_message_auth(secret, type, id, length, authenticator, attrs)
OpenSSL::HMAC.digest("MD5", secret, [
type,
id,
length,
authenticator,
attrs + [RADIUS_ATTRIBUTE_TYPE_INVERT['Message-Authenticator'], 18,''].pack('CCa16')
].pack('CCS>a16A*'))
end
def radius_response_vendor_value(vendor, attributes)
[id = VENDOR_IDS_INVERT.fetch(vendor, vendor), attributes.map do |t,vals|
vals.map{|v|[VENDOR_SPECIFIC_INVERT.fetch(id).fetch(t,t), v.length+2, v].pack('CCA*')}.join('')
end.join('')].pack('L>A*')
end
def radius_response_vendor_value_parse(buffer)
package = {}
id_int = buffer.read(4).unpack1('L>')
package[:id] = VENDOR_IDS.fetch(id_int, nil)
if package[:id]
package[:type] = VENDOR_SPECIFIC[id_int].fetch(buffer.read(1).unpack1('C'))
package[:data] = buffer.read(buffer.read(1).unpack1('C') - 2)
end
package
end
def radius_response(secret, code, request, attributes={})
code = RADIUS_PACKET_CODE_INVERT.fetch(code, code)
attrs = attributes.map{|t,vals| vals.map{|v| raise "toolong #{v.length}" if v.length >=254;[RADIUS_ATTRIBUTE_TYPE_INVERT.fetch(t,t), v.length+2, v].pack('CCA*')}.join('')}.join('').b
length = 20 + attrs.length
raise 'too long' if length > 4096
if attributes['EAP-Message']
# see RFC 3579
length += 18
mauth = radius_message_auth(secret, code, request[:id], length, request[:auth], attrs)
attrs << [RADIUS_ATTRIBUTE_TYPE_INVERT['Message-Authenticator'], 18, mauth].pack('CCa16')
end
authenticator = OpenSSL::Digest::MD5.digest(
[code, request[:id], length, request[:auth], attrs, secret].pack('CCS>a16A*A*')
)
[code, request[:id], length, authenticator, attrs].pack('CCS>a16A*')
end
# https://datatracker.ietf.org/doc/html/rfc2548#section-2.4.3
# Note: decrypt is using a ciphertext as plaintext, its a md5 stream cipher
def microsoft_md5_cipher(secret, authenticator_plus_salt, plaintext)
require 'openssl'
b = OpenSSL::Digest::MD5.digest(secret + authenticator_plus_salt)
plaintext.b.split('').each_slice(16).map do |plain|
c = plain.join().ljust(16, "\0").each_byte.zip(b.each_byte).map{|x,y|(x^y).chr}.join('')
b = OpenSSL::Digest::MD5.digest(secret + c).b
c
end.join()
end
a = "\xFA\xD7\xC0r\xABWbE\x8F\xCE\x8Fo\x9F\xF2\xCD\x93\x80a".b
b = "|\x92\xBC\x9D\xDB[\x12\x9C^Y_\x16\xA8s(U".b
c = "\x1D\xE0\x17\xC7\xC6\xDA\xD7[~\x8B\x03c\xF3\xBC\x83\x8F".b
raise 'AH! (microsoft_md5_cipher broken)' unless microsoft_md5_cipher('s3cr3t', a, b) == c
raise 'AH! (microsoft_md5_cipher broken)' unless microsoft_md5_cipher('s3cr3t', a, microsoft_md5_cipher('s3cr3t', a, b)) == b
Socket.udp_server_loop(1812) do |msg, client|
request = radius_parse(StringIO.new(msg).binmode)
if DEBUG
puts '------------------------------------------------ New RADIUS Packet'
p request
end
case request
in {type: 'Access-Request'}
if eap = request[:attributes]['EAP-Message']
eap = eap.first
if eap[:method] == 'EAP-TTLS'
unless tls_connections['a']
puts "! starting TLS inside of EAP connection"
tunnel_socket, plain_socket = UNIXSocket.pair
tls_socket = OpenSSL::SSL::SSLSocket.new(tunnel_socket, tls_context)
tls_socket.sync_close = true
tls_connections['a'] = [tls_socket, plain_socket, [], nil]
if eap[:data]
plain_socket.send(eap[:data]['data'], 0)
thing = (tls_socket.accept_nonblock rescue '')
response = plain_socket.recv(99999)
tls_connections['a'][2] = response.b.each_char.each_slice(1450).map(&:join)
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>eap_response('Request', eap[:id]+1, 'EAP-TTLS', tls_connections['a'][2].shift)}))
end
else
tls_socket, plain_socket, messages, socket = tls_connections['a']
if socket.nil?
socket = (tls_socket.accept_nonblock rescue nil)
tls_connections['a'][3] = socket
end
plain_socket.send(eap[:data]['data'], 0)
response = plain_socket.recv_nonblock(99999) rescue ''
if response != ''
tls_connections['a'][2] = response.b.each_char.each_slice(1450).map(&:join)
messages = [true]
end
if messages.any?
puts '! send TLS data until we finished sending a TLS message'
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>eap_response('Request', eap[:id]+1, 'EAP-TTLS', tls_connections['a'][2].shift)}))
next
end
tls_query = socket.read_nonblock(99999) rescue nil
if tls_query
puts '--------------------- New TTLS encapsulated EAP Packet'
avp = eap_ttls_avp_parse(StringIO.new(tls_query).binmode)
p(avp) if DEBUG
if avp['User-Password'] && avp['User-Name'] # PAP
puts '! doing EAP-PAP authentication path'
# vvvvvvvvvvvvvvvv worst possible implementation vvvvvvvvvvvvvvvv
ok = USERS[avp['User-Name'].first] == avp['User-Password'].first
# ^^^^^^^^^^^^^^^^ worst possible implementation ^^^^^^^^^^^^^^^^
if ok
puts '! sent ok'
# https://datatracker.ietf.org/doc/html/rfc5705
msk = socket.export_keying_material("ttls keying material", 64).b
keyshare = [
[msk[...32], (2**7).chr + "a"],
[msk[32...], (2**7).chr + "b"]
].map do |msk, s|
radius_response_vendor_value('Microsoft', {"MS-MPPE-Recv-Key"=>[s + microsoft_md5_cipher(SECRET, request[:auth]+s, [32].pack('C')+msk)]})
end
client.reply(r = radius_response(SECRET, 'Access-Accept', request, {'EAP-Message'=>eap_response('Success', eap[:id]+1, 'EAP-TTLS'), 'Vendor-Specific'=>keyshare}))
p(radius_parse(StringIO.new(r).binmode)) if DEBUG
else
puts '! sent nok'
client.reply(radius_response(SECRET, 'Access-Reject', request, {'EAP-Message'=>eap_response('Failure', eap[:id]+1, 'EAP-TTLS')}))
end
puts '! killing connection, done'
tls_connections.delete('a')
elsif avp['EAP-Message'] # EAP goes even deeper here
puts '! not implemented for now'
client.reply(radius_response(SECRET, 'Access-Reject', request))
else
puts '! unknown auth here!'
client.reply(radius_response(SECRET, 'Access-Reject', request))
end
next # ignore the stuff below
end
response = plain_socket.recv_nonblock(99999) rescue ''
raise 'TLS response should be empty here, we expect data' if response != ''
puts '! sending empty TLS data to get responses'
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>eap_response('Request', eap[:id]+1, 'EAP-TTLS', '')}))
end
else
puts "! not TTLS, trying to get the client to start TTLS"
client.reply(radius_response(SECRET, 'Access-Challenge', request, {'EAP-Message'=>[eap_response_single('Request', eap[:id]+1, 'EAP-TTLS', eap_ttls_data_encode('Start'=>'1'))]}))
end
else
puts "! rejected client"
client.reply(radius_response(SECRET, 'Access-Reject', request))
end
else
puts "! did not got an Access-Request, not answering"
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment