Sneaker Dev Logo
Back to Blog

PerimeterX SDK: Part 1, reverse engineering

September 3, 2025

obfio

Firstly, I want to introduce PerimeterX (PX) anti-bot. PX is a very common anti-bot that you will come across on many websites and their accompanying mobile apps. Common does not mean good though. PX...
PerimeterX SDK: Part 1, reverse engineeringThis article may have been republished from another source and might not have been originally written for this site.

⚠️ Some information, tools, or techniques discussed may have changed or evolved since the publishing of this article.

Originally published at https://antibot.blog/posts/1741549175263

Intro

Firstly, I want to introduce PerimeterX (PX) anti-bot. PX is a very common anti-bot that you will come across on many websites and their accompanying mobile apps. Common does not mean good though. PX on the web is a pretty decent anti-bot, however, their mobile SDK is known for being really bad. Most apps you come across that use PX will also be using an outdated version of PX, which doesn't help their case at all.

With that being said, PX is a great anti-bot to introduce beginners to how reverse engineering an anti-bot SDK would work, granted this is going to be a lot easier than a lot of other anti-bot SDKs.

As for the article itself, part 1 will be just reverse engineering the anti-bot, this includes my general setup for android reverse engineering and my thought process. Keep in mind, any tools I use that I didn't make will either have a hyperlink in the text or the References section. Part 2 will show how to take what you learned and put it into practice. Part 2 will include a working gen for this PX version, only being tested on this specific app. Part 2 will also include an explanation of how you test your API to ensure it's working and not just passing a few times.

Target

The app we will be targeting this time will be the GrubHub android app. I chose GrubHub because I happen to know that they don't use any other anti-bot alongside PX, most websites, like Walmart for example, will use multiple anti-bots on top of each other (like Akamai, ThreatMetrix, etc.). This doesn't make reverse engineering the target anti-bot any harder when they do this, however, it does make testing your solution, especially at scale, much more annoying and inconvenient.

As for the specific version of PX, we will be reverse engineering 2.2.3, which is outdated and pretty easy overall. GrubHub only uses px2, not px3, this is pretty rare nowadays so do keep that in mind.

Setup

When I'm reverse engineering an anti-bot SDK, I prefer to do Android apps, it's usually the easiest way because Android uses APKs instead of binaries. This means you can very easily decompile the code, which is usually not obfuscated. With IOS apps you typically have to put a lot more effort in and it typically nets you no real advantage over the former.

The setup I use is very easy to use and seems to be standard for Android reverse engineering. I choose to use NoxPlayer for my Android emulator, though you can use any Android emulator, or just your own Android phone if you wanted, I find it easier to use nox so I don't have to look at a phone IRL while trying to collect HTTP request logs. On that topic, I use Burp for my mitm proxy, a lot of people use charles but I prefer all the features included in the Burp free tier, like in-app atob (base64 -> text), request/response editing, brute force, etc. which makes it easier when reverse engineering big projects, tabbing in and out of your proxy intercept constantly to do little things gets old really fast. As for the APK decompiler I use, that would be JADX, it's pretty good for a free decompiler, however, it does poorly with kotlin decompilation so sometimes it can be annoying. I also use a combination of ADB and Frida to interact with the APK, seeing function inputs/outputs, changing those inputs/outputs, etc.

Setting all of this stuff up from scratch can feel like a large barrier to entry so I'll try to keep it as simple as possible. I'll show you how to set up your entire environment to get ready for the next part.

To start we will download everything we need, like Nox, ADB, Burp, and JADX. All these downloads can be found in the references section below.

Now open Burp, community edition won't allow you to make a project so go ahead and just click "Next" until it opens the actual app. Now you'll want to click the "Proxy" tab at the top, you'll notice 4 different options here, Intercept, HTTP history, WebSockets history, and Proxy settings. In this article, we will only be using HTTP history and Proxy settings. Now to setup the proxy correctly you'll want to click "Proxy settings", under the "Proxy listeners" section, you'll want to "Add" a new proxy. You'll notice it asks for a port, I use 8899 but it's not super important what you choose, just remember it. Now you'll want to choose the address, you should see a dropdown box, click it and select your internal IP. To get your internal IP, open cmd (Windows) then type the command "ipconfig", here listed you should see your internal IP, it will typically be seen in the last section of text and will say "IPv4 Address".

Great, now your proxy is set up, let's open Nox now. Initially, it may ask you to do something before you're able to open an emulator session, if so, just do whatever it says, I'd suggest to setup a v9 64bit emulator but it's not a big deal if it makes one for you and it's v7 x32. Now that you're in nox, first install the GrubHub mobile app in the google play store, alternatively you can download the APK from grubhub-food-delivery.en.uptodown.com/android, to add it to your emulation you'll just want to drag and drop the APK into the window. Now we want to set our emulator to use our burp proxy. Go to "Settings" -> "Network & internet" -> "Wi-Fi" -> whatever name is there -> then the edit button, it doesn't say edit for me, for me it's a pencil icon. It's worth noting that you may have to do something slightly different depending on which Android version you have. Now click the "Proxy" dropdown and select "Manual", from there just input your internal IP and the port you used in Burp (8899).

Now we can set up ADB and Frida, first we need to do ADB so we can get the information we need to download the correct Frida version. First, you should know, most emulators use their own port range that emulators use for ADB, for nox it's 62001 - 62999 I believe, that may sound like a large range but if this is your first Nox instance, you should be able to just use 62001. Anyways, first we will open a cmd where we installed ADB and type adb connect 127.0.0.1:62001 followed by adb shell then getprop ro.product.cpu.abi. The output is your CPU arch we will use when installing Frida, now we can go to github.com/frida/frida/releases. Here you should see many different versions of Frida but you'll need to get the one specifically for your CPU arch, you'll want to look for something like "frida-server-x.x.x-android-CPU_ARCH.xz". You'll want to extract this archive to the same place you have ADB installed. Now in your ADB shell, you'll want to type "cd data/local/tmp/". Now we want to open a new cmd and type adb push .\frida-server-x.x.x-android-CPU_ARCH /data/local/tmp/frida-server. Back in our ADB shell, we run chmod 777 frida-server followed by ./frida-server &.

Now we have ADB setup, Frida setup, and our proxy setup. To use frida you'll want to use Python's pip to run the command pip install frida-tools. After that is installed you can make a JavaScript file, let's say "test.js", then run the command firda -U -l test.js -f com.grubhub.android. Those commands should be run in your Windows cmd, not your adb shell.

SSL Pinning

So, how do you unpin it? Well, first I like to just see what HTTP package it's using, so let's do that. Your approach will be different once you've done this a lot. For this case, I'll show a pretty reliable way to go about it. The anti-bot we're targeting is PX, so let's search the code for perimeterx.net just to see if we can find where the pin is being added. You should notice this code:

1this.f73783a = dVar.a("*.perimeterx.net", "sha256/V5L96iSCz0XLFgvKi7YVo6M4SIkOP9zSkDjZ0EoU6b8=");

If you look at the code behind dVar.a() you'll see this:

1public final ww0.a a(String host, String sslPin) {
2    Intrinsics.checkNotNullParameter(host, "host");
3    Intrinsics.checkNotNullParameter(sslPin, "sslPin");
4    OkHttpClient.Builder builder = new OkHttpClient.Builder();
5    builder.certificatePinner(new CertificatePinner.Builder().add(host, sslPin).build());
6    return ww0.c.a(ax0.a.f6894a, new a(builder.build()));
7}

Now we know PX is using okhttp3 and specifically the CertificatePinner method. This is good, so we should just be able to hook "okhttp3.CertificatePinner.add" and bypass the pinning.

1Java.perform(function() {
2    let okhttp3Pin = Java.use("okhttp3.CertificatePinner$Builder")
3    okhttp3Pin["add"].implementation = function(pattern, pins) {
4        return this
5    }
6})

So we just return this without actually adding the pins. Let's run our Frida script with "frida -U -l test.js -f com.grubhub.android" and see what happens. You should notice that it changed nothing, pretty odd considering we followed a pretty logical line of thinking to get here. What's going on here is that we only bypassed the pinning for PX, not for whatever requests are happening before the PX init code is hit.

After some time, I realized that the actual GrubHub requests were not causing PX to not init, instead, it has to be one of the other packages they use. What must be happening is that one of the other packages GrubHub is using that loads before PX has a different method of pinning and GrubHub isn't handling the exception very well.

I don't intend to look into each SDK to figure out what's being pinned, instead, I know that by default Android 7+ will use "com.android.org.conscrypt" for pinning, specifically it uses "com.android.org.conscrypt.TrustManagerImpl.verifyChain". If we hook this method and make it return the "untrustedChain", it should bypass that method of pinning completely, which is used by plenty of SDKs.

When we add this code:

1var TrustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl');
2TrustManagerImpl.verifyChain.implementation = function (untrustedChain, trustAnchorChain, host, clientAuth, ocspData, tlsSctData) {
3    return untrustedChain;
4};

We should see requests showing up in your burp HTTP logs.

Payloads

Now that we have the SSL pinning defeated, we can start looking into the anti-bot. First, make sure you force shutdown the app and clear the cache + storage. I always make sure to do this when reverse engineering any app because most apps will put certain things in the cache/storage, anti-bots typically put information about previous sessions in the storage.

Alright first things first, let's launch our app and attempt to signup with a random email so we can see how the anti-bot is used in the requests. When we do that we should see one of the first requests is to px-conf.perimeterx.net, so we could assume that in the future the anti-bot will be using *.perimeterx.net as its API domain. Now let's look at this request, it's made clear by the subdomain that it's getting some sort of config. My logs show the response body:

1{
2    "enabled":true,
3    "ipv6":false
4}

and the request body:

1{
2    "device_os_version": "9",
3    "device_os_name": "Android",
4    "device_model": "SM-G988N",
5    "sdk_version": "v2.2.3",
6    "app_version": "2023.25",
7    "app_id": "PXO97ybH4J"
8}

Let's look for where this payload is created. In our SDK let's search for "device_os_version", we will see a few results but we know the payload is JSON, so we should be looking for something JSON related. We will find:

1jSONObject.putOpt(j.DEVICE_OS_VERSION.a(), bVar3.f10771h);

The method it's contained in does mention px-conf so we can assume we're in the right place. So let's look at all the keys being added and reverse engineer how they get these values.

First off, you'll notice bVar3.f10771h isn't exactly the most helpful thing in the world, so let's figure out what that actually is. In JADX you can right-click variables and click Find Usage to find where this variable is being used, you'll see only 1 place where it's setting a value, so let's look there:

1this.f10771h = osVersion;

We see osVersion is just an input to this constructor, so let's use that Find Usage feature again, this time we will do it on the constructor instead of a variable. You can only see 1 result so it's clear this is where we should be:

1return new bv0.b(androidId, i13, i14, str3, z13, aVar3, num, osVersion, i12, str2, deviceModel, deviceName, deviceManufacturer, deviceFingerprint, deviceBoard, deviceBootloader, deviceBrand, deviceDisplay, deviceHardware, j12, deviceUser, hasSystemFeature, hasSystemFeature2, hasSystemFeature3, hasSystemFeature4, hasSystemFeature5, hasSystemFeature6, hasSystemFeature7, aVar2, locale, currentTimeMillis, b12, contains$default);

Here we see where osVersion is input, now we just go to where the variable is initialized and we have our answer. So device_os_version is Build.VERSION.RELEASE, in this case, it's "9". Now next is device_os_name, you'll notice it's j.ANDROID.a(), after reading the a() method, we will see that this value is static. Getting device_model will take you thru the same steps as device_os_version, device_model = Build.MODEL.

If we repeat our steps for sdk_version, we will see the origin of the sdkVersion variable is the result of this function com.perimeterx.mobile_sdk.PerimeterX.sdkVersion(), which just returns a static string. You can do the same for app_version, app_version = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName. app_id is pretty obviously static, considering it's named app_id and had no previous inputs from the backend, it's safe to say that's just static. Honestly, you could've assumed the same way for sdk_version.

So that's our first request, pretty simple, just fetching a config. For now, I won't be touching on what that config does.

Let's look for the next *.perimeterx.net request. We will see a request to https://collector-pxo97ybh4j.perimeterx.net/api/v1/collector/mobile with this body:

1payload=W3sidCI6IlBYMzE1IiwiZCI6eyJQWDEyMTQiOiI0MzBiYjRiNjFhMTk0YjMyIiwiUFg5MSI6OTAwLCJQWDkyIjoxNjAwLCJQWDMxNiI6dHJ1ZSwiUFgzMTgiOiIyOCIsIlBYMzE5IjoiNC4xOS4xMTAiLCJQWDMyMCI6IlNNLUc5ODhOIiwiUFgzMzkiOiJzYW1zdW5nIiwiUFgzMjEiOiJ6M3EiLCJQWDMyMyI6MTY4OTQwNTAwNiwiUFgzMjIiOiJBbmRyb2lkIiwiUFgzMzciOnRydWUsIlBYMzM2Ijp0cnVlLCJQWDMzNSI6dHJ1ZSwiUFgzMzQiOnRydWUsIlBYMzMzIjp0cnVlLCJQWDMzMSI6dHJ1ZSwiUFgzMzIiOnRydWUsIlBYNDIxIjoiZmFsc2UiLCJQWDQ0MiI6ImZhbHNlIiwiUFgzMTciOiJXaUZpIiwiUFgzNDQiOiJULU1vYmlsZSIsIlBYMzQ3IjoiW2VuX1VTXSIsIlBYMzQzIjoiVW5rbm93biIsIlBYNDE1Ijo5NCwiUFg0MTMiOiJnb29kIiwiUFg0MTYiOiIiLCJQWDQxNCI6Im5vdCBjaGFyZ2luZyIsIlBYNDE5IjoiYnYwLmFAMjVmMjQ0OSIsIlBYNDE4IjozNC43LCJQWDQyMCI6NC4wMiwiUFgzNDAiOiJ2Mi4yLjMiLCJQWDM0MiI6IjIwMjMuMjUiLCJQWDM0MSI6IlwiR3J1Ymh1YlwiIiwiUFgzNDgiOiJjb20uZ3J1Ymh1Yi5hbmRyb2lkIiwiUFgxMTU5IjpmYWxzZSwiUFgzMzAiOiJuZXdfc2Vzc2lvbiIsIlBYMzQ1IjowLCJQWDM1MSI6MSwiUFgzMjYiOiIwMDAwMDE4OS01ODYyLWEzYmMtMDAwMC0wMDAwMDAwMDAwMDEiLCJQWDMyNyI6IjAwMDAwMTg5IiwiUFgzMjgiOiIwNzU4NEY2MkI1MTM2NDZBQjZDMzFGMjEyMUI4MDBBOTU1QkMyQkQzIiwiUFgxMjA4IjoiW10ifX1d&uuid=a66b97de-bf63-42a2-9746-4a8a8f228345&appId=PXO97ybH4J&tag=mobile&ftag=22

This is where the burp atob feature comes in handy, when you highlight the payload key, you'll see it's just base64. Very nice, we won't need to deal with encrypted payloads and whatnot, now let's go ahead and dig into this payload.

So first we will be able to see how the PX payloads normally look, this is the same for web and mobile PX. We can see it has t and d, d clearly holds data, and t is pretty likely to be an identifier of some sort. I'll use hindsight to say, yes, t is just telling PX which type of payload you're sending, in this case, you're sending an init payload to start a session. Reverse engineering the data is a lot easier than it may seem at first.

First, we will search for the string PX1214, this is the first key in the data. We're doing this to try and see where the payload is being created, there is only 1 result from this search and it is exactly what we want. So we see it's being set to bVar3.f10764a. Refer back to the px-conf section to get this value, you just follow the usage flow and you'll eventually reach Settings.Secure.getString(context.getContentResolver(), "android_id"), so PX1214 is the phone's android_id. I'll save us all the time and say you can do that for the next few values, so here's that:

1PX91 = displayMetrics.widthPixels // phone width
2PX92 = displayMetrics.heightPixels // phone height
3PX316 = ((TelephonyManager) systemService2).getSimState() != 1 // (do you have a sim card)
4PX318 = Build.VERSION.SDK_INT.toString() // your OS SDK version
5PX319 = System.getProperty("os.version") // your os version, x.x.x
6PX320 = Build.MODEL // phone model, SM-G988N
7PX339 = Build.MANUFACTURER // phone manufacturer, samsung 
8PX321 = Build.DEVICE // eg: z3q
9PX323 = System.currentTimeMillis() / 1000 // current time in seconds
10PX322 = ClickstreamConstants.OS // Constant string "Android"
11PX337 = context.getPackageManager().hasSystemFeature("android.hardware.location.gps") // Does your phone have GPS?
12PX336 = context.getPackageManager().hasSystemFeature("android.hardware.sensor.gyroscope") // Does your phone have gyrospocic capabilities?
13PX335 = context.getPackageManager().hasSystemFeature("android.hardware.sensor.accelerometer") // Does your phone have an accelerometer?
14PX334 = Build.VERSION.SDK_INT >= 24 ? context.getPackageManager().hasSystemFeature("android.hardware.ethernet") : false // This is conditional, so if your SDK version is >= 24, does your phone have ethernet?
15PX333 = context.getPackageManager().hasSystemFeature("android.hardware.touchscreen") // Does your phone have a touchscreen?
16PX331 = context.getPackageManager().hasSystemFeature("android.hardware.nfc") // Does your phone have NCF? (Near Field Communication)
17PX332 = context.getPackageManager().hasSystemFeature("android.hardware.wifi") // Does your phone have WiFi? Probably just checking if you have a WiFi chip.

It's not until we reach PX421 that we finally aren't able to do that as easily, here we have to really read code. So we can follow the usage path to get to boolean b12 = b();, now we know that function is giving us the bool we need.

1public final boolean b() {
2    boolean z12;
3    d dVar = new d();
4    h hVar = h.f79591a;
5    String[] strArr = h.f79593c;
6    int length = strArr.length;
7    int i12 = 0;
8    while (true) {
9        if (i12 >= length) {
10            z12 = false;
11            break;
12        } else if (new File(strArr[i12]).exists()) {
13            z12 = true;
14            break;
15        } else {
16            i12++;
17        }
18    }
19    return z12 || dVar.a();
20}
21

Looking at the function we see it's using new d(), h.f79591a, and h.f79593c. We can see hVar isn't even used, so we can ignore that. Looking at where f79593c is defined we will see it's actually just:

1new String[]{"/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su", "/su/bin/su"}

We can just replace that to make it easier to read. Now knowing that it's easier to see what the function does with that, it's checking if all of those files exist or not, if not, z12 will be false. If z12 is false it will return dVar.a(), so lets see that code:

1public final boolean a() {
2    Process process;
3    boolean z12 = false;
4    try {
5        process = Runtime.getRuntime().exec("/system/xbin/which su");
6        try {
7            if (new BufferedReader(new InputStreamReader(process.getInputStream())).readLine() != null) {
8                z12 = true;
9            }
10        } catch (Throwable unused) {
11        }
12    } catch (Throwable unused2) {
13        process = null;
14    }
15    process.destroy();
16    return z12;
17}

Now this isn't super obvious at first glance, when you read the code you see it's executing the command /system/xbin/which su. The code then tries to read the output of that command, if it's not null, it returns true, otherwise it's false. So it's just checking if /system/xbin/which su returns a valid output. It's easier to understand what PX421 actually is now, it's checking if either an array of files exists on the device or if this command works. Granted you don't really know why it's checking for that but let's just assume false is the output PX wants since that's what we get. If you're really curious, this code checks to see if you have superuser installed on your device, superuser is something that lets you root your phone, so I guess this is their really bad way of checking for a rooted phone.

Moving on to PX442, we can go back to basics and follow the uses. We will see this value is just checking if Build.TAGS contains test-keys, I'd assume this is another secondary root check. I don't actually know the point of this though so I could be wrong.

Now PX317 is quite tricky, since this is written in kotlin and not java, the decompiler can fail to decompile some segments of code. For this key, we will switch to the Simple view instead of the Code view in JADX. Now we see:

1L40:
2    jSONObject3.putOpt("PX442", jVar2.a());
3    lv0.a aVar2 = bVar3.f10769f;
4    if (aVar2 == null) goto L241;
5    int ordinal = aVar2.ordinal();
6    if (ordinal != 0) goto L45;
7    j jVar3 = j.WIFI;
8L48:
9    if (jSONObject3.putOpt("PX317", jVar3.a()) == null) goto L241;

It's more annoying to read but we can see generally what it's doing, so first let's see what f10769f is.

Eventually, you will see this code:

1int i12 = Build.VERSION.SDK_INT;
2if (i12 >= 23) {
3    Network activeNetwork = connectivityManager.getActiveNetwork();
4    if (activeNetwork != null) {
5        NetworkCapabilities networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork);
6        if (networkCapabilities != null) {
7            if (networkCapabilities.hasTransport(1)) {
8                aVar = lv0.a.WIFI;
9            } else if (networkCapabilities.hasTransport(0)) {
10                aVar = lv0.a.CELLULAR;
11            }
12        }
13    }
14    aVar = null;
15} else {
16    NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
17    if (networkInfo != null) {
18        int type = networkInfo.getType();
19        if (type == 0) {
20            aVar = lv0.a.CELLULAR;
21        } else if (type == 1) {
22            aVar = lv0.a.WIFI;
23        }
24    }
25    aVar = null;
26}

All and all, you don't really need to even read this code to understand what's generally going on. It's checking your network, if you have cellular then the output should be 0, if you have wifi, it's 1, else it will be null. So now we know f10769f is just an enum being 0, 1, or null. Now we can see j.WIFI is "WiFi" and j.CELLULAR is AnalyticsEvent.EVENT_TYPE_MOBILE aka "Mobile". If it's null, it uses "NA".

The next 2 values you should be able to get using the same methods shown previously:

1PX344 = ((TelephonyManager) systemService).getNetworkOperatorName(); // your network operator, for example, T-Mobile
2PX347 = Locale.getDefault().toString(); // your locale language, for example, [en_US]

Now PX343 is a pretty similar case to PX317. We can see the possible outputs are Unknown, 2G, 3G, 4G, or 5G. Using this we can assume it's just outputting your network connectivity information, aka how many bars you have(if any). If you wanted, you could spend unneeded time confirming this assumption, in this case, I don't find it necessary.

PX415 is just registerReceiver.getIntExtra("level", 0); which basically just means your current battery level. You can tell it's referring to battery level because registerReceiver is made with this intent: "android.intent.action.BATTERY_CHANGED".

For the next few keys, we will need to look at some code, starting with PX413. We see this code is how the value is set:

1switch (aVar3.f10757a) {
2    case 1:
3        jVar2 = j.BATTERY_HEALTH_UNKNOWN;
4        break;
5    case 2:
6        jVar2 = j.BATTERY_HEALTH_GOOD;
7        break;
8    case 3:
9        jVar2 = j.BATTERY_HEALTH_OVERHEAT;
10        break;
11    case 4:
12        jVar2 = j.BATTERY_HEALTH_DEAD;
13        break;
14    case 5:
15        jVar2 = j.BATTERY_HEALTH_OVER_VOLTAGE;
16        break;
17    case 6:
18        jVar2 = j.BATTERY_HEALTH_UNSPECIFIED_FAILURE;
19        break;
20    case 7:
21        jVar2 = j.BATTERY_HEALTH_COLD;
22        break;
23    default:
24        jVar2 = j.BATTERY_HEALTH_MISSING;
25        break;
26}

This is pretty simple, using the methods shown before, you can easily see f10757a is registerReceiver.getIntExtra(IntegrityManager.INTEGRITY_TYPE_HEALTH, 0);. You can tell this is just checking the state of your battery health. We can see all the possible outputs are empty string, good, overheat, dead, over voltage, unspecified failure, and cold.

PX416 is similar except it returns the status of your phone's plug. All possible values are Wireless, USB, AC, and an empty string.

PX414 is also similar except with information on your battery status. All possible options are full, not charging, discharging, charging, and an empty string.

PX419 is different, this value is only set if registerReceiver.getExtras().getString("technology") is not null. The value however also looks pretty odd. You should pretty quickly realize that bv0.a is actually the class where the payload itself is built. @ed6028e is @. Realistically you could just come to that assumption but you can also tell it's not static by getting 2 payload samples and comparing them. That bv0.a will still be there but ed6028e will be different. In case anyone is confused, the value that's being checked is the battery "technology", I'd assume this is some sort of brand name or something like that, the docs aren't super helpful with this specific key lol.

These 2 values you should be able to get using the steps shown previously.

1PX418 = registerReceiver.getIntExtra("temperature", 0); // battery temp
2PX420 = registerReceiver.getIntExtra("voltage", 0); // battery voltage

These 4 values are static per app.

1PX340 = 'v' + sdkVerson; // PX version
2PX342 = appVersion; // GrubHub app version
3PX341 = appName; // GrubHub app name
4PX348 = packageName; // GrubHub package name, aka com.grubhub.android

PX1159 is related to the previous 4. This value is InstantApps.getPackageManagerCompat(context).isInstantApp(); which I would assume is just checking if the app is a Google Play instant app. I didn't know this even existed but you don't need to think very much about it, it's likely gonna be static per app unless the app in question randomly decides to switch which wouldn't be common but who knows.

Now we get into the PX state controller. PX has a state in the "shared preferences", which is just storage that persists through an app being closed and reopened. This tracks if you've opened the app before and stuff like that, ideally when making an API you will be getting fresh cookies (in this case a header) every time a client requests one, instead of trying to reuse on your API's end. With that in mind, we should assume this PX330 value should always be new_session on the first request.

Now looking at where PX345 is set, we see the value is f31243b. Now to understand what this value does, we need to look at usage and use context clues to follow what's happening. We should see this code:

1jv0.b key = jv0.b.RESUME_COUNTER;
2String appId = session.f57837a;
3SharedPreferences a13 = aVar2.a(appId);
4Integer valueOf = a13 != null ? Integer.valueOf(a13.getInt(key.a(), 0)) : null;
5int intValue = valueOf != null ? valueOf.intValue() : 0;

This isn't very complex code, first, we see key is resume_counter, which means we're looking for resume_counter in a13. If we look into what aVar2.a() is doing, we see it's getting our shared preferences for the PX app ID. We then later see this code:

1int i13 = intValue + 1;
2String appId2 = session.f57837a;
3SharedPreferences a14 = aVar2.a(appId2);
4if (a14 != null) {
5    SharedPreferences.Editor edit = a14.edit();
6    edit.putInt(key.a(), i13);
7    edit.apply();
8}

This is also super simple, it's just increasing our output value by 1, then setting resume_counter to that number in the shared preferences, aka the storage. All this would lead you to assume that this value is the number of times this value has been fetched, another way to say this is basically how many requests have been made in this state controller. It's 0 because we cleared the storage before opening.

PX351 you see is (int) (new Date().getTime() - cv0.a.f31241a) and now you just need to see what cv0.a.f31241a means. Using the methods shown before, we see it's set to new Date().getTime();. So this is the time between now and when that variable was initiated. I'm gonna assume it's 0 because we first used the variable when making this payload.

NOTE: I was pretty confused at first when looking at those two values because a lot of times my first payload and second payload would have the same value both times. Usually always just 0 on both requests which logically didn't make sense at first. I think I know what's going on now though, I assume that their code just generates the second payload without refreshing these values. Probably a bug or something idk for sure. This however means that PX345 appears to not increase when you make your second request, leaving its internal counter at 1 after making 2 requests. This will be important to note when we make our API in part 2.

The next keys, PX326, PX327, and PX328 all come from the same code. We see they all use cVar which is a variable passed into this function, this value is also a type of bv0.c, so let's check that out. Going there uncovers this code:

1public c(String uniqueString, String deviceModel) {
2    List split$default;
3    this.f10789a = uniqueString;
4    split$default = StringsKt__StringsKt.split$default((CharSequence) uniqueString, new String[]{"-"}, false, 0, 6, (Object) null);
5    Locale locale = Locale.ROOT;
6    String upperCase = ((String) split$default.get(0)).toUpperCase(locale);
7    String valueOf = String.valueOf(upperCase);
8    this.f10790b = valueOf;
9    iv0.c cVar = iv0.c.f46778a;
10    String upperCase2 = cVar.a(deviceModel + uniqueString + valueOf).toUpperCase(locale);
11    this.f10791c = upperCase2;
12}

Right away we see 2 inputs that decide the output, let's go see the uses of this constructor to see what's being passed in. Technically there are 3 uses of this function but they're all pretty much identical. Right away we see the first input is a UUID, specifically made with this code new UUID(currentTimeMillis, 1L).toString();. The second param is cVar.f82492a.f10774k, I can already guess this will probably just be the device modal but if you are paying close attention, this f10774k value was used in the px-conf request, its value is Build.MODEL.

So with all that information, figuring out what this does isn't too hard. First, it will set f10789a aka PX326 to the input UUID aka uniqueString. Next it sets split$default to basically the javascript equivalent of uniqueString.split("-"). Next, we get the first index of the split$default list and make it all uppercase. Then that value is used by String.valueOf to get the f10790b value, aka PX327. So in javascript that would be var PX327 = uniqueString.split("-")[0]. Next we use deviceModel, uniqueString (UUID), and valueOf for a parameter in cVar.a(), lets see this cVar.a() code.

1public final String a(String input) {
2    MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
3    byte[] bytes = input.getBytes(Charsets.UTF_8);
4    byte[] bytes2 = messageDigest.digest(bytes);
5    StringBuilder sb2 = new StringBuilder(bytes2.length * 2);
6    for (byte b12 : bytes2) {
7        sb2.append("0123456789abcdef".charAt((b12 >> 4) & 15));
8        sb2.append("0123456789abcdef".charAt(b12 & 15));
9    }
10    String sb3 = sb2.toString();
11    return sb3;
12}

So this function is pretty weird, however, it seems to just be a simple hashing function. So first we SHA-1 encrypt the input string, which again is deviceModel + uniqueString + valueOf. After that, we get the bytes of the original input and the bytes of the SHA-1 hash, the SHA-1 bytes are then turned into a string sb2. Next, we see it will range over each byte in the SHA-1 hash, doing some pretty basic math on the bytes to get a character in 0123456789abcdef to add to the SHA-1 string sb2. After that, we just return sb2.toString(). Now we take that output, uppercase it, and now we have the value of f10791c aka PX328.

Now we just have PX1208, this value is pretty tricky also. This is the string value of an array that's passed into the payload building function. This array seems to be all the previous URLs requested in the session. Since this is a new session, this should always be "[]" I think. On the second payload, it's also empty though, so I'm not sure, I assume it should just always be "[]" when trying to register a new cookie on this specific app/version of PX.

Finally, the last key. Let's look back at the request body because, if you remember, the payload was not the only thing being sent. We also sent uuid, appId, tag, and ftag. We've already looked into appId, so let's look into the other 3.

Let's look for "22" in the function that builds the payload. Doing this will bring us to this code:

1JSONObject json = new JSONObject();
2json.putOpt(j.PAYLOAD.a(), dataBase64);
3json.putOpt(j.UUID.a(), str);
4json.putOpt(j.APPID.a(), bVar.f57837a);
5json.putOpt(j.TAG.a(), "mobile");
6json.putOpt(j.FTAG.a(), "22");

Now we can ignore the second and fourth lines, and now that we see tag and ftag are static, we can ignore those too. Let's see what uuid is. So str is a string input when calling this function, by tracking that, we can see that uuid is UUID.randomUUID().toString();. This uuid is put into the session manager for future use in the next request(s) as well.

Finally, we're done with that request.

Now we do have 1 more request to look at, that request gives us the _px2 cookie/header as a response so let's see what's different in this request.

Immediately you should notice that the first and second payloads have quite a lot of duplicate keys, so let's ignore those keys and only focus on the ones that aren't shared.

There are only 3 keys that aren't shared, that would be PX259, PX256, and PX257. The first two keys are much easier than the last one, so let's start with those. Let's look at PX259 first, that key is set to aVar.f60611a. Let's go thru the usage to see where it's being set, we end up at this a constructor, so let's go thru the usages until we find something useful. Eventually, we end up here:

1public b(JSONObject response) {
2    List<String> split$default;
3    a a12;
4    this.f60621c = new ArrayList<>();
5    Object obj = response.get("do");
6    JSONArray jSONArray = (JSONArray) obj;
7    int length = jSONArray.length();
8    for (int i12 = 0; i12 < length; i12++) {
9        Object obj2 = jSONArray.get(i12);
10        split$default = StringsKt__StringsKt.split$default((CharSequence) ((String) obj2), new String[]{"|"}, false, 0, 6, (Object) null);
11        if (split$default.size() > 1) {
12            String str = split$default.get(0);
13            String str2 = split$default.get(1);
14            int hashCode = str.hashCode();
15            if (hashCode != 113870) {
16                if (hashCode != 116753) {
17                    if (hashCode != 3000930) {
18                        if (hashCode == 3016153 && str.equals("bake") && split$default.size() > 5) {
19                            this.f60621c.add(new c(split$default.get(1), split$default.get(3), Integer.parseInt(split$default.get(5))));
20                        }
21                    } else if (str.equals("appc") && (a12 = a.f60610i.a(split$default)) != null) {
22                        this.f60622d = a12;
23                    }
24                } else if (str.equals("vid")) {
25                    this.f60620b = str2;
26                }
27            } else if (str.equals("sid")) {
28                this.f60619a = str2;
29            }
30        }
31    }
32}

This code can look complicated at first but believe me, it's not complicated. So what we notice is it's getting do from the response input. Let's look at the response to the previous request we reverse-engineered:

1{
2    "do": [
3        "sid|b23a538a-1e24-11ee-8b28-4a495456456d", 
4        "vid|b23a467b-1e24-11ee-8b28-2cb452de27a1|31536000|false", 
5        "appc|1|1688967166574|82bdf735e01cc1fd89afe73c1a04aa2d9070aed0a2d92ff800ee1e33ed2230d1|2a92d760-1ee3-11ee-9486-7d6cfd79bd83", 
6        "appc|2|1688967166574|42443399873c02eadc3ebd747567101f265e835563d18725f73ec88b7012eb8f,42443399873c02eadc3ebd747567101f265e835563d18725f73ec88b7012eb8f|374|3542|3311|1478|3717|352"
7    ]
8}

So do is just an array of instructions it seems, you can see sid and vid just set a value, super simple. appc calls a.f60610i.a which checks if the second index isn't "2", if it's not, you get to where f60611a is set. So let's see, the first instance of apcc says 1, so that won't be used to set the values. The second instance says 2, so that must be what's being used to set all these values. So we set f60611a to the third index, aka 1688967166574 which we can see in the payload itself. Then we set f60612b to the fourth index, which we can also see in the payload itself. Then we set all these next 6 values to the 6 numbers in the appc instruction. Anyways, that's how PX259 and PX256 are set.

PX257 is more complicated than the last 2 keys. This value is basically a proof of work(POW) function using those 6 numbers. So let's rename those 6 variables to appcNumber1 - appcNumber6 to make it easier to read, to do this just right-click the variables and use the rename feature.

First we should see int a13 = aVar2.a(aVar2.a(aVar2.appcNumber3, aVar2.appcNumber4, aVar2.appcNumber1, aVar2.appcNumber6), aVar2.appcNumber5, aVar2.appcNumber2, aVar2.appcNumber6);. That aVar2.a() function is quite simple, here's the code:

1public final int a(int i12, int i13, int i14, int i15) {
2    int i16 = i15 % 10;
3    int i17 = i16 != 0 ? i14 % i16 : i14 % 10;
4    int i18 = i12 * i12;
5    int i19 = i13 * i13;
6    switch (i17) {
7        case 0:
8            return i13 + i18;
9        case 1:
10            break;
11        case 2:
12            return i13 * i18;
13        case 3:
14            return i13 ^ i12;
15        case 4:
16            return i12 - i19;
17        case 5:
18            int i22 = i12 + 783;
19            i12 = i22 * i22;
20            break;
21        case 6:
22            return i13 + (i12 ^ i13);
23        case 7:
24            return i18 - i19;
25        case 8:
26            return i13 * i12;
27        case 9:
28            return (i13 * i12) - i12;
29        default:
30            return -1;
31    }
32    return i12 + i19;
33}

This is just a pretty simple POW math function, not much to see here. I'll go more in-depth with how you would port this code to another language in part 2. Next, we have this code:

1byte[] bArr = new byte[4];
2try {
3    byte[] bytes = string1.getBytes(Charsets.UTF_8);
4    bArr = bytes;
5} catch (UnsupportedEncodingException unused) {
6}
7int i14 = (bArr.length >= 4 ? 0 : ByteBuffer.wrap(bArr).getInt()) ^ a13;
8Integer boxInt = Boxing.boxInt(i14);

We can see string1 is f10774k aka Build.MODEL. Keep in mind boxInt is the actual value of PX257. So this might look complicated but let's break it down. First, we get the bytes of string1, pretty simple. Next, if the length of string1's bytes (basically just string1.length) is >= 4, we select 0, if it's less than 4, we do ByteBuffer.wrap(bArr).getInt(). Now the reason this is a lot simpler than it seems at first is that, if you're like me, you have no clue what ByteBuffer.wrap(bArr).getInt() means, so let's look at what getInt() does. According to the docs:

1The getInt() method of java. nio. ByteBuffer class is used to read the next four bytes at this buffer's current position, composing them into an int value according to the current byte order, and then increments the position by four.

So with this in mind, you might realize where this is going. So we only call the getInt() method if the length of the byte array is < 4. But the getInt() method takes the next 4 bytes? So that code is impossible, if there aren't 4 bytes in the array, it will raise an exception. So logically that code can be simplified down to:

1int i14 = 0 ^ a13;
2Integer boxInt = Boxing.boxInt(i14);

If you're also like me you are clueless as to what Boxing.boxInt does, so looking at that method we see this code:

1public static final Integer boxInt(int i12) {
2    return new Integer(i12);
3}

Pretty simple now that we see that. Overall this is a big oversimplification of the PX257 key. In part 2 this key will be put into much more detail since I will be porting the code to golang.

Now we just need to look back at the request body one last time, we should see 2 more keys were added, sid and vid. These two keys SHOULD be pretty obvious at this point, it's just the response of the sid and vid instructions in the previous response body.

That's it for part 1. I know this was a very long read, probably too long tbh, I was just trying to make the article as easy as possible for beginners to understand. Keep an eye out for part 2 though. I'll be showing the process of making and scale-testing an actual API. Probably not an API ready for sale or anything but perfect for personal use. The language will be golang.

References

Frida: github.com/frida/frida Android Debug Bridge (ADB): developer.android.com/tools/adb JADX, APK decompiler: github.com/skylot/jadx Burp Suite Community Edition: portswigger.net/burp/communitydownload Nox: bignox.com Python pip: python.org


obfio