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.

***
Risk & Underwriting Team All of our underwriting decisions are made with a human touch, which is what sets us apart and allows us to meet each customer's needs in a way that software alone could not. More posts by this author

You Might Also Like