Defeating web API integrity controls in Android
Introduction
In this post, I will be showing how I could reverse engineer an Android application to find API endpoints and how to issue requests to it by defeating integrity checks.
For legal reasons, I will be referencing the application being studied as "CASE_STUDY".
CASE_STUDY is a great tool for finding services near you. It is used worldwide and a lot of people use it in a day to day basis. Finding accounts on CASE_STUDY about specific people can reveal a lot of information about a person of interest. This will be our case study today because of the density of the code and the protections put in place.
Skills
- Bypassing SSL Pinning
- Debugging applications using Frida and making scripts for it
- Rooting Android emulators
- Identifying and understanding cryptography
Skills not covered
- Installation of the tools required
- Installation of the Android emulator
- Debugging/fixing own tools
Disclaimer
This document is provided solely for academic and educational purposes. All reverse engineering and analysis were conducted on a publicly available Android application binary, in accordance with applicable reverse engineering exemptions for research and interoperability.
No unauthorized access was attempted. No vulnerability was exploited. All sensitive information — including cryptographic keys, authentication tokens, endpoint identifiers, and service names — has been anonymized or redacted to prevent misuse. The purpose of this work is to demonstrate general techniques for understanding client-side integrity controls and cryptographic enforcement mechanisms.
The application studied is referred to generically as “CASE_STUDY” to respect intellectual property and contractual boundaries. This work does not claim affiliation with, endorsement by, or criticism of any vendor or service. Any resemblance to a real product is incidental to the technical nature of the research.
If any party believes this work unintentionally discloses proprietary or sensitive implementation details, the author welcomes private communication for resolution.
First steps, looking at the app
The app allows users to find their contacts in CASE_STUDY, which can be abused to check large quantities of email addresses. I already knew this, and this was the reason I decided to research this topic.
To start off, make sure to install an emulator of a well-used API level. My choice is usually API 31 because its mostly still maintained while keeping it old enough for most tools to work out of the box. You might want to update all software on the AVD (the emulator), including Play Services. This is because we will be using them for logging in.
Wanting to abuse email lookups, our usual suspect would be anything related to our contacts. We can explore until we find the options under "More" section. The app seems to vary from region to region, but the endpoint I found is located under More>Notifications>Find Friends>Find in your contacts.
Now that we have located what actions trigger a contact lookup, we will proceed to try and intercept the requests made by the app to see where its reaching and what data is passed to it.
Rooting the AVD and its reasons
Usually we would simply set up a proxy and use BurpSuite or Wireshark, but this is a bit more complicated than that because of SSL Pinning.
SSL pinning is a technique to prevent MITM attacks by binding a specific SSL/TLS certificate to a particular server or service. It is also notorious for being an excellent anti-analysis technique. Though, modern tools have advanced and we already have easy ways to bypass this.
We will be using HTTP Toolkit. But first, we need to root the Android device to allow HTTP Toolkit to take care of the SSL Pinning.
To root the Android device, its as simple as downloading the rootAVD repo and following the instructions. We might need to install additional updates for Magisk, which is automatically installed and setup by rootAVD.
This tool will take care of all the processes needed to root an Android AVD. After its done running, we would simply "Cold boot" the AVD from Android Studio device manager.
We now would go into the newly installed Magisk app and install/update the required software. Its an easy process, so Im going to skip writing that.
Bypassing SSL Pinning
We should now have a working rooted AVD and HTTP Toolkit installed. Our first step now is allowing all root access in a non-blocking way, which can mess up with HTTP Toolkit. To do this, simply go to the Magisk app > Settings, and turn off "User Authentication" which would display a code-blocking pop up. After that, select "Superuser Access" and select "ADB only" which will only allow our external software to run with root. This also helps to avoid detection with apps that might check for root access.
Once this is done, we should run HTTP Toolkit.
My usual choice is "Android App via Frida". We will be using this, but since its prone to fail in newer devices I will show a quick overview of its alternative.
Alternative to "Android App via Frida"
Select Android Device via ADB. The process is fairly simple and will also bypass SSL pinning. The downside of this, is that it captures all system network traffic and also installs a VPN app (used only as proxy). This might trigger detection or problems identifying our wanted requests.
Bypassing SSL pinning the easy way
Select "Android App via Frida". Select the device, and select the app. Done. Now, all web requests will be intercepted seamlessly.
Lets look at what we have. First, we want to only intercept traffic coming from the apps domain. So we put on the filter hostname$=CASE_STUDY.com
Much better now. Now, lets repeat the contact lookup process.
Assuming weve added an email address we want to look up to our contacts. Im using "test@gmail.com".
Before clicking "Find in your contacts", Ive cleared the traffic history. Now, we only have 2 requests to analyse.
The first one is made to the bunsen.CASE_STUDY.com
domain, containing these headers:
The next one is made to REDACTED-api.CASE_STUDY.com
, and looks like this:
I think its obvious now which one of these two is doing the lookup.
Analising test results
We have now found a weird looking request to CASE_STUDY which is in fact in charge of returning user data.
Lets analyse this from top to bottom, starting from the URL.
URL and GET parameters
- Protocol: HTTPS
- Domain:
REDACTED-api.CASE_STUDY.com
- URI:
/user/friend_finder_v2
Note: The following table contains my theories at this moment of the analysis, which by no means are yet fully correct.
Parameter | Theory | Observations | Value |
---|---|---|---|
time | Unix time in seconds | N/A | 1752058603 |
nonce | Some kind of code to use as salt | URL encoded and Base64 encoded | tMTU6Q%3D%3D |
ywsid | None, unknown | Repeats in different requests | TLD_YWSID |
device_type | Just a device type | Doesnt need much attention for now | google%2Bemulator64_x86_64_arm64%2FSE1A.211212.001.B1 |
app_version | App version | N/A | 25.26.0-28252600 |
cc | Country code | N/A | US |
lang | Language | N/A | en |
efs | Account information/authentication? | URL + Base64 encoded, but doesnt return a string | i8m%2F6CTloWKb%2BwCGwHUL%2BjZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv%2Bwgq99BiHlGG132XjtQZ06wAME80Z4%3D |
signature | Integrity check for the request | N/A | _8QSgm7K9sRY1jMTVm82KH4JXdW0%3D |
Headers
We see the payload is sent in x-www-form-urlencoded
format. The cookies are often irrelevant and can be skipped, which is the case too. The user agent is handled by the app too. The X headers are the following:
Header | Theory | Observations | Data |
---|---|---|---|
X-Auth-Token-2 | Session token | Used to authenticate requests | REDACTED |
X-Bunsen-MAM | Telemetry | JSON format | {"hardware_model":"sdk_gphone64_x86_64,Google,ranchu","local_timezone_offset":"+00:00","unique_view_id":"4aa74903-fe60-REDACTED-047ecf47eb80","previous_unique_view_id":"932b92ad-00ed-4ecf-8422-388f4254632f","user_advertising_id":"00000000-0000-0000-0000-000000000000","session_id":"30877930-REDACTED-c688fa959650"} |
X-Foregrounded | Are we running in foreground? | N/A | true |
X-Screen-Scale | Screen size | Maybe inches | 2.625 |
- The "X-Auth-Token" is assigned by the server, and can be gathered anytime by capturing a logged in request.
- The "X-Bunsen-MAM" looks like telemetry or tracking information. We need to find out how its crafted.
- The "X-Foregrounded" header seems straightforward, we just assume its always true.
- The same goes for the "X-Screen-Scale". There is no reason to investigate further.
Test conclusions
Weve figured how the requests work, but we need to find out how some of the parameters are crafted.
Here is a list of the data we need to gather:
- nonce
- ywsid
- efs
- signature
- X-Bunsen-MAM
Finding the code
Now that weve gathered some basic information, its time to find out how it all works from the inside. Reverse engineering experience as well as some Java and Android experience are recommended.
Now, we will be using JADX-GUI for reverse engineering the code. JADX works like Ghidra, IDA, Cutter, etc. but for Java and Android applications. Its a great choice because of its stability and ease of use. It also supports a variety of plugins which can really help with the tasks.
Before just opening the APK, we will first need to extract it or get a copy. There are a lot of ways to do it, but my preferred one is downloading it on my personal phone, sharing it through any file-sharing service and download it in my main workstation.
Once we have the APK file at hand, we can start getting our hands dirty.
Well fire up Jadx and load the APK file. It might take a while. After it finishes loading, I also recommend selecting Tools > Decompile all classes. This will also take a while, but will make things faster in the long run as well as fixing some decompilation errors. Once done, save it to a project file.
There is a lot of things to look at, so lets not get nervous. My usual approach is to find a string to look for in the code, then moving from there. A strong election would be the endpoint, which in this case is /user/friend_finder_v2
. We will keep removing characters from the beginning until we find what we want.
Which points here:
Fortunately for us, JADX renames variables and classes to a better name choosing automatically.
Lets dig into the FriendFinderRequest
class.
We got redirected to ApiV1NetworkingRequest
. Might be some issues with Jadx renamings, but lets stay here and read the code to see if we find something interesting.
Indeed, we see things that start making sense:
This is worth reverse engineering. Lets work a bit on the function to have a better understanding of it.
After some superficial work on the variables, it looks like this:
First of all, lets bookmark this class. Second, lets dig into the efs calculation.
Were passing CASE_STUDYQuery.d
to the URL encoder before adding it to the httpStrBuilder
, which will be used for making the request. It is not clear what this first variable is for, even by looking at the code:
So lets now dig into the CASE_STUDYQuery.a
function.
Defeating cryptography
It iterates through a map and is passed to APIUtil.a
before returning. Lets see what it is.
An overall explanation of what this function does is:
- Take a Map
- Create a 500 character string builder object
- Cast to string and append each element into the new string builder
- Encrypt the string.
We also have some logs that hint us what were looking to. We now know the EFS is encrypted, lets dig a bit more into this function. Remember to bookmark interesting functions.
Lets see what kind of encryption were dealing with. Lets see the line Cipher cipher = Cipher.getInstance(FileEncryptionUtil.CIPHER_TRANSFORMATION);
. If we follow the CIPHER_TRANSFORMATION
we will be directed into the encryption library.
So we know we are dealing we AES encryption, using CBC mode and PKCS5 padding. Also, were using UTF-8 encoding for the text. The IV is 16 bytes long.
Dealing with AES encryption, both the server and the client should know the encryption key. This leaves the code with 2 options:
- The server gives us the means to calculate the key and then use it at runtime (we assume not, since we have looked at the web requests already)
- The key is somewhere in the code.
Lets try and find the key.
After the efs string is crafted, its passed to the cipher object, which first requires additional initialization. Lets see the line:
aesCipher.init(1, new SecretKeySpec(new BigInteger(CASE_STUDYWebStrings.a(CASE_STUDYWebStrings.l), 16).toByteArray(), "AES"), new IvParameterSpec(new byte[16]));
Looking at the Java docs, we can see SecretKeySpec
accepts either 2 or 4 parameters. Since we are using 2, we know which overload is it.
The first parameter new BigInteger(CASE_STUDYWebStrings.a(CASE_STUDYWebStrings.l), 16).toByteArray()
is the key used. The second parameter is the algorithm.
There is a lot of processing done here, so lets make a step by step breakdown
CASE_STUDYWebStrings.a
method loads theCASE_STUDYWebStrings.l
variable.- The result is then converted into a big integer
- The result is then converted into a byte array.
Lets see what the CASE_STUDYWebStrings
class is for:
Might look bad, but don't get scared. We hit the sweet spot!
The code brings no obfuscation whatsoever, and this looks manually obfuscated. So this must be sensitive. This obfuscation is actually really easy to defeat and was probably implemented to avoid searching for this strings.
Just by looking at the variables they look like reversed base64 encoded strings. This is confirmed by the method CASE_STUDYWebStrings.a
which we will rename to "`decodeStr"
We can only assume that Base64Coder2.a
is actually what decodes base64, so lets rename that too.
Lets get back to what we were looking for: CASE_STUDYWebStrings.l
. Lets rename that to AES_KEY
. To see what the key is, we dont need any fancy coding. Lets just open CyberChef and do our thing there.
We first clean the string:
The decode it:
Which leaves us with the key: AES_KEY
.
The IV would now be an empty 16 byte array, so theres nothing really we should do.
So, after all the information we have on the EFS, we can craft a graph to understand it better:
Following this process in reverse will give us the original string. Lets try!
We got it!
Finishing the EFS
We still have to find out what those parameters are. Lets look for the string "y_device" in Jadx:
This one looks like a nice candidate, since we have a clear view of what it does:
This is the class DeviceInfo3
, which actually gives us plenty of information:
It gives us nice sources for the ywsid
, the device
, y_device
, etc. Lets bookmark it.
y_device
is assigned earlier in the code:
Lets see what this function does:
Its a very simple code snippet. The y_device
parameters is actually referenced as KEY_Y_DEVICE_ID
in the code. This function checks if its set somewhere in the app settings (we dont really care a lot about that part), but we see how its generated. Its just a random UUID which is prepended with a "y_".
Lets find out the device
parameter, which is also set earlier in the code:
This code is not very easy to understand if you dont have experience with Android:
What this does is get your unique Android ID. It then checks if something has gone wrong or the ID is specifically 9774d56d682e549c
, which is a magic number. This means it is not unique and there are more devices with this Android ID, such as emulators or privacy focused versions.
Knowing that its an Android ID, we know that the device
variable is simply a random 16 character string.
The code that executes if we have a magic ID is a fallback mechanism to set a new trackable ID for the installation, so we dont really care about it.
We now know how to generate an EFS from scratch.
Finding the ywsid
We have already found a way to generate an arbitrary EFS. We now need to find the rest of the parameters used in the signature calculation before proceeding, since they will be used in the process.
Lets continue digging into the previous code since we saw something about the ywsid
:
We can see it is assigned in the constructor:
Following XREFs, we can see its called by an upper class:
We can safely rename "str" to ywsid since its confirmed it is that. Again, lets do another XREF search for the constructor. We see there are two results:
Notice the second result calls an "invoke" method. This usually means its a synthetic class. Synthetic classes are "made up" by the Java/Kotlin compiler to make things such as lambdas, anonymous classes or other behind-the-scenes code structures work. This can result in unpredictable and confusing decompilation results, so we should avoid it unless its strictly necessary. Lets just use the first one.
The code seems pretty straightforward. Lets rename some of the variables first for readability. Notice I renamed CASE_STUDYWebStrings.b
to CASE_STUDYWebStrings.ywsid
, which could be easily deduced by the codeflow.
We know its in the CASE_STUDYWebStrings
class, lets take a look at that again.
The code shows us there are two possible values for the ywsid. We already know one of the options by the request weve captured, but lets see which criteria it needs to meet. After some work on the class, it looks like this:
After some research on the code, we find out that the ywsid
is actually static. It does try to check if we are in a subdomain, but the value is never changed so it will always use the TLD_YWSID
.
We conclude the ywsid
is: TLD_YWSID
Finding the time and nonce
These two we have seen them before in an earlier stage of our research. Lets get back to the ApiV1NetworkingRequest
class.
The time and nonce are very easy to make.
The time
parameter is the UNIX time in seconds, since we can see it takes milliseconds and divides it by 1000.
In the other hand, the nonce is just a base64 encoded random 4 byte value. Done too.
Defeating HMACs
We will now dig into calculating the signature. Staying in the same class as before, we can see where its made, right after the efs
:
We can see the signature is actually crafted in the try-catch statement. Lets work it out from there and rename variables accordingly.
We can see the signature is calculated here. Its an HMAC using SHA1 algorithm. Looking at the code, we can see how it was made:
Now we only need to find how the URI is made. The code is very dense and contains a lot of unknown information.
Logging information with Frida
If we were on a Windows or Linux based system, we would simply debug it using a tool such as x64dbg, and set breakpoints wherever we want. When working with Android applications, this is a bit different. This is why we will be using Frida for this task.
"Frida lets you inject snippets of JavaScript or your own library into native apps on Windows, macOS, GNU/Linux, iOS, watchOS, tvOS, Android, FreeBSD, and QNX."
Source: https://frida.re/docs/home/
We dont really know what is being passed to the HMAC calculation, so lets hook and print the contents of all the steps in the code. By logging StringBuilder
operations, we can see what is going on.
Before digging further, we need to keep in mind that when renaming variables inside of JADX, the names on the original APK havent been really changed. This is why we need to export the mappings from JADX to hook the specific code snippets. In this case, the class ApiV1NetworkingRequest
was originally com.CASE_STUDY.android.g11.e
. With this mappings, we can manually translate our reverse engineered code to the original names. To save the mappings, you can go in JADX and select File > Save mappings as... and select the one of our choosing. Usually, ProGuard is a good choice, since its widely used and human readable. To find the names, just open the mappings in a text editor and look for the renamed function. You will easily find the original name.
Here are the current project mappings:
com.CASE_STUDY.android.bp1.a -> com.CASE_STUDY.android.bp1.a:
java.lang.Object a -> subdomain
java.lang.String a() -> getFQDN
com.CASE_STUDY.android.et0.w -> com.CASE_STUDY.android.et0.HomeBodyComponentFactory:
com.CASE_STUDY.android.g11.e -> com.CASE_STUDY.android.g11.e:
com.CASE_STUDY.android.td1.e j -> CASE_STUDYQuery
com.CASE_STUDY.android.td1.a -> com.CASE_STUDY.android.td1.Base64Coder:
byte[] a(java.lang.String) -> decode
char[] b(byte[]) -> encode
com.CASE_STUDY.android.td1.e -> com.CASE_STUDY.android.td1.e:
java.security.SecureRandom g -> secureRandom
java.util.HashSet i -> deviceInfo
com.CASE_STUDY.android.td1.f -> com.CASE_STUDY.android.td1.f:
java.lang.String a -> REDACTEDapi
java.lang.String b -> ywsid
java.lang.String c -> hmacSecret
com.CASE_STUDY.android.bp1.a e -> errorLogInfo
java.lang.String f -> TLD_YWSID
java.lang.String g -> TLD_HMAC_SECRET
java.lang.String i -> SUBDOMAIN_YWSID
java.lang.String l -> AES_KEY
java.lang.String m -> BIZ_ACTIVITY_NAME
java.lang.String o -> packageName
java.lang.String a(java.lang.String) -> decodeStr
com.CASE_STUDY.android.x3.a -> com.CASE_STUDY.android.x3.Concatenate:
java.lang.String a(java.lang.String,java.lang.String) -> concatenate
com.CASE_STUDY.android.xo1.a -> com.CASE_STUDY.android.xo1.a:
java.lang.String a(java.util.Map) -> encryptEfs
com.CASE_STUDY.android.xt1.k -> com.CASE_STUDY.android.xt1.k:
void h(java.lang.Object,java.lang.String) -> checkParamIsSet
com.CASE_STUDY.android.yt.h -> com.CASE_STUDY.android.yt.h:
android.content.Context a -> context
java.lang.String b -> device
java.lang.String c -> y_device
android.content.SharedPreferences i -> sharedPreferences
android.content.SharedPreferences c() -> getSharedPreferences
java.lang.String d() -> getYDevice
Using this, we can craft the Frida script. It will look something like this:
Java.perform(function () {
const StringBuilder = Java.use("java.lang.StringBuilder");
const TargetClass = Java.use("com.CASE_STUDY.android.g11.e");
let isInSignRequest = false;
TargetClass.i.overload("java.lang.String").implementation = function (uri) {
console.log("\n=== [*] signRequest (i) called with: " + uri + " ===");
isInSignRequest = true;
const result = this.i(uri);
isInSignRequest = false;
console.log("=== [*] signRequest (i) finished ===\n");
return result;
};
function safeLog(prefix, value) {
if (!isInSignRequest) return;
if (typeof value === "string") {
const aesPattern = /\bAES\b|\baes-128-cbc\b|\bAES\/CBC\/PKCS5Padding\b/i;
if (aesPattern.test(value)) {
return;
}
}
console.log(prefix + value);
}
StringBuilder.append.overload("java.lang.String").implementation = function (
str
) {
safeLog("[StringBuilder.append(String)] -> ", str);
return this.append(str);
};
StringBuilder.append.overload("java.lang.Object").implementation = function (
obj
) {
safeLog("[StringBuilder.append(Object)] -> ", obj);
return this.append(obj);
};
StringBuilder.append.overload("char").implementation = function (c) {
safeLog("[StringBuilder.append(char)] -> ", c);
return this.append(c);
};
StringBuilder.toString.implementation = function () {
const result = this.toString();
safeLog("[StringBuilder.toString()] => ", result);
return result;
};
StringBuilder.$init.overload("int").implementation = function (capacity) {
safeLog("[StringBuilder constructor] capacity: ", capacity);
return this.$init(capacity);
};
});
console.log("StringBuilder request logging script loaded successfully.");
Now, we need to install the Frida server into the Android device. Given were using an AVD, were using the x86_64 architecture. We can find the appropriate server in the Frida release page with our architecture. After downloading it, we can upload it to the AVD using ADB.
adb push frida-server /data/local/tmp/.
Then get into a shell and set up the permissions:
adb shell
$ su
# chown root:root /data/local/tmp/frida-server
# chmod 0 /data/local/tmp/frida-server
# chmod +rx /data/local/tmp/frida-server
And execute it:
# /data/local/tmp/frida-server
It will produce no output. Dont worry about this. Now, we can start playing around with Frida. Make sure to have Frida installed in your system too.
Lets find our script and run it. Make sure to open up the app first in the AVD or else it wont work.
PS> frida -U -n CASE_STUDY -l .\logStringBuilderRequest.js
Now navigate normally to the found endpoint in the app, which will leave us with the following output:
=== [*] signRequest (i) called with: user/friend_finder_v2 ===
[StringBuilder constructor] capacity: 500
[StringBuilder constructor] capacity: 500
[StringBuilder.toString()] => device=c881fee26fb5f768&y_device=y_f8b6fc9c-f44f-4e50-b6b6-c5e0a5e67687
[StringBuilder constructor] capacity: 500
[StringBuilder.toString()] => device=c881fee26fb5f768&y_device=y_f8b6fc9c-f44f-4e50-b6b6-c5e0a5e67687
[StringBuilder.toString()] => ywsid=TLD_YWSID
[StringBuilder.toString()] => cc=US
[StringBuilder.toString()] => efs=i8m/6CTloWKb+wCGwHUL+jZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv+wgq99BiHlGG132XjtQZ06wAME80Z4=
[StringBuilder.toString()] => app_version=25.26.0-28252600
[StringBuilder.toString()] => device_type=google+emulator64_x86_64_arm64/SE1A.211212.001.B1
[StringBuilder.toString()] => time=1752158836
[StringBuilder.toString()] => lang=en
[StringBuilder.toString()] => nonce=3Z/0mQ==
[StringBuilder constructor] capacity: 270
[StringBuilder constructor] capacity: 21
[StringBuilder.toString()] => user/friend_finder_v2
[StringBuilder.toString()] => /user/friend_finder_v2app_version=25.26.0-28252600cc=USdevice_type=google+emulator64_x86_64_arm64/SE1A.211212.001.B1efs=i8m/6CTloWKb+wCGwHUL+jZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv+wgq99BiHlGG132XjtQZ06wAME80Z4=lang=ennonce=3Z/0mQ==time=1752158836ywsid=TLD_YWSID
[StringBuilder.toString()] => _Ld/qvLwSEblnHUxXyaTD2MDkVMQ=
[StringBuilder.toString()] => time=1752158836&nonce=3Z%2F0mQ%3D%3D&ywsid=TLD_YWSID&device_type=google%2Bemulator64_x86_64_arm64%2FSE1A.211212.001.B1&app_version=25.26.0-28252600&cc=US&lang=en&efs=i8m%2F6CTloWKb%2BwCGwHUL%2BjZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv%2Bwgq99BiHlGG132XjtQZ06wAME80Z4%3D&signature=_Ld%2FqvLwSEblnHUxXyaTD2MDkVMQ%3D
[StringBuilder.toString()] => https://REDACTED-api.CASE_STUDY.com/user/friend_finder_v2?time=1752158836&nonce=3Z%2F0mQ%3D%3D&ywsid=TLD_YWSID&device_type=google%2Bemulator64_x86_64_arm64%2FSE1A.211212.001.B1&app_version=25.26.0-28252600&cc=US&lang=en&efs=i8m%2F6CTloWKb%2BwCGwHUL%2BjZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv%2Bwgq99BiHlGG132XjtQZ06wAME80Z4%3D&signature=_Ld%2FqvLwSEblnHUxXyaTD2MDkVMQ%3D
=== [*] signRequest (i) finished ===
There is a lot of information that also verifies the other parameters.
Lets focus on these specific 3 lines:
[StringBuilder.toString()] => /user/friend_finder_v2app_version=25.26.0-28252600cc=USdevice_type=google+emulator64_x86_64_arm64/SE1A.211212.001.B1efs=i8m/6CTloWKb+wCGwHUL+jZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv+wgq99BiHlGG132XjtQZ06wAME80Z4=lang=ennonce=3Z/0mQ==time=1752158836ywsid=TLD_YWSID
[StringBuilder.toString()] => _Ld/qvLwSEblnHUxXyaTD2MDkVMQ=
[StringBuilder.toString()] => time=1752158836&nonce=3Z%2F0mQ%3D%3D&ywsid=TLD_YWSID&device_type=google%2Bemulator64_x86_64_arm64%2FSE1A.211212.001.B1&app_version=25.26.0-28252600&cc=US&lang=en&efs=i8m%2F6CTloWKb%2BwCGwHUL%2BjZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv%2Bwgq99BiHlGG132XjtQZ06wAME80Z4%3D&signature=_Ld%2FqvLwSEblnHUxXyaTD2MDkVMQ%3D
The first log is the string to be passed for the HMAC function. Notice there is no domain at the beginning or ampersands to separate the GET parameters. We now know what the content looks like before being signed.
The second parameter is the signature. It is prepended with an underscore and is Base64 encoded, which we already knew.
The last string is actually the signed URI, which contains ampersands and URL encoded parameters. This is because the first string is a copy of the last, and was processed specifically for the calculation of the signature.
Looking up, we see the parameters used:
[StringBuilder.toString()] => ywsid=TLD_YWSID
[StringBuilder.toString()] => cc=US
[StringBuilder.toString()] => efs=i8m/6CTloWKb+wCGwHUL+jZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv+wgq99BiHlGG132XjtQZ06wAME80Z4=
[StringBuilder.toString()] => app_version=25.26.0-28252600
[StringBuilder.toString()] => device_type=google+emulator64_x86_64_arm64/SE1A.211212.001.B1
[StringBuilder.toString()] => time=1752159053
[StringBuilder.toString()] => lang=en
[StringBuilder.toString()] => nonce=nK+rPA==
The previous lines were used for the EFS calculation as we saw earlier.
Matching this information with the code, we find out that the URL is not a string, but a dictionary that contains data from the URL for the signature. Before the URL is parsed to the signature function, it first sorts itself to then be casted to a string with NO ampersands to separate the GET parameters.
This is the final GET parameter to reverse engineer.
Finding the X-Bunsen-MAM headers
As usual, we will begin by searching the plain text of the header.
The second one matches the capitalization of the string, so lets choose it.
We see that the bunsen header is located in the state
object, which is an instance of the BizWebViewContract3.c
class. We infer the names of the properties and rename them:
Now, lets dig into the BizWebViewContract3.c
class:
This is the constructor, which shows that the bunsen headers are set through it. Lets now look for XREFs:
We see two results, but none of them seems to be the better option. After digging into them, I chose the first one which seemed a bit easier:
This code is actually very confusing as of now, so we should think of an alternative. A nice approach would be to search for the parameters of the bunsen JSON string individually, and go upwards from there. Lets search for the unique_view_id
parameter:
The second one looks way nicer, lets check:
If we cross reference this with the JSON contents of the saved request, we can see it has the same parameters. Lets break down the Bunsen header parameters.
Parameter | Description | Observations | Value |
---|---|---|---|
hardware_model | Type of phone | Can be reused | sdk_gphone64_x86_64,Google,ranchu |
local_timezone_offset | Timezone | Can be reused | +00:00 |
unique_view_id | 4aa74903-fe60-40aa-877f-047ecf47eb80 | ||
previous_unique_view_id | 932b92ad-00ed-4ecf-8422-388f4254632f | ||
user_advertising_id | Telemetry | Static | 00000000-0000-0000-0000-000000000000 |
session_id | 30877930-748a-4765-b836-c688fa959650 |
Lets find out how they are crafted.
Session ID
The session ID is passed in the line new Tuples("session_id", this.sessionId)
. Lets find what writes into this.sessionId
. The XREFs are the following:
Well use the last one because its the most clear one.
The code is pretty dense, well need to work on it first. After some work it gets like this:
If we track up, this.sessionId
is assigned the value of sessionId2
, which is assigned session2.sessionId
. If we track where session2
is made, we end up earlier in the code:
I could assign that method the name getCurrentSession
because of the type and use it was being given. If the method is used to get a value of type Session and then used later in the code, the origin method is probably a getter. Lets dive in real quick:
Even though I already renamed and worked out the code, it was very easy and quick to do it because of the strings gave me the hints necessary. Notice how the code can return null
if some time conditions are met. The code does not generate the session ID. Instead, it verifies it and returns it.
So we rename it and get back the caller code:
Heres another variable being assigned. Lets dig into the function:
Remember earlier in this post where I mentioned synthetic classes? Well, this is an example.
Even when the code is very simple, it has some differences with the usual class structure. For example, it does not bring a constructor like other classes do.
This time we got lucky it was just this simple code, which tells us the sessionId
is actually just a random UUID.
Unique view ID
To find the unique_view_id
and the previous_unique_view_id
we need to find how they are generated. From their names we can guess theyre made in the same part of the code. Lets get back to the Bunsen
class where the bunsen headers were made.
Lets XREF the unique view ID to see where it comes from:
Lets use the last one because it looks nicer:
Well, this was pretty straightforward. The bunsen.uniqueViewId
was assigned string
which was a assigned a random UUID.
From this we can guess that the previous_unique_view_id
is also a random UUID. There is no need to work harder on this because we already know what we wanted.
We wont be digging into the advertising ID because it is set to zeroes. I really doubt that was randomly generated, so if its static there is no need to change it.
Writing down the results
Now that we have reverse engineered everything we needed, we should write down the results of the analysis:
GET parameters
Parameter | Description | Done | Note | Example |
---|---|---|---|---|
time | Unix time | Yes | 1751368312 | |
nonce | Random 4 bytes | Yes | Base64 then URL encoded | 53NLTA%3D%3D |
ywsid | Some kind of ID | Yes | Static | TLD_YWSID |
device_type | Device name | N/A | App specific probably | google%2Bemulator64_x86_64_arm64%2FSE1A.211212.001.B1 |
app_version | Version | N/A | Should be latest | 25.26.0-28252600 |
cc | Country code | N/A | Just use whatever | US |
lang | Language | N/A | en | |
efs | Used in signature | Yes | URL and Base64 encoded | i8m%2F6CTloWKb%2BwCGwHUL%2BjZ2hunIyXWs0pSBHhCCmKxwCX23HempE4MaITvDog4oDvSnUKw1qRntv%2Bwgq99BiHlGG132XjtQZ06wAME80Z4%3D |
signature | URI checksum | Yes | URL and Base64 encoded (prepended with a "_") | _%2BGYw3yNYcgt%2FERficxDPX82c1HY%3D |
Notes
The EFS contents are composed of:
- The Android ID, which is a 16 character fingerprint of the Android device being used.
- Pseudo code:
toHex(random(16))
- Pseudo code:
- The
y_device
UUID value.- Pseudo code:
"y_" + randomUUID()
.
- Pseudo code:
- A raw, unencrypted EFS would look like this:
device=c881fee26fb5f768&y_device=y_f8b6fc9c-f44f-4e50-b6b6-c5e0a5e67687
Headers
Header | Description | Done | Mandatory | Note | Example | |||
---|---|---|---|---|---|---|---|---|
Accept-Encoding | N/A | N/A | Yes | Standard | gzip | |||
Connection | N/A | N/A | Yes | Standard | Keep-Alive | |||
Content-Length | N/A | N/A | Yes | Standard | 37 | |||
Content-Type | N/A | N/A | Yes | Standard | application/x-www-form-urlencoded | |||
Cookie | N/A | N/A | No | Set in the response | `datadome=6FjERKGvSDXIWZYpccgOiFCELelFkmHGFlNYqn9KLMmtLp2cKNxKFtrphtxeeOOWc9WFW8z_~Y0tK7ruN0pPq3YizT65A25ipkLXUF4jJCSVLfwqDHeCYafJ3wd1iLkn; bse=a039cb6a296e4b1f8485635ec6708206; wdi=2\ | 8CBA30BA6973AB68\ | 0x1.a1647e87e6877p+30\ | c306da7ca2fc4c4c` |
Host | N/A | N/A | Yes | Static | REDACTED-api.CASE_STUDY.com | |||
User-Agent | N/A | N/A | Yes | Custom | Version/1 CASE_STUDY/v25.26.0-28252600 Carrier/T-Mobile Model/emulator64_x86_64_arm64 OSBuild/SE1A.211212.001.B1 Android/12 | |||
X-Auth-Token-2 | Auth token assigned at login | No | Yes | Dynamic | REDACTED | |||
X-Bunsen-MAM | Advertising data | Yes | Yes | {"hardware_model":"sdk_gphone64_x86_64,Google,ranchu","local_timezone_offset":"+00:00","unique_view_id":"814404d6-7055-4668-bf10-7cf68834ab4e","previous_unique_view_id":"ccedb3ff-57ad-4a7f-8964-fd38020d4899","user_advertising_id":"00000000-0000-0000-0000-000000000000","session_id":"659eb376-9a71-4650-8290-09ff4b240bc1"} |
||||
X-Foregrounded | Is in foreground | N/A | Yes | Static | true | |||
X-Screen-Scale | Screen size | N/A | Yes | Static | 2.625 |
POST data
Key | Description | Done | Value |
---|---|---|---|
emails | Raw, comma separated | Yes | test@gmail.com |
ignored | Raw, static | Yes | false |
Example POST payload:
emails=test@gmail.com,test@yahoo.com&ignored=false
Wrapping up
We have reverse engineered all the code necessary to defeat integrity controls, including cryptography and telemetry checks.
By no means I encourage the misuse of this research. As previously stated in the disclaimer, this post serves only as an educational and research resource.