SSLSocket -- #verify_mode Doesn't Verify

Here at Braintree, we integrate with a number of credit card processing platforms, and all communication with them is encrypted. Most of the services speak HTTPS, but one doesn't, so we drop down to a raw SSLSocket to talk to them. Fortunately, we test like crazy, so we caught an inconsistency in Ruby's certification validation mechanism that would have caused a security vulnerability for us.

The basics

At the highest level, SSL/TLS serves two purposes.
  1. It provides an encrypted channel of communication.
  2. It can verify the identity of the server.

For the second point to hold, the server's certificate must be signed by someone you trust. Additionally (in Ruby), you have to explicitly set a flag if you want to fail if the server isn't who you think it is. This is important to make sure you are communicating with the server that you think you are. Without this, your traffic would be encrypted, but you could be sending it to an attacker. All it would take is for an attacker to hack your hosts file or DNS lookup to point the domain to a different IP. If the attacker acts as a man-in-the-middle and proxies your traffic on to the originally intended host, it may be very difficult to catch this.

Common usage – Connection over HTTPS (works as expected)

For normal HTTPS connections, setting up a connection is straight-forward

require 'net/http'

require 'net/https'

require 'uri'



url = URI.parse "https://provider.example.com/path"

http = Net::HTTP.new(url.host, url.port)

http.use_ssl = true

http.ca_file = CA_FILE

http.verify_mode = OpenSSL::SSL::VERIFY_PEER

request = Net::HTTP::Get.new(url.path)

response = http.request(request)



At Braintree, we can be a bit paranoid when it comes to matters of security, and we try to err on the side of too many tests. For example, we test that an OpenSSL exception actually is raised if the cert's verification (CommonName) fails. It looks something like:
it "fails verification when the certificate claims to be someone else" do

  SomeProvider.stub(:port).and_return(8443)

  SomeProvider.stub(:domain).and_return("localhost")



  # Server with a fake cert, as we might find in a man-in-the-middle attack

  start_ssl_server(8443) do

    expect do

      SomeProvider::Http._http_do(Net::HTTP::Get, "/login")

    end.to raise_error(OpenSSL::SSL::SSLError, /hostname was not match with the server certificate/) # [sic]

  end

end

It's important to set the ca_file and verify_mode, but once you have that, you'll be verifying the server's identity. The same does not hold true for an SSLSocket.

Less Common Usage – Connection over an SSL socket (does not work as expected)

To open an SSLSocket, we use OpenSSL::SSL::SSLSocket. It behaves much like Net::HTTP:
require 'socket'

require 'openssl'



socket = TCPSocket.new('provider.example.com', 443)



ssl_context = OpenSSL::SSL::SSLContext.new

ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER

ssl_context.ca_file = CA_FILE



ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)

ssl_socket.sync_close = true

ssl_socket.connect



ssl_socket.puts("GET / HTTP/1.0")

ssl_socket.puts("")

while line = ssl_socket.gets

  puts line

end



The problem is that despite having the same #ca_file and #verify_mode settings, the SSLContext does not use them in the same manner as Net::HTTP. In fact, using the above code, you can connect to a HTTPS server that is masquerading as someone else without getting an exception. This is a serious issue for anyone sending sensitive data over an SSLSocket. The fix (albeit a bit ugly) is to do the check and raise an exception yourself:
def verify_ssl_certificate(preverify_ok, ssl_context)

  if preverify_ok != true || ssl_context.error != 0

    err_msg = "SSL Verification failed -- Preverify: #{preverify_ok}, Error: #{ssl_context.error_string} (#{ssl_context.error})"

    raise OpenSSL::SSL::SSLError.new(err_msg)

  end

  true

end



socket = TCPSocket.new('some.server.com', 443)



ssl_context = OpenSSL::SSL::SSLContext.new()

ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER

ssl_context.verify_callback = proc do |preverify_ok, ssl_context|

  verify_ssl_certificate(preverify_ok, ssl_context)

end

ssl_context.ca_file = CA_FILE



ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)

ssl_socket.sync_close = true

ssl_socket.connect



ssl_socket.puts("GET / HTTP/1.0")

ssl_socket.puts("")

while line = ssl_socket.gets

  puts line

end



If you're using an SSLContext, be sure to define the verify_callback. Otherwise your data will be encrypted, but you may not be sending it to the server you think you're sending it to.