Reverse Proof-of-Work

Posted on April 13, 2018 by Rasmus Précenth

One day some months ago I decided that I would try to reverse engineer the internal API used by one of the local public transport companies’ Android app. These are my findings.

After extracting the APK and decompiling the Xamarin DLLs I found the functions I was looking for. Below are some examples translated to python.

def register_user(uuid: str, mobile_number: str, email: str=None):
    email = email or ''
    return request(
        'POST',
        "api/user/register",
        json={
            'id': uuid,
            'mobileNumber': mobile_number,
            'email': email,
            'autoReceipt': True,
            'checksum': compute_checksum([
                uuid, mobile_number, email, 'True'
            ])
        }
    )

def get_travelfund(user_id: str):
    return request(
        'GET',
        "api/user/{}/travelfund".format(user_id),
        params={
            'checksum': compute_checksum([user])
        }
    )

# HK is a constant defined in the app.
HK = "..."

def compute_checksum(params: list) -> str:
    data = ''.join(map(str, params)) + HK
    salt = bcrypt.gensalt(rounds=6, prefix=b'2a')
    return bcrypt.hashpw(data.encode('ascii'), salt)

TL;DR: The endpoints take a number of parameters and those are concatenated and hashed together with a global constant. The hash function used is bcrypt.

What I noticed immediately was the checksum. What is it used for and is there a way to fake it?

Checksum?

Let’s assume that the checksum is intended to be just that: a checksum, i.e it verifies the integrity of the message. But does it really? According to the bcrypt Wikipedia page, most implementations truncate the bcrypt input to 72 bytes. It’s easily verified in python:

>>> import bcrypt
>>> salt = bcrypt.gensalt()
>>> key = b"a" * 100
>>> bcrypt.hashpw(key, salt) == bcrypt.hashpw(key[:72], salt)
True

Whoops! That means that only the first 72 bytes of the parameters are verified! Not a very good integrity test.

MAC?

Okay, let’s give the devs the benefit of the doubt and assume that the checksum is actually a message authentication code (MAC), i.e it verifies the authenticity of the message. But the MAC is missing a very important piece, the secret key. There is a hard coded value called HK that is being used as a key. The problem is of course that it is not secret, all instances of the app have the same key!

It is trivial for an attacker to modify the content and recompute the checksum, all of the values are visible in the request! (Also note that the key is irrelevant in the case when the concatenated parameters exceed the size of 72 bytes)

Proof-of-work?

Wait a second! Isn’t bcrypt supposed to prevent brute forcing of passwords by having a variable cost? That cost isn’t sent to the server, is it?

Yes and yes. A bcrypt hash looks like this:

$2b$12$FbrdOSnwfe33oVYR7MORfeWreE5ZedBf6j.1BcC5vMbLJgcY7.y6u

The first part specifices which version it is and what the cost parameter is, in this case 2b and 12 respectively. The last part is the salt and hash concatenated together. This makes sense in a lot of applications, but not so much here.

According to the decompiled code, the app uses a cost of 6. What happens if we create a fake hash with an unreasonable high work factor and send it to the server? Let’s add some logging and try it out.

>>> get_travelfund(USER_ID)
Took 0.509083 seconds
# After bumping the cost to 31
>>> get_travelfund(USER_ID)
Took 230.509074 seconds

It is possible to perform a really simple denial-of-service attack by sending the above request a couple of times.

Note that the attacker doesn’t have to do any work to send the request, the hash can be fake and the server won’t know until it has verified it. It’s basically a reverse proof-of-work, where the client asks the server to waste resources.

So not only is the MAC – which is the most likely option of the ones discussed – broken, it is also a liability.

Responsible Disclosure

  • 2017-12-02: Sent a message through the support form on the company website with information about the issue.
  • 2017-12-05: Received a response with contact details for a person I should contact directly.
  • 2017-12-11: Sent an email to the above address describing the problem and possible mitigations.
  • 2018-01-23: Sent another email to the same address after not receiving a response.
  • 2018-03-14: Sent a response to the support email telling them that I hadn’t received any response yet. I then received a reply rather quickly and got in touch with the person I had been trying to reach. It looks like my original emails never reached them.
  • 2018-03-14: Got confirmation that my report had been received.
  • 2018-03-16: Got another confirmation, this time thanking me for the report and confirming that they had already started fixing the problem.
  • 2018-03-29: Received a travel card for unlimited travel in the city for 1 year, which would cost me around €770 if I were to buy one myself.
  • 2018-04-13: Verified that the issue was fixed before posting.

Thanks to the people that helped me proofread this post.