Testing Hostname Verification

This is part of a series of posts about setting up Play WS as a TLS client for a "secure by default" setup and configuration through text files, along with the research and thinking behind the setup. I recommend The Most Dangerous Code in the World for more background. And thanks to Jon for the shoutout in Techcrunch.

Previous posts are:

The last talked about implementing hostname verification, which was a particular concern in TMDCitW. This post shows how you can test that your TLS client implements hostname verification correctly, by staging an attack. We're going to use dnschef, a DNS proxy server, to confuse the client into talking to the wrong server.

To keep things simple, I'm going to assume you're on Mac OS X Mavericks at this point. (If you're on Linux, this is old hat. If you're on Windows, it's probably easier to use a VM like Virtualbox to set up a Linux environment.)

The first step to installing dnschef is to install a decent Python. The Python Guide suggests Homebrew, and Homebrew requires XCode be installed, so let's start there.

Install XCode

Install XCode from the App Store and also install the command line tools:

xcode-select --install

Install Homebrew itself:

ruby -e "$(curl -fsSL https://raw.github.com/Homebrew/homebrew/go/install)"

Homebrew has some notes about Python, so we set up the command line environment:

export ARCHFLAGS="-arch x86_64"
export PATH=/usr/local/bin:/usr/local/sbin:~/bin:$PATH

Now (if you already have homebrew installed):

brew update
brew install openssl
brew install python --with-brewed-openssl --framework

You should see:

$ python --version
Python 2.7.6
$ which python
/usr/local/bin/python

If you run into trouble, then brew doctor or brew link --overwrite python should sort things out.

Now upgrade the various package tools for Python:

pip install --upgrade setuptools
pip install --upgrade pip

Now that we've got Python installed, we can install dnschef:

wget https://thesprawl.org/media/projects/dnschef-0.2.1.tar.gz
tar xvzf dnschef-0.2.1.tar.gz
cd dnschef-0.2.1

Then, we need to use dnschef as a nameserver. An attacker would use rogue DHCP or ARP spoofing to fool your computer into accepting this, but we can just add it directly:

OS X - Open System Preferences and click on the Network icon.

Select the active interface and fill in the DNS Server field. If you are using Airport then you will have to click on Advanced… button and edit DNS servers from there.

dns-localhost

Don't forget to click "Apply" after making the changes!

Now, we're going to use DNS to redirect https://www.howsmyssl.com to https://playframework.com.

$ host playframework.com
playframework.com has address 54.243.50.169

We need to specify the IP address 54.243.50.169 as the fakeip argument.

$ sudo /usr/local/bin/python ./dnschef.py --fakedomains www.howsmyssl.com --fakeip 54.243.50.169
          _                _          __
         | | version 0.2  | |        / _|
       __| |_ __  ___  ___| |__   ___| |_
      / _` | '_ \/ __|/ __| '_ \ / _ \  _|
     | (_| | | | \__ \ (__| | | |  __/ |
      \__,_|_| |_|___/\___|_| |_|\___|_|
                   [email protected]

[*] DNSChef started on interface: 127.0.0.1
[*] Using the following nameservers: 8.8.8.8
[*] Cooking A replies to point to 54.243.50.169 matching: www.howsmyssl.com

Now that we've got dnschef working as a proxy, we can see whether various TLS clients notice that www.howsmyssl.com has started returning an X.509 certificate that says it came from "playframework.com":

$ curl https://www.howsmyssl.com/
curl: (60) SSL certificate problem: Invalid certificate chain
More details here: http://curl.haxx.se/docs/sslcerts.html

Curl is not fooled. It knows the subjectAltName.dnsName is different.

Let's try Play WS:

[ssltest] $ testOnly HowsMySSLSpec
[info] Compiling 1 Scala source to /Users/wsargent/work/ssltest/target/scala-2.10/test-classes...
Mar 31, 2014 6:11:08 PM org.jboss.netty.channel.DefaultChannelFuture
WARNING: An exception was thrown by ChannelFutureListener.
java.net.ConnectException: HostnameVerifier exception.
	at com.ning.http.client.providers.netty.NettyConnectListener.operationComplete(NettyConnectListener.java:81)
	at org.jboss.netty.channel.DefaultChannelFuture.notifyListener(DefaultChannelFuture.java:427)
	at org.jboss.netty.channel.DefaultChannelFuture.notifyListeners(DefaultChannelFuture.java:413)
	at org.jboss.netty.channel.DefaultChannelFuture.setSuccess(DefaultChannelFuture.java:362)
	at org.jboss.netty.handler.ssl.SslHandler.setHandshakeSuccess(SslHandler.java:1383)
	at org.jboss.netty.handler.ssl.SslHandler.unwrap(SslHandler.java:1252)
	at org.jboss.netty.handler.ssl.SslHandler.decode(SslHandler.java:913)
	at org.jboss.netty.handler.codec.frame.FrameDecoder.callDecode(FrameDecoder.java:425)
	at org.jboss.netty.handler.codec.frame.FrameDecoder.messageReceived(FrameDecoder.java:303)
	at org.jboss.netty.channel.Channels.fireMessageReceived(Channels.java:268)
	at org.jboss.netty.channel.Channels.fireMessageReceived(Channels.java:255)
	at org.jboss.netty.channel.socket.nio.NioWorker.read(NioWorker.java:88)
	at org.jboss.netty.channel.socket.nio.AbstractNioWorker.process(AbstractNioWorker.java:109)
	at org.jboss.netty.channel.socket.nio.AbstractNioSelector.run(AbstractNioSelector.java:312)
	at org.jboss.netty.channel.socket.nio.AbstractNioWorker.run(AbstractNioWorker.java:90)
	at org.jboss.netty.channel.socket.nio.NioWorker.run(NioWorker.java:178)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at java.lang.Thread.run(Thread.java:744)

[info] HowsMySSLSpec
[info]
[info] WS should
[info] + NOT be fooled by dnschef
[info]
[info] Total for specification HowsMySSLSpec
[info] Finished in 21 seconds, 162 ms
[info] 1 example, 0 failure, 0 error
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 25 s, completed Mar 31, 2014 6:11:26 PM]

Yep, it throws an exception.

Now let's try it with hostname verification off by setting the 'loose' option on the client:

class HowsMySSLSpec extends PlaySpecification with ClientMethods {
  val timeout: Timeout = 20.seconds
  "WS" should {
    "be fooled by dnschef" in {
      val rawConfig = play.api.Configuration(ConfigFactory.parseString(
        """
          |ws.ssl.loose.disableHostnameVerification=true
        """.stripMargin))

      val client = createClient(rawConfig)
      val response = await(client.url("https://www.howsmyssl.com").get())(timeout)
      response.status must be_==(200)
      response.body must contain("Play Framework")
    }
  }
}

Run the test:

[ssltest] $ testOnly HowsMySSLSpec
[info] HowsMySSLSpec
[info]
[info] WS should
[info] + be fooled by dnschef
[info]
[info] Total for specification HowsMySSLSpec
[info] Finished in 9 seconds, 675 ms
[info] 1 example, 0 failure, 0 error
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] Total time: 12 s, completed Mar 31, 2014 6:08:50 PM

It works! We have fooled WS into setting up a TLS connection with a different host, one that we have control over. If we were evil, we could then proxy https://playframework.com to the intended URL, and save off all the content or inject fake data.

Let's try Apache HttpClient 3.x:

name := "httpclienttest"

version := "1.0-SNAPSHOT"

libraryDependencies ++= Seq(
    "commons-httpclient" % "commons-httpclient" % "3.1",
    "org.specs2" %% "specs2" % "2.3.10" % "test"
)

scalacOptions in Test ++= Seq("-Yrangepos")

resolvers ++= Seq("snapshots", "releases").map(Resolver.sonatypeRepo)
import org.apache.commons.httpclient.HttpClient
import org.apache.commons.httpclient.methods.GetMethod
import org.specs2.mutable.Specification

class HttpClientSpec extends Specification {
  "HTTPClient" should {
    "do something" in {
      val httpclient = new HttpClient()
      val httpget = new GetMethod("https://www.howsmyssl.com/")
      try {
        httpclient.executeMethod(httpget)
        //val line = httpget.getResponseBodyAsString
        //line must not contain("Play Framework")
        httpget.getStatusCode must not be_==(200)
      } finally {
        httpget.releaseConnection()
      }
    }
  }
}

Running this gives:

[info] HttpClientSpec
[info]
[info] HTTPClient should
[info] x do something
[error]    '200' is equal to '200' (HttpClientSpec.scala:14)
[info]
[info]
[info] Total for specification HttpClientSpec
[info] Finished in 18 ms
[info] 1 example, 1 failure, 0 error
[error] Failed: Total 1, Failed 1, Errors 0, Passed 0
[error] Failed tests:
[error] 	HttpClientSpec
[error] (test:test) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 4 s, completed Mar 31, 2014 8:26:46 PM

Nope. HttpClient 3.x was retired in 2007, but any code that's still using it under the hood is vulnerable to this attack.

Try this on your own code and see what it does. I'll bet it'll be interesting.

Next

Odds and ends I couldn't cover elsewhere. And then best practices, and summing things up.

Comments