Inside PerimeterX's (HUMAN Security) Android SDK
Today we'll be taking a look inside PerimeterX's (HUMAN Security) Android SDK, specifically the v4.0.1 SDK.
While I was sniffing an app with a MITM attack, I stumbled upon the Android SDK for PerimeterX. How I knew it was PerimeterX was by the request URL (collector-*.perimeterx.net).
Looking at the requests that are being made, there are two requests that matter to us. The content-type is application/x-www-form-urlencoded, and the first POST request's payload has:
payload=W3sidCI6IlBYMzE1IiwiZCI6eyJQWDMzMCI6Im5ld19zZXNzaW9uIiwiUFgxMjE0IjoiKHJlZGFjdGVkKSIsIlBYOTEiOjEwODAsIlBYOTIiOjIxMzgsIlBYMjEyMTUiOjQsIlBYMzE2IjpmYWxzZSwiUFgzMTgiOiIzNCIsIlBYMzE5IjoiNC4xOS4yNzgtZzdiMDk0NDY0NTE3Mi1hYjEwODEyODE0IiwiUFgzMjAiOiJTTS1OOTM1RiIsIlBYMzM5Ijoic2Ftc3VuZyIsIlBYMzIxIjoiZ3JhY2VybHRlIiwiUFgzMjMiOjE3OTA2ODcxNDgsIlBYMzIyIjoiQW5kcm9pZCIsIlBYMzM3Ijp0cnVlLCJQWDMzNiI6dHJ1ZSwiUFgzMzUiOnRydWUsIlBYMzM0IjpmYWxzZSwiUFgzMzMiOnRydWUsIlBYMzMxIjp0cnVlLCJQWDMzMiI6dHJ1ZSwiUFg0MjEiOiJmYWxzZSIsIlBYNDQyIjoiZmFsc2UiLCJQWDIxMjE4IjoiW10iLCJQWDIxMjE3IjoiW10iLCJQWDIxMjI0IjoiZmFsc2UiLCJQWDIxMjIxIjoiZmFsc2UiLCJQWDMxNyI6IldpRmkiLCJQWDM0NCI6Im1pc3NpbmdfdmFsdWUiLCJQWDM0NyI6IltcImVuX1VTXCJdIiwiUFgzNDMiOiJVbmtub3duIiwiUFg0MTUiOjkwLCJQWDQxMyI6Imdvb2QiLCJQWDE2IjoiVVNCIiwiUFg0MTQiOiJjaGFyZ2luZyIsIlBYNDE5IjoiVW5rbm93biIsIlBYNDE4IjoyNC45LCJQWDQyMCI6NC4yNSwiUFgzNDAiOiJ2NC4wLjEiLCJQWDM0MiI6IjguMzEuMCIsIlBYMzQxIjoidHJ1ZSIsIlBYMzQ4IjoiKHNhbXBsZSkiLCJQWDExNTkiOmZhbHNlLCJQWDM0NSI6MCwiUFgzNTEiOjEsIlBYMzI2IjoiMDAwMDAxYTAtZWQ0NS03OTc0LTAwMDAtMDAwMDAwMDAwMDAxIiwiUFgzMjciOiIwMDAwMDFBMCIsIlBYMzI4IjoiRjdDM0IxRTgzRkE3M0IzNDZBOEJDQzA5RTQ3NUY2RDdFQzYzMTk2MiIsIlBYMTIwOCI6IltdIiwiUFgyMTIxOSI6Int9In19XQ==&uuid=7d4ade25-bc06-11f1-b0ac-a7d37788bc95&appId=PXFrHHZVId&tag=mobile&ftag=22
Decoding the payload
The payload parameter is where the device specific values live. The uuid seems to be a device guid from first glance, but lets not get ahead of ourselves. Immediately I can tell the payload parameter is base64 encoded, and by the trailing =, this signals standard padded base64. Decoding the base64 gives us this:
[
ย {
ย ย "t": "PX315",
ย ย "d": {
ย ย ย "PX330": "new_session",
ย ย ย "PX1214": "(redacted)",
ย ย ย "PX91": 1080,
ย ย ย "PX92": 2138,
ย ย ย "PX21215": 4,
ย ย ย "PX316": false,
ย ย ย "PX318": "34",
ย ย ย "PX319": "4.19.278-g7b0944645172-ab10812814",
ย ย ย "PX320": "SM-N935F",
ย ย ย "PX339": "samsung",
ย ย ย "PX321": "gracerlte",
ย ย ย "PX323": 1790687148,
ย ย ย "PX322": "Android",
ย ย ย "PX337": true,
ย ย ย "PX336": true,
ย ย ย "PX335": true,
ย ย ย "PX334": false,
ย ย ย "PX333": true,
ย ย ย "PX331": true,
ย ย ย "PX332": true,
ย ย ย "PX421": "false",
ย ย ย "PX442": "false",
ย ย ย "PX21218": "[]",
ย ย ย "PX21217": "[]",
ย ย ย "PX21224": "false",
ย ย ย "PX21221": "false",
ย ย ย "PX317": "WiFi",
ย ย ย "PX344": "missing_value",
ย ย ย "PX347": "[\"en_US\"]",
ย ย ย "PX343": "Unknown",
ย ย ย "PX415": 90,
ย ย ย "PX413": "good",
ย ย ย "PX416": "USB",
ย ย ย "PX414": "charging",
ย ย ย "PX419": "Unknown",
ย ย ย "PX418": 24.9,
ย ย ย "PX420": 4.25,
ย ย ย "PX340": "v4.0.1",
ย ย ย "PX342": "8.31.0",
ย ย ย "PX341": "(name of app, redacted)",
ย ย ย "PX348": "(package name, redacted)",
ย ย ย "PX1159": false,
ย ย ย "PX345": 0,
ย ย ย "PX351": 1,
ย ย ย "PX326": "000001a0-ed45-7974-0000-000000000001",
ย ย ย "PX327": "000001A0",
ย ย ย "PX328": "F7C3B1E83FA73B346A8BCC09E475F6D7EC631962",
ย ย ย "PX1208": "[]",
ย ย ย "PX21219": "{}"
ย ย }
ย }
]
From a glance, I can already tell what a few of these values are. PX341 is the name of the app, PX348 is the package name of the app, PX347 is the language of the device, PX342 is the app version, PX340 is the SDK version, etc. The ones that stick out to me are the boolean values (we can't tell from just a false or true), PX1214 seems like it's the android deviceID but we'll be making sure of that, PX328, PX327 and PX326. There's a couple others not listed that we'll be diving into as well, as there's not enough information from their value alone to come to a hypothesis.
Following the values in JadX
I'm going to be using JadX in this demonstration to decompile the app and figure out where these values are coming from. I'll be working from top-down, and the first values I want to understand are the boolean values (PX337, PX336, PX335, PX334, PX333, PX331, PX332, PX421, PX442, PX21224, PX21221 and PX1159). So the first thing I'll be trying is to search the fields directly in the decompiled APK (PX337). Searching for the value gave us two results, but we'll be looking at the first:
jSONObject5.put("PX337", eVar.f7648o);
so PX337 comes from eVar.f7648o, eVar comes from this:
Jf.e eVar = bVar != null ? bVar.f6455a : null;
so eVar is bVar.f6455a, and bVar comes from:
Hf.b bVar = hVar.f3796b;
bVar is hVar.f3796b, and hVar comes from:
h hVar = aVar.f3782b;
and aVar is one of the variables called into the function. You may have noticed that the names of the variables are intentionally named vague values, this is what we call identifier renaming, and it makes the code much harder to read. When dealing with this sort of obfuscation, I like to rename the code as I go, along with writing down the values to make it easier to follow. Since we're using JadX it makes it a lot easier to navigate through the code, and I can simply just double click on f7648o to see exactly where this value comes from. It's lead me to gold, and I can now see where most of the values come from:
public e(String androidId, int i10, int i11, int i12, String str, boolean z10, Vf.c cVar, Integer num, String osVersion, int i13, String systemOsVersion, String deviceModel, String deviceName, String deviceManufacturer, String deviceFingerprint, String deviceBoard, String deviceBootloader, String deviceBrand, String deviceDisplay, String deviceHardware, String deviceUser, boolean z11, boolean z12, boolean z13, boolean z14, boolean z15, boolean z16, boolean z17, a aVar, String deviceLanguage, long j10, boolean z18, boolean z19, ArrayList touchDatas, ArrayList deviceMotionDatas) {
ย ย ย ย Intrinsics.checkNotNullParameter(androidId, "androidId");
ย ย ย ย Intrinsics.checkNotNullParameter(osVersion, "osVersion");
ย ย ย ย Intrinsics.checkNotNullParameter(systemOsVersion, "systemOsVersion");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceModel, "deviceModel");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceName, "deviceName");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceManufacturer, "deviceManufacturer");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceFingerprint, "deviceFingerprint");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceBoard, "deviceBoard");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceBootloader, "deviceBootloader");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceBrand, "deviceBrand");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceDisplay, "deviceDisplay");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceHardware, "deviceHardware");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceUser, "deviceUser");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceLanguage, "deviceLanguage");
ย ย ย ย Intrinsics.checkNotNullParameter(touchDatas, "touchDatas");
ย ย ย ย Intrinsics.checkNotNullParameter(deviceMotionDatas, "deviceMotionDatas");
ย ย ย ย Intrinsics.checkNotNullExpressionValue(UUID.randomUUID().toString(), "toString(...)");
ย ย ย ย this.f7634a = androidId;
ย ย ย ย this.f7635b = i10;
ย ย ย ย this.f7636c = i11;
ย ย ย ย this.f7637d = i12;
ย ย ย ย this.f7638e = str;
ย ย ย ย this.f7639f = z10;
ย ย ย ย this.f7640g = cVar;
ย ย ย ย this.f7641h = num;
ย ย ย ย this.f7642i = osVersion;
ย ย ย ย this.f7643j = i13;
ย ย ย ย this.f7644k = systemOsVersion;
ย ย ย ย this.f7645l = deviceModel;
ย ย ย ย this.f7646m = deviceName;
ย ย ย ย this.f7647n = deviceManufacturer;
ย ย ย ย this.f7648o = z11;
ย ย ย ย this.f7649p = z12;
ย ย ย ย this.f7650q = z13;
ย ย ย ย this.f7651r = z14;
ย ย ย ย this.f7652s = z15;
ย ย ย ย this.f7653t = z16;
ย ย ย ย this.f7654u = z17;
ย ย ย ย this.f7655v = aVar;
ย ย ย ย this.f7656w = deviceLanguage;
ย ย ย ย this.f7657x = j10;
ย ย ย ย this.f7658y = z18;
ย ย ย ย this.f7659z = z19;
ย ย ย ย this.f7632A = touchDatas;
ย ย ย ย this.f7633B = deviceMotionDatas;
ย ย }
However, this does not tell us what the boolean values actually are. f7648o comes from z11, so the next thing I'll be doing is finding what calls the e function, and making sure that the parameters that are called are the same amount as our function we have now. Using JadX's find usage tool, I've found exactly what we're after. Looking at the call to the constructor, we can see that the first boolean after deviceUser is hasSystemFeature:
e(string, i14, i15, i10, str14, z15, a10, num, str, i11, str2, str3, str4, str5, str6, str7, str8, str9, str10, str11, str12, hasSystemFeature, hasSystemFeature2, hasSystemFeature3, hasSystemFeature4, hasSystemFeature5, hasSystemFeature6, hasSystemFeature7, aVar, locale, currentTimeMillis, r37, z14, arrayList, arrayList3);
This means hasSystemFeature is passed into the constructor as z11, which is then assigned to f7648o. So PX337 comes from hasSystemFeature. Now we just need to find what hasSystemFeature is checking.
context.getPackageManager().hasSystemFeature("android.hardware.location.gps");
PX337 is checking if the device has GPS hardware. If the device advertises GPS hardware, PX337 is set to true. That's one JSON key knocked down, and now that we've followed the app we know exactly where the device values are coming from. The next one we'll be looking for is PX336 which comes from hasSystemFeature2. Using the same steps as above, we'll be finding it.
context.getPackageManager().hasSystemFeature("android.hardware.sensor.gyroscope");
PX336 checks if the device has a gyroscope. Now onto PX335.
context.getPackageManager().hasSystemFeature("android.hardware.sensor.accelerometer");
PX335 checks if the device has an accelerometer. Now onto PX334.
context.getPackageManager().hasSystemFeature("android.hardware.ethernet");
PX334 checks if the device has ethernet hardware. Now PX333.
context.getPackageManager().hasSystemFeature("android.hardware.touchscreen");
This one is simple, it's just checking if the device has a touchscreen. Now onto PX331.
context.getPackageManager().hasSystemFeature("android.hardware.nfc");
PX331 checks if the device has NFC hardware. Next is PX332.
context.getPackageManager().hasSystemFeature("android.hardware.wifi");
This one is just checking if the device has wifi. Now I'll be going through PX421 and PX442.
PX421 and PX442
PX421 was actually quite difficult to figure out as my decompiler mangled the names so finding and tracing it down to it's value proved to be more difficult than the previous checks. However after tracing eVar.f7658y back through the constructor, we can see that it ultimately comes from the z16 boolean:
boolean z16 = !z11 || z12;
The value is then passed into the e constructor, where it becomes z18 and is assigned to f7658y:
this.f7658y = z18;
PX421 then uses f7658y as a condition:
At first this looks more complicated than it actually is. Gf.c.f5703b is the TRUE_VALUE enum with ordinal 0, while Gf.c.f5705c is the FALSE_VALUE enum with ordinal 1. The .a() function converts those enum values into the strings true and false.
Therefore, the whole expression can be simplified to:
PX421 = (!z11 || z12) ? "true" : "false";
We now know what PX421 is, it's a boolean condition but we do not know what the boolean condition actually checks, lets get to that next.
I'll be working backwards, so first I'll be looking at where z11 gets set.
z11 gets set further up in the code:
String[] strArr = Gf.j.f5773b;
int length = strArr.length;
int i12 = 0;
while (true) {
if (i12 >= length) {
z10 = false;
break;
}
int i13 = i12;
String[] strArr2 = strArr;
if (new File(strArr[i13]).exists()) {
z10 = true;
break;
}
i12 = i13 + 1;
strArr = strArr2;
}
try {
Process exec = Runtime.getRuntime().exec("/system/xbin/which su");
try {
z11 = z10;
...
This is a while true loop. It first defines i12, it checks if i12 is greater than or equal to length, and length is the strArr's length. So we need to find what strArr is. Going to the f5773b definition, we've finally got what we need.
public static final String f5772a;
public static final String[] f5773b;
static {
e[] eVarArr = e.f5736a;
if (d.f5735a[0] != 1) {
throw new NoWhenBranchMatchedException();
}
f5772a = "UTF-8";
f5773b = new 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"};
}
PX421 is checking for indicators of root/superuser access on the device. It does this by checking whether any known su or superuser related paths exist on the filesystem. If one of these paths exists, PX421 is set to true.
Now onto PX442.
PX442 follows a very similar structure:
jSONObject5.putOpt("PX442", (eVar.f7659z ? Gf.c.f5703b : Gf.c.f5705c).a());
Tracing f7659z back through the e constructor shows that it comes from z19, which is passed the local variable z14:
this.f7659z = z19;
and:
..., currentTimeMillis, z16, z14, arrayList, ...
We already traced z14 earlier, where it was assigned from:
contains$default = StringsKt.contains$default( (CharSequence) str13, (CharSequence) "test-keys", false, 2, (Object) null);
with:
String str13 = Build.TAGS;
This is effectively just:
z14 = Build.TAGS.contains("test-keys");
Because PX442 uses the same TRUE_VALUE / FALSE_VALUE enum conversion as PX421, it can therefore be simplified to:
PX442 = Build.TAGS.contains("test-keys") ? "true" : "false";
So PX442 is a stringified boolean indicating whether the Android build tags contain test-keys.
PX21224 and PX21221
Now we can move on to PX21224.
jSONObject5.putOpt("PX21224", (hSPolicy.getDetectionPolicy().getAllowTouchDetection() ? Gf.c.f5703b : Gf.c.f5705c).a());
As we established earlier, the Gf.c.f5703b and Gf.c.f5705c is just "true" or "false". So PX21224 reports whether touch detection is allowed by the configured detection policy.
PX21221 is similar:
jSONObject5.putOpt("PX21221", ((hSPolicy.getDetectionPolicy().getAllowTouchDetection() && hSPolicy.getDetectionPolicy().getAllowDeviceMotionDetection()) ? Gf.c.f5703b : Gf.c.f5705c).a());
PX21221 is true only when both touch detection and device motion detection are allowed.
PX1159
Now onto PX1159.
jSONObject6.put("PX1159", bVar4.f7074e);
PX1159 comes from bVar4 which is in this function:
public b(String packageName, String appName, String appVersion, String pxSDKVersion, boolean z10) {
ย ย Intrinsics.checkNotNullParameter(packageName, "packageName");
ย ย Intrinsics.checkNotNullParameter(appName, "appName");
ย ย Intrinsics.checkNotNullParameter(appVersion, "appVersion");
ย ย Intrinsics.checkNotNullParameter(pxSDKVersion, "pxSDKVersion");
ย ย this.f7070a = packageName;
ย ย this.f7071b = appName;
ย ย this.f7072c = appVersion;
ย ย this.f7073d = pxSDKVersion;
ย ย this.f7074e = z10;
}
so we need to find what calls b using the find usage tool.
boolean z10 = false;
String versionName = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionName;
Intrinsics.checkNotNullExpressionValue(versionName, "versionName");
String sdkVersion = HumanSecurity.INSTANCE.sdkVersion();
try {
ย ย Tb.a aVar = AbstractC6637a.f64799c;
ย ย Object invoke = lc.b.class.getMethod("isInstantApp", null).invoke(AbstractC6637a.class.getMethod("getPackageManagerCompat", Context.class).invoke(null, context), null);
ย ย Intrinsics.checkNotNull(invoke, "null cannot be cast to non-null type kotlin.Boolean");
ย ย z10 = ((Boolean) invoke).booleanValue();
} catch (Exception unused) {
}
Intrinsics.checkNotNull(packageName);
return new b(packageName, str, versionName, sdkVersion, z10);
So PX1159 is just checking if the app is an Android Instant App.
PX316
Now onto PX316.
((TelephonyManager) systemService2).getSimState() != 1
This simply checks the sim state, if it's not equal to 1 (ABSENT in Android's TelephonyManager API) it's true, otherwise false.
PX345 and PX351
Now onto PX345 and PX351.
jSONObject7.put("PX345", bVar7.f8213a);
jSONObject7.put("PX351", bVar7.f8214b);
Both of these fields come from Kf.b:
public final class b {
public final int f8213a;
public final int f8214b;
public b(int i10, int i11) {
this.f8213a = i10;
this.f8214b = i11;
}
}
So PX345 is the first integer passed to the constructor, while PX351 is the second.
Looking at where Kf.b is created:
new Kf.b(
intValue,
(int) (new Date().getTime() - Kf.a.f8212a)
)
This immediately tells us that PX345 comes from intValue, while PX351 is an elapsed-time value.
PX345
We already traced intValue back to the RESUME_COUNTER enum:
EnumC1731w key = EnumC1731w.f14358f;
Integer f10 = interfaceC1734z.f(key, appId);
where:
EnumC1731w enumC1731w6 = new EnumC1731w("RESUME_COUNTER", 5);
f14358f = enumC1731w6;
The retrieved counter is then incremented before being used to construct the Kf.b object.
Therefore:
PX345 = the application's resume counter
This value tracks how many times the application has resumed, using the stored RESUME_COUNTER value.
PX351
The second value is:
(int) (new Date().getTime() - Kf.a.f8212a)
We can now see exactly what Kf.a.f8212a contains:
public final class a {
public static final long f8212a = new Date().getTime();
}
This means f8212a is initialized to the current Unix time in milliseconds when the Kf.a class is initialized.
Therefore PX351 is simply:
PX351 = current time - Kf.a.f8212a
or, more simply:
PX351 = milliseconds elapsed since Kf.a was initialized
The result is cast to an int.
So the final meanings are:
PX345 = application resume counter
PX351 = milliseconds elapsed since Kf.a initialization
The Kf.b object containing these two values is then stored inside the larger Hf.b usage-information object.
PX343
Now onto PX343.
jSONObject5.putOpt("PX343", Gf.c.f5706c0.a());
We already know that the Gf.c enum contains a number of predefined values, and we can find f5706c0 in the enum:
c cVar60 = new c("NETWORK_UNKNOWN", 59);
f5706c0 = cVar60;
The .a() function converts this enum value into the string associated with it. For this value, that string is:
"Unknown"
So PX343 is simply reporting an unknown network type.
PX419, PX418 and PX420
Next are PX419, PX418 and PX420.
String str7 = aVar2.f7624e;
jSONObject5.put("PX418", Float.valueOf(aVar2.f7625f / 10));
jSONObject5.put("PX420", Float.valueOf(aVar2.f7626g / 1000));
jSONObject5.putOpt("PX419", str7);
We can trace where aVar2 comes from and see that it is constructed using values from the battery information:
aVar = new a(
ย ย intExtra,
ย ย intExtra2,
ย ย intExtra3,
ย ย intExtra4,
ย ย extras != null ? extras.getString("technology") : null,
ย ย registerReceiver.getIntExtra("temperature", 0),
ย ย registerReceiver.getIntExtra("voltage", 0)
);
This makes the three fields fairly straightforward.
PX419 comes from the battery technology string:
extras != null ? extras.getString("technology") : null
PX418 comes from the battery temperature, which is divided by 10:
aVar2.f7625f / 10
and PX420 comes from the battery voltage, which is divided by 1000:
aVar2.f7626g / 1000
So PX419 is the battery technology, PX418 is the battery temperature and PX420 is the battery voltage.
PX413, PX415, PX416 and PX414
Now onto PX413, PX415, PX416 and PX414.
These are all using the same battery object:
Jf.a aVar2 = eVar.f7655v;
If this object exists, PX415 is taken directly from one of its fields:
jSONObject5.put("PX415", aVar2.f7621b);
The next value is PX413, which is determined from the battery health value:
int i12 = aVar2.f7620a;
switch (i12) {
ย ย case 1:
ย ย ย ย cVar3 = Gf.c.f5694T;
ย ย ย ย break;
ย ย case 2:
ย ย ย ย cVar3 = Gf.c.f5695U;
ย ย ย ย break;
ย ย case 3:
ย ย ย ย cVar3 = Gf.c.f5696V;
ย ย ย ย break;
ย ย case 4:
ย ย ย ย cVar3 = Gf.c.f5697W;
ย ย ย ย break;
ย ย case 5:
ย ย ย ย cVar3 = Gf.c.f5698X;
ย ย ย ย break;
ย ย case 6:
ย ย ย ย cVar3 = Gf.c.f5699Y;
ย ย ย ย break;
ย ย case 7:
ย ย ย ย cVar3 = Gf.c.f5700Z;
ย ย ย ย break;
ย ย default:
ย ย ย ย cVar3 = Gf.c.f5693S;
ย ย ย ย break;
}
jSONObject5.putOpt("PX413", cVar3.a());
We can map these enum values back to their strings:
f5693S = "BATTERY_HEALTH_MISSING";
f5694T = "BATTERY_HEALTH_UNKNOWN";
f5695U = "BATTERY_HEALTH_GOOD";
f5696V = "BATTERY_HEALTH_OVERHEAT";
f5697W = "BATTERY_HEALTH_DEAD";
f5698X = "BATTERY_HEALTH_OVER_VOLTAGE";
f5699Y = "BATTERY_HEALTH_UNSPECIFIED_FAILURE";
f5700Z = "BATTERY_HEALTH_COLD";
The .a() function turns these into the values actually put into the JSON, so PX413 is reporting the battery health.
PX416 is next:
int i13 = aVar2.f7622c;
jSONObject5.putOpt(
ย ย "PX416",
ย ย (i13 != 1 ? i13 != 2 ? i13 != 4 ? Gf.c.f5689O : Gf.c.f5692R : Gf.c.f5691Q : Gf.c.f5690P).a()
);
We can simplify this by following each value:
1 -> f5690P -> "AC"
2 -> f5691Q -> "USB"
4 -> f5692R -> "Wireless"
anything else -> f5689O -> ""
So PX416 is the type of power source the device is currently plugged into.
PX414 works in a very similar way:
int i14 = aVar2.f7623d;
jSONObject5.putOpt(
ย ย "PX414",
ย ย (i14 != 2 ? i14 != 3 ? i14 != 4 ? i14 != 5 ? Gf.c.f5684J : Gf.c.f5688N : Gf.c.f5687M : Gf.c.f5686L : Gf.c.f5685K).a()
);
Following the enum values gives us:
2 -> "charging"
3 -> "discharging"
4 -> "not charging"
5 -> "full"
anything else -> "Unknown"
So PX414 is the current battery charging status.
PX326, PX327 and PX328
Now onto PX326, PX327 and PX328.
PX326 is directly assigned from kVar:
jSONObject8.putOpt("PX326", kVar.f7686a);
The k constructor tells us what this value is:
public k(String uniqueString, String deviceModel) {
ย ย ...
ย ย this.f7686a = uniqueString;
ย ย ...
}
The uniqueString itself is generated from the current time:
long currentTimeMillis = System.currentTimeMillis();
if (currentTimeMillis == Qf.i.f13616a) {
ย ย currentTimeMillis++;
}
Qf.i.f13616a = currentTimeMillis;
String uuid = new UUID(currentTimeMillis, 1L).toString();
So PX326 is this generated UUID value.
PX327 comes from:
this.f7687b = valueOf;
where valueOf is the upper-case version of the first part of the uniqueString after splitting it using the separator.
So PX327 is derived from PX326 rather than being an independent device value.
PX328 is then generated using the device model, the uniqueString and that upper-case value:
String input = deviceModel + uniqueString + valueOf;
MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
byte[] bytes = input.getBytes(Charsets.UTF_8);
byte[] digest = messageDigest.digest(bytes);
The SHA-1 digest is then converted into hexadecimal and upper-cased:
String upperCase2 = sb3.toUpperCase(Locale.ROOT);
this.f7688c = upperCase2;
So PX328 is a SHA-1 hash of the device model, PX326's unique string and PX327's value.
PX344
Now onto PX344.
String str6 = eVar.f7638e;
if (str6 == null) {
ย ย str6 = Gf.c.f5732x.a();
}
jSONObject5.putOpt("PX344", str6);
If f7638e has no value, it falls back to the enum value f5732x.
From the enum we can see that f5732x is:
ERROR_MISSING_VALUE
and its .a() function returns:
"missing_value"
So PX344 is the value of eVar.f7638e, or "missing_value" when that value is unavailable.
PX21215
Next is PX21215.
PX21215 = Settings.System.getInt(
ย ย context.getContentResolver(),
ย ย "screen_brightness"
);
This one is straightforward. It reads the current screen brightness from Android's system settings and stores it as an integer.
So PX21215 is the screen brightness value.
PX91 and PX92
PX91 and PX92 are also straightforward:
PX91 = displayMetrics.widthPixels;
PX92 = displayMetrics.heightPixels;
PX91 is the display width in pixels and PX92 is the display height in pixels.
PX1214
PX1214 comes from:
this.f7634a = androidId;
So PX1214 is the androidId value passed into the object.
PX318
PX318 is:
PX318 = Build.VERSION.SDK_INT;
So PX318 is the Android SDK version.
PX21218 and PX21217
Now onto PX21218 and PX21217.
The constructor stores the touch and device motion data in:
this.f7632A = touchDatas;
this.f7633B = deviceMotionDatas;
PX21218 then converts the touch data into a JSON array:
jSONObject5.putOpt(
ย ย "PX21218",
ย ย new JSONArray((Collection) arrayList2).toString()
);
PX21217 does the same thing for the device motion data:
ArrayList arrayList3 = new ArrayList();
Iterator it2 = eVar.f7633B.iterator();
while (it2.hasNext()) {
ย ย Object next2 = it2.next();
ย ย arrayList3.add(((Jf.f) next2).toString());
}
jSONObject5.putOpt(
ย ย "PX21217",
ย ย new JSONArray((Collection) arrayList3).toString()
);
So PX21218 contains the collected touch detection data, while PX21217 contains the collected device motion data.
PX21224 and PX21221 are the same ones we already went through earlier.
PX323
Finally, PX323:
PX323 = currentTimeMillis
This is the current time in milliseconds from:
long currentTimeMillis = System.currentTimeMillis();
So PX323 is simply the current timestamp in milliseconds.
The second request
Now that we've dug into the collection side of PX's SDK, we can now move on to the next request. The response body of the first request (the collector side) returns multiple values:
{
"do": ["sid|7ca2eb07-bc06-11f1-8701-4f19793f06be", "vid|7ca2e07d-bc06-11f1-8701-91fdb81edf0f|31536000|true",
"appc|1|1790687162220|bd0dfcde91eb0f372fbb8608f7a6e8d28c5dbcc911003995f5cbd21bdb248a29|7ca4d940-bc06-11f1-aa84-e959edf33ed0", "appc|2|1790687162220|f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6,f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6|1055|51|3137|3808|940|864",
"d|www.(redacted).com,(redacted).com,.(redacted).com,(redacted).app,(redacted).com,.(redacted).app", "ipd|true"]
}
So we can see an sid, appc, and d which seems to stand for domain. Now that you've got this context, lets get into the second requests payload.
payload=W3sidCI6IlBYMzI5IiwiZCI6eyJQWDMzMCI6Im5ld19zZXNzaW9uIiwiUFgxMjE0IjoiKHJlZGFjdGVkLCBhbmRyb2lkaWQpIiwiUFg5MSI6MTA4MCwiUFg5MiI6MjEzOCwiUFgyMTIxNSI6NCwiUFgzMTYiOmZhbHNlLCJQWDMxOCI6IjM0IiwiUFgzMTkiOiI0LjE5LjI3OC1nN2IwOTQ0NjQ1MTcyLWFiMTA4MTI4MTQiLCJQWDMyMCI6IlNNLU45MzVGIiwiUFgzMzkiOiJzYW1zdW5nIiwiUFgzMjEiOiJncmFjZXJsdGUiLCJQWDMyMyI6MTc5MDY4NzE0OCwiUFgzMjIiOiJBbmRyb2lkIiwiUFgzMzciOnRydWUsIlBYMzM2Ijp0cnVlLCJQWDMzNSI6dHJ1ZSwiUFgzMzQiOmZhbHNlLCJQWDMzMyI6dHJ1ZSwiUFgzMzEiOnRydWUsIlBYMzMyIjp0cnVlLCJQWDQyMSI6ImZhbHNlIiwiUFg0NDIiOiJmYWxzZSIsIlBYMjEyMTgiOiJbXSIsIlBYMjEyMTciOiJbXSIsIlBYMjEyMjQiOiJmYWxzZSIsIlBYMjEyMjEiOiJmYWxzZSIsIlBYMzE3IjoiV2lGaSIsIlBYMzQ0IjoibWlzc2luZ192YWx1ZSIsIlBYMzQ3IjoiW1wiZW5fVVNcIl0iLCJQWDM0MyI6IlVua25vd24iLCJQWDQxNSI6OTAsIlBYNDEzIjoiZ29vZCIsIlBYNDE2IjoiVVNCIiwiUFg0MTQiOiJjaGFyZ2luZyIsIlBYNDE5IjoiVW5rbm93biIsIlBYNDE4IjoyNC45LCJQWDQyMCI6NC4yNSwiUFgzNDAiOiJ2NC4wLjEiLCJQWDM0MiI6IjguMzEuMCIsIlBYMzQxIjoiKG5hbWUgb2YgYXBwLCByZWRhY3RlZCkiLCJQWDM0OCI6IihwYWNrYWdlIG5hbWUsIHJlZGFjdGVkKSIsIlBYMTE1OSI6ZmFsc2UsIlBYMzQ1IjowLCJQWDM1MSI6MSwiUFgzMjYiOiIwMDAwMDFhMC1lZDQ1LTc5NzQtMDAwMC0wMDAwMDAwMDAwMDEiLCJQWDMyNyI6IjAwMDAwMUEwIiwiUFgzMjgiOiJGN0MzQjFFODNGQTczQjM0NkE4QkNDMDlFNDc1RjZEN0VDNjMxOTYyIiwiUFgyNTkiOjE3OTA2ODcxNjIyMjAsIlBYMjU2IjoiZjE4OWZiYTBkNTY0ZWM2OTliNGNjMDY3Y2NkMWVmMjU0MzFmNjk5NjM2MDFiZmRiZjFiMDZhNmQxNjMzNzlmNixmMTg5ZmJhMGQ1NjRlYzY5OWI0Y2MwNjdjY2QxZWYyNTQzMWY2OTk2MzYwMWJmZGJmMWIwNmE2ZDE2MzM3OWY2IiwiUFgyNTciOiIxMzk3NTY2NTMxIiwiUFgxMjA4IjoiW10iLCJQWDIxMjE5Ijoie30ifX1d=&uuid=7d4ade25-bc06-11f1-b0ac-a7d37788bc95&appId=PXFrHHZVId&tag=mobile&ftag=22&sid=7ca2eb07-bc06-11f1-8701-4f19793f06be&vid=7ca2e07d-bc06-11f1-8701-91fdb81edf0f
So sid and vid are used as parameters inside of the second post request. Now it's time for decoding the payload to see what is new from the first request's payload:
[
{
"t": "PX329",
"d": {
"PX330": "new_session",
"PX1214": "(redacted, androidid)",
"PX91": 1080,
"PX92": 2138,
"PX21215": 4,
"PX316": false,
"PX318": "34",
"PX319": "4.19.278-g7b0944645172-ab10812814",
"PX320": "SM-N935F",
"PX339": "samsung",
"PX321": "gracerlte",
"PX323": 1790687148,
"PX322": "Android",
"PX337": true,
"PX336": true,
"PX335": true,
"PX334": false,
"PX333": true,
"PX331": true,
"PX332": true,
"PX421": "false",
"PX442": "false",
"PX21218": "[]",
"PX21217": "[]",
"PX21224": "false",
"PX21221": "false",
"PX317": "WiFi",
"PX344": "missing_value",
"PX347": "[\"en_US\"]",
"PX343": "Unknown",
"PX415": 90,
"PX413": "good",
"PX416": "USB",
"PX414": "charging",
"PX419": "Unknown",
"PX418": 24.9,
"PX420": 4.25,
"PX340": "v4.0.1",
"PX342": "8.31.0",
"PX341": "(name of app, redacted)",
"PX348": "(package name, redacted)",
"PX1159": false,
"PX345": 0,
"PX351": 1,
"PX326": "000001a0-ed45-7974-0000-000000000001",
"PX327": "000001A0",
"PX328": "F7C3B1E83FA73B346A8BCC09E475F6D7EC631962",
"PX259": 1790687162220,
"PX256": "f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6,f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6",
"PX257": "1397566531",
"PX1208": "[]",
"PX21219": "{}"
}
}
]
So the there's three values that are new, PX259, PX256 and PX257. First thing I'll be doing is searching for PX256 since that is the most interesting.
Uf.b bVar8 = hVar.f3797c;
Integer num2 = hVar.f3798d;
JSONObject jSONObject9 = new JSONObject();
if (bVar8 != null) {
jSONObject9.put("PX259", bVar8.f16532a);
}
if (bVar8 != null && (str4 = bVar8.f16533b) != null) {
jSONObject9.putOpt("PX256", str4);
}
if (num2 != null) {
jSONObject9.putOpt("PX257", String.valueOf(num2.intValue()));
}
So PX256 comes from str4, and str4 = bVar8.f16533b. Now we'll see what f16533b is:
public final class b {
public final long f16532a;
public final String f16533b;
public final int f16534c;
public final int f16535d;
public final int f16536e;
public final int f16537f;
public final int f16538g;
public final int f16539h;
public b(List list) {
this.f16532a = Long.parseLong((String) list.get(2));
this.f16533b = (String) list.get(3);
this.f16537f = Integer.parseInt((String) list.get(4));
this.f16538g = Integer.parseInt((String) list.get(5));
this.f16534c = Integer.parseInt((String) list.get(6));
this.f16535d = Integer.parseInt((String) list.get(7));
this.f16536e = Integer.parseInt((String) list.get(8));
this.f16539h = Integer.parseInt((String) list.get(9));
}
public static int a(int i10, int i11, int i12, int i13) {
int i14 = i13 % 10;
int i15 = i14 != 0 ? i12 % i14 : i12 % 10;
int i16 = i10 * i10;
int i17 = i11 * i11;
switch (i15) {
case 0:
return i16 + i11;
case 1:
return i10 + i17;
case 2:
return i16 * i11;
case 3:
return i10 ^ i11;
case 4:
return i10 - i17;
case 5:
int i18 = i10 + 783;
return (i18 * i18) + i17;
case 6:
return (i10 ^ i11) + i11;
case 7:
return i16 - i17;
case 8:
return i10 * i11;
case 9:
return (i11 * i10) - i10;
default:
return -1;
}
}
public final int b(String string1) {
Intrinsics.checkNotNullParameter(string1, "string1");
int a10 = a(a(this.f16534c, this.f16535d, this.f16537f, this.f16539h), this.f16536e, this.f16538g, this.f16539h);
byte[] bArr = new byte[4];
try {
byte[] bytes = string1.getBytes(Charsets.UTF_8);
Intrinsics.checkNotNullExpressionValue(bytes, "getBytes(...)");
bArr = bytes;
} catch (UnsupportedEncodingException unused) {
}
return a10 ^ (bArr.length < 4 ? 0 : ByteBuffer.wrap(bArr).getInt());
}
}
Okay so this is where things get better than just collector values. This seems to be a little arithmetic VM. The b class calls it with the argument string 1. It then performs a with values we have yet to figure out, creates a byte array called bArr and has 4 bytes inside. It then takes the bytes from string1 and assigns that to bArr, so it takes the first 4 bytes of string1, then it takes the result of the arithmetic VM and XOR's it by bArr.
I first want to know what string 1 is. I'll be finding the usage of the b function. It is used here:
new Df.h(kVar, bVar2, bVar3, Boxing.boxInt(bVar3.b(bVar2.f6455a.f7645l)))
b(bVar2.f6455a.f7645l) is what we're after, so we need to find what f7645l is. It leads us back to the original e class we were using for the collector keys:
this.f7645l = deviceModel;
So string 1 is the deviceModel. Now we need to figure out what the values that are passed into the little arithmetic VM are. I'll be finding the calls to the b list.
case 3000930:
if (!str.equals("appc")) {
break;
} else {
Intrinsics.checkNotNullParameter(components, "components");
b bVar = (components.size() < 2 || !Intrinsics.areEqual(components.get(1), "2") || components.size() < 10) ? null : new b(components);
if (bVar != null) {
this.f16546g = bVar;
break;
} else {
continue;
}
}
...
So appc, which is the response value in the first request:
appc|2|1790687162220|f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6,f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6|1055|51|3137|3808|940|864
Is turned into a list, and the b list tells us out what each one does. Now we can move on to PX257 and PX259.
PX259 is the "1790687162220" value from the appc challenge.
public final long f16532a;
this.f16532a = Long.parseLong((String) list.get(2));
PX257 comes from:
Integer num2 = hVar.f3798d;
jSONObject9.putOpt("PX257", String.valueOf(num2.intValue()));
f3798d comes from:
public h(Jf.k fingerprint, Hf.b bVar, Uf.b bVar2, Integer num) {
Intrinsics.checkNotNullParameter(fingerprint, "fingerprint");
this.f3795a = fingerprint;
this.f3796b = bVar;
this.f3797c = bVar2;
this.f3798d = num;
}
and h is called here:
new Df.h(kVar, bVar2, bVar3, Boxing.boxInt(bVar3.b(bVar2.f6455a.f7645l)))
So PX257 is the integer result of running deviceModel through the appc computation, i.e: "1397566531". Now we can figure out how the uuid string is generated in the first payload.
So after digging for around 20 minutes, I've mapped everything out, first:
String pxUUID = F.f18783m;
the pxUUID comes from f18783m, so lets find what that is:
static {
String uuid = AbstractC5553b.b().b().toString();
Intrinsics.checkNotNullExpressionValue(uuid, "toString(...)");
f18783m = uuid;
f18784n = new Date();
}
f18783m = uuid and uuid is AbstractC5553b.b().b().toString(), so we need to know what AbstractC5553b.b() is.
public static C5603b b() {
return c(null);
}
Now we just need to see what C5603b does.
public C5603b(C5552a c5552a, g gVar) {
byte[] bArr = new byte[16];
c5552a = c5552a == null ? C5552a.i() : c5552a;
this.f57172a = c5552a;
c5552a.m(bArr, 10);
int a10 = gVar.a();
bArr[8] = (byte) (a10 >> 8);
bArr[9] = (byte) a10;
this.f57174c = AbstractC5604c.c(AbstractC5604c.b(bArr, 8));
this.f57173b = gVar;
}
public UUID a(long j10) {
int i10 = (int) (j10 >>> 32);
return new UUID((((int) j10) << 32) | ((((((i10 << 16) | (i10 >>> 16)) & (-61441)) | 4096) << 32) >>> 32), this.f57174c);
}
public UUID b() {
return a(this.f57173b.b());
}
Now we're getting somewhere. Now we just need to figure out what AbstractC5604c is, C5552a, and gVar. I'll be hunting for gVar first.
private static synchronized g a() {
g gVar;
synchronized (AbstractC5553b.class) {
if (f56647a == null) {
try {
f56647a = new g(new Random(System.currentTimeMillis()), null);
} catch (IOException e10) {
throw new IllegalArgumentException("Failed to create UUIDTimer with specified synchronizer: " + e10.getMessage(), e10);
}
}
gVar = f56647a;
}
return gVar;
}
return new C5603b(c5552a, gVar);
Okay so gVar is just the currentTimeMilliseconds. Now we can move to C5552a.
private static final char[] f56643f = "0123456789abcdefABCDEF".toCharArray();
private static final long serialVersionUID = 1;
protected static Random f56644g = null;
public C5552a(byte[] bArr) {
if (bArr.length != 6) {
throw new NumberFormatException("Ethernet address has to consist of 6 bytes");
}
long j10 = bArr[0] & UByte.MAX_VALUE;
for (int i10 = 1; i10 < 6; i10++) {
j10 = (j10 << 8) | (bArr[i10] & UByte.MAX_VALUE);
}
this.f56645d = j10;
}
protected static synchronized Random c() {
Random random;
synchronized (C5552a.class) {
try {
if (f56644g == null) {
f56644g = new SecureRandom();
}
random = f56644g;
} catch (Throwable th2) {
throw th2;
}
}
return random;
}
public static C5552a i() {
return l(c());
}
public static C5552a l(Random random) {
byte[] bArr = new byte[6];
synchronized (random) {
random.nextBytes(bArr);
}
bArr[0] = (byte) (bArr[0] | 1);
return new C5552a(bArr);
}
private final void a(StringBuilder sb2, int i10) {
char[] cArr = f56643f;
sb2.append(cArr[(i10 >> 4) & 15]);
sb2.append(cArr[i10 & 15]);
public int compareTo(C5552a c5552a) {
long j10 = this.f56645d - c5552a.f56645d;
if (j10 < 0) {
return -1;
}
return j10 == 0 ? 0 : 1;
}
public int hashCode() {
long j10 = this.f56645d;
return ((int) j10) ^ ((int) (j10 >>> 32));
}
public void m(byte[] bArr, int i10) {
if (i10 < 0 || i10 + 6 > bArr.length) {
throw new IllegalArgumentException("Illegal offset (" + i10 + "), need room for 6 bytes");
}
long j10 = this.f56645d;
int i11 = (int) (j10 >> 32);
bArr[i10] = (byte) (i11 >> 8);
bArr[i10 + 1] = (byte) i11;
int i12 = (int) j10;
bArr[i10 + 2] = (byte) (i12 >> 24);
bArr[i10 + 3] = (byte) (i12 >> 16);
bArr[i10 + 4] = (byte) (i12 >> 8);
bArr[i10 + 5] = (byte) i12;
}
public String toString() {
String str = this.f56646e;
if (str != null) {
return str;
}
StringBuilder sb2 = new StringBuilder(17);
long j10 = this.f56645d;
int i10 = (int) (j10 >> 32);
int i11 = (int) j10;
a(sb2, i10 >> 8);
sb2.append(':');
a(sb2, i10);
sb2.append(':');
a(sb2, i11 >> 24);
sb2.append(':');
a(sb2, i11 >> 16);
sb2.append(':');
a(sb2, i11 >> 8);
sb2.append(':');
a(sb2, i11);
String sb3 = sb2.toString();
this.f56646e = sb3;
return sb3;
}
Okay we understand C5552a, now we need to figure out what g does before moving onto AbstractC5604c.
public class g {
private final C5602a f56649a;
protected final Random f56650b;
protected final e f56651c;
private int f56652d;
private long f56653e;
private long f56654f;
private long f56655g;
private int f56656h;
public g(Random random, d dVar) {
this(random, dVar, e.b());
}
private void c(Random random) {
int nextInt = random.nextInt();
this.f56652d = nextInt;
this.f56656h = (nextInt >> 16) & 255;
}
public int a() {
return this.f56652d & Settings.DEFAULT_INITIAL_WINDOW_SIZE;
}
public synchronized long b() {
long j10;
try {
long a10 = this.f56651c.a();
if (a10 < this.f56653e) {
this.f56649a.e("System time going backwards! (got value %d, last %d)", Long.valueOf(a10), Long.valueOf(this.f56653e));
this.f56653e = a10;
}
long j11 = this.f56654f;
if (a10 <= j11) {
if (this.f56656h >= 10000) {
long j12 = j11 - a10;
j11++;
this.f56649a.c("Timestamp over-run: need to reinitialize random sequence");
c(this.f56650b);
if (j12 >= 100) {
d(a10, j12);
}
}
a10 = j11;
} else {
this.f56656h &= 255;
}
this.f56654f = a10;
int i10 = this.f56656h;
j10 = (a10 * 10000) + 122192928000000000L + i10;
this.f56656h = i10 + 1;
} catch (Throwable th2) {
throw th2;
}
return j10;
}
protected void d(long j10, long j11) {
long j12 = j11 / 100;
long j13 = 2;
if (j12 < 2) {
j13 = 1;
} else if (j12 >= 10) {
j13 = j12 < 600 ? 3L : 5L;
}
this.f56649a.d("Need to wait for %d milliseconds; virtual clock advanced too far in the future", Long.valueOf(j13));
long j14 = j10 + j13;
int i10 = 0;
while (true) {
try {
Thread.sleep(j13);
} catch (InterruptedException unused) {
}
i10++;
if (i10 > 50 || System.currentTimeMillis() >= j14) {
return;
} else {
j13 = 1;
}
}
}
public g(Random random, d dVar, e eVar) {
this.f56649a = C5602a.b(getClass());
this.f56653e = 0L;
this.f56654f = 0L;
this.f56655g = Long.MAX_VALUE;
this.f56656h = 0;
this.f56650b = random;
this.f56651c = eVar;
c(random);
this.f56653e = 0L;
this.f56654f = 0L;
this.f56655g = 0L;
}
}
g.a() provides the 16 bit clock sequence, which is initialized from the lower 16 bits of a randomly generated integer. g.b() obtains the current unix time in milliseconds and converts it into the 100 nanosecond timestamp format required by a UUID, adding the UUID epoch offset and a counter to allow multiple UUID's to be generated within the same millisecond. Now we can finally move to AbstractC5604c.
public abstract class AbstractC5604c {
private static final UUID f57175a = new UUID(0, 0);
private static final UUID f57176b = new UUID(-1, -1);
private static final int a(byte[] bArr, int i10) {
return (bArr[i10 + 3] & UByte.MAX_VALUE) | (bArr[i10] << 24) | ((bArr[i10 + 1] & UByte.MAX_VALUE) << 16) | ((bArr[i10 + 2] & UByte.MAX_VALUE) << 8);
}
protected static final long b(byte[] bArr, int i10) {
return ((a(bArr, i10 + 4) << 32) >>> 32) | (a(bArr, i10) << 32);
}
public static long c(long j10) {
return ((j10 << 2) >>> 2) | Long.MIN_VALUE;
}
}
We now have everything that is needed for the uuid generation. I've written a clean Java reimplementation without all the noise:
public static String generatePxUUID() {
// C5552a.i()
SecureRandom secureRandom = new SecureRandom();
byte[] node = new byte[6];
secureRandom.nextBytes(node);
node[0] |= 0x01;
// g.a() / clock sequence
Random random = new Random(System.currentTimeMillis());
int randomValue = random.nextInt();
int clockSequence = randomValue & 0xFFFF;
int counter = (randomValue >> 16) & 0xFF;
// g.b()
long unixMillis = System.currentTimeMillis();
long uuidTimestamp =
unixMillis * 10000L
+ 122192928000000000L
+ counter;
// C5603b.a(uuidTimestamp)
int high = (int) (uuidTimestamp >>> 32);
long msb =
(((((high << 16) | (high >>> 16))
& 0xFFFF0FFF)
| 0x1000L) << 32) >>> 32;
// C5603b constructor's f57174c
long lsb =
((long) (clockSequence & 0xFFFF) << 48);
lsb |= ((long) (node[0] & 0xFF) << 40);
lsb |= ((long) (node[1] & 0xFF) << 32);
lsb |= ((long) (node[2] & 0xFF) << 24);
lsb |= ((long) (node[3] & 0xFF) << 16);
lsb |= ((long) (node[4] & 0xFF) << 8);
lsb |= ((long) (node[5] & 0xFF));
UUID uuid = new UUID(msb, lsb);
return uuid.toString();
}
The UUID is generated as a time based UUID. The timestamp is derived from the current unix time, and converted to the UUID epoch/100 nanosecond format. A 16 bit clock sequence is initialized from Random.nextInt(), while the 48 bit node portion is generated using SecureRandom and has its multicast bit set. The resulting UUID is converted to its standard string representation.
We have now just reversed every single part of the PX Android SDK. Now we can map out what each collector actually does.
[
{
"t": "PX329", // payload type tag (first request = PX315)
"d": {
"PX330": "new_session", // new session marker
"PX1214": "(redacted, androidid)", // androidId
"PX91": 1080, // display width (px)
"PX92": 2138, // display height (px)
"PX21215": 4, // screen brightness
"PX316": false, // SIM present (getSimState() != 1; 1 = ABSENT)
"PX318": "34", // Android SDK version (Build.VERSION.SDK_INT)
"PX319": "4.19.278-g7b0944645172-ab10812814", // kernel/system-OS version (systemOsVersion โ not traced)
"PX320": "SM-N935F", // device model
"PX339": "samsung", // device manufacturer
"PX321": "gracerlte", // device codename
"PX323": 1790687148, // current timestamp (ms)
"PX322": "Android", // OS
"PX337": true, // has GPS hardware
"PX336": true, // has gyroscope
"PX335": true, // has accelerometer
"PX334": false, // has ethernet hardware
"PX333": true, // has touchscreen
"PX331": true, // has NFC hardware
"PX332": true, // has WiFi
"PX421": "false", // root check, any known su/Superuser path exists
"PX442": "false", // Build.TAGS contains "test-keys"
"PX21218": "[]", // collected touch detection data
"PX21217": "[]", // collected device motion data
"PX21224": "false", // touch detection allowed by detection policy
"PX21221": "false", // true only if touch AND device-motion detection allowed
"PX317": "WiFi", // connection/network type
"PX344": "missing_value", // eVar.f7638e, else "missing_value"
"PX347": "[\"en_US\"]", // device language
"PX343": "Unknown", // network type (NETWORK_UNKNOWN -> "Unknown")
"PX415": 90, // battery percentage
"PX413": "good", // battery health
"PX416": "USB", // power source type (AC / USB / Wireless)
"PX414": "charging", // battery charging status
"PX419": "Unknown", // battery technology
"PX418": 24.9, // battery temperature (รท10)
"PX420": 4.25, // battery voltage (รท1000)
"PX340": "v4.0.1", // SDK version
"PX342": "8.31.0", // app version
"PX341": "(name of app, redacted)", // app name
"PX348": "(package name, redacted)", // package name
"PX1159": false, // is the app an Android Instant App
"PX345": 0, // application resume counter
"PX351": 1, // elapsed milliseconds since Kf.a initialization
"PX326": "000001a0-ed45-7974-0000-000000000001", // generated time-based UUID (uniqueString)
"PX327": "000001A0", // derived from PX326 (upper-cased first segment of uniqueString)
"PX328": "F7C3B1E83FA73B346A8BCC09E475F6D7EC631962", // SHA-1 of deviceModel + uniqueString + PX327
"PX259": 1790687162220, // timestamp from the appc challenge (list[2])
"PX256": "f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6,f189fba0d564ec699b4cc067ccd1ef25431f69963601bfdbf1b06a6d163379f6", // hash component from appc (list[3])
"PX257": "1397566531", // appc result, deviceModel through the arithmetic VM, XORed
"PX1208": "[]", // not traced, empty.
"PX21219": "{}" // not traced, empty.
}
}
]
The defensive side
Now that we've fully reversed PerimeterX's Android SDK, we can move onto the defensive side of the coin.
Firstly, the obfuscation is pretty mediocre. PerimeterX's Web SDK is a whole different beast. The Android SDK is a flat base64 device snapshot, the web sensor is an obfuscated, self rotating, XOR ciphered weapon that renames every field roughly every hour. On the Android SDK, they do have identifier mangling, which does make it more difficult for the naive reverser to figure out what each JSON key means, but there are definitely better ways to make this more difficult for even experienced reversers. If I had to suggest one thing to PerimeterX, it'd be to move in the direction their Web SDK did. They could take a route like reCaptcha mobile did and serve a rotating bytecode VM via their first collection payload that the device has to execute.
The appc challenge itself is a nice touch, and I think it's a good add, but again, since there's minimal obfuscation it defeats the purpose of adding it, as it isn't all too hard to find. Rotating the app challenge logic, not just the values, would make this much more difficult for an attacker to reverse. Moving the appc logic into a rotating bytecode VM would be my choice. This raises the cost of reversing, and it makes it much more difficult for an attacker to maintain a solver for it, and it must be polymorphic (rotating opcodes, bytecode tape, etc) so the attacker doesn't just get to solve once and survive each update.
Encrypting or XORing the payload field adds another layer of reversing and I think that while it'd be good to add, it'd only buy PerimeterX a little time, however, base64 encoding the payload, which is the key to solving it, is like a locked safe with the combination written on the outside.
Another thing I noticed is they have touch/movements in the payload, but every single PX enforced app I've seen has not used the motion data/touch detection data. This should be a non-negotiable as creating realistic looking motion/touches can be quite difficult depending on how good the server-side detection is. We know PerimeterX has this capability as we reversed the JSON keys for it, but the consumers themselves aren't enabling it. The behavioural signals are the strongest and hardest to forge, but it does carry privacy/battery/false positive costs, which is likely why consumers decide to disable it. So the fix would be to make it cheap and compliant to enable, and defaulting it on where consent allows.
There also isn't much collection going on, so if they're going this route with low entropy device snapshots they must make it so it is not the primary detection signal.
Now all this comes with a catch, and I've been circling it so I'll say it outright. Client-side hardening (obfuscation, encryption, VM) only ever buys time, so the durable detection must live server-side. The client's real job is to emit signals that are expensive to forge, and rotate faster than a solver can keep up.
That's it for this post, we've dissected the Android SDK, and we've talked about what PX can improve on. Stay tuned for the next post.