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 or ''
email return request(
'POST',
"api/user/register",
={
json'id': uuid,
'mobileNumber': mobile_number,
'email': email,
'autoReceipt': True,
'checksum': compute_checksum([
'True'
uuid, mobile_number, email,
])
}
)
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:
= ''.join(map(str, params)) + HK
data = bcrypt.gensalt(rounds=6, prefix=b'2a')
salt 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?
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.
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)
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.
Thanks to the people that helped me proofread this post.