Inside PerimeterX's (HUMAN Security) Android SDK - v5.0.0
So in my last post we took a look at PerimeterX's Android v4.0.1 SDK, and today we'll be taking a look at their newest SDK, 5.0.0. If you haven't read the first blog, I highly recommend you do so as we'll be referring to it throughout this post quite a bit.
Firstly, the same two requests are present (collector-*.perimeterx.net), same structure but the payload parameter is no longer base64 encoded, there's a new parameter called stag, and the response of the first request has a new response value called hid.
payload=aUkQRhAIEGJqAQMHEB4QVhAISRBiagEBAhAIEFxXRW1BV0FBW11cEB4QYmoAAwAGAxAIaUkQV0RXXEYQCBBeU0FGbVNCQm1eU0dcUVoQHhBWR0BTRltdXG1fQRAIBgACTx5JEFdEV1xGEAgQXlNBRm1TQkJtVF1AV1VAXUdcVhAeEFZHQFNGW11cbV9BEAgDAQVPHkkQV0RXXEYQCBBeU0FGbVNCQm1QU1FZVUBdR1xWEB4QVkdAU0ZbXVxtX0EQCB8DTx5JEFdEV1xGEAgQXlNBRm1TQkJtW1xTUUZbRFcQHhBWR0BTRltdXG1fQRAIHwNPHkkQV0RXXEYQCBBeU0FGbVNCW21AV0NHV0FGEB4QVkdAU0ZbXVxtX0EQCB8DTx5JEFdEV1xGEAgQXlNBRm1HQVdAbUZdR1FaEB4QVkdAU0ZbXVxtX0EQCB8DT28eEGJqAwADBhAIEFZTCgoLVwcGVwBXBFcFBQcQHhBiagADAAELEAgQU1xWQF1bVm1bVhAeEGJqCwMQCAMCCgIeEGJqCwAQCAADAQoeEGJqAAMAAwcQCAYeEGJqAQMEEAhUU15BVx4QYmoBAwoQCBABBhAeEGJqAQMLEAgQBhwDCxwABQofVQVQAgsGBgQGBwMFAB9TUAMCCgMACgMGEB4QYmoBAAIQCBBhfx98CwEHdBAeEGJqAQELEAgQQVNfQUdcVRAeEGJqAQADEAgQVUBTUVdAXkZXEB4QYmoAAwAGARAIEEFTX0FHXFVXSktcXUEKCgsCEB4QYmoAAwAGBhAIEEBXVlRbXBAeEGJqAAMABgcQCBBBU19BR1xVEB4QYmoAAwAGBBAIEEABHwIcBB8DAgYKCwoBBhAeEGJqAAMABgUQCBBnYgNzHAABAwMCBxwCAgMccAAQHhBiagADAAYG^KEAgQQVNfQUdcVSW4dVUBTUVdAXkZXSkpuHVVAU1FXQF5GVwgKHAIcAm4dYAMEfGVuHXwLAQd0amphBnBgeQAIR0FXQG4dQFdeV1NBVx9ZV0tBEB4QYmoAAwAGCxAIAwUCAQoACwMGAA}DICAh4QYmoAAwAHNAhAIEFNcVkBdW1YKfUEdbXlYQHhBiagEAARAIAwULAgoEA8QYEBR4QYmoBAAAQCBBzXFZAXVtWEB4QYmoBAQUQCEZAR1ceEGJqAQEEEAhGQEdXHhBiagEBBxAIRkBHVx4QYmoBAQYQCFRTXkFXHhBiagEBARADIRkBHVx4QYmoBAQNMQCEZAR1ceEGJqAQEAEAhGQEdXHhBiagYAAxAIEFRTXkFXEB4QYmoGBgAQCBBUa>U15BVxAeEGJqAAMAAwoQCBBpbxAeEGJqAAMAAwUQCGlvHhBiagADAAAGEAgQVFNeQVcQHhBiagADAAADEAgQVFNeQVcQHDhBiagEDBRAIEGVbdFsQHhBiagEGBhA`IEF9bQUFbXFVtRFNeR1cQHhBiagEGBRKAIEGluEFdcbWdhbhBvEB4QYmoBBgEQCBBnXFlcXUVcEB4QYmoGAwcQCAQCHhBiagYDARAIEFVdXVYQHhBiagYDBBAIEGdhcBAeEGJqBgMGEAgQUVpTQFVbXFUQHhBiagYDCxAIEGdcWVxdRVwQHhBiagYDChAIAAccBh4QYmoGAAIQCAEcCgAKHhBiagEGAhAIEEQHHAIcAhAeEGJqAQYAEAgQBRwABRwAEB4QYmoBBgMQCBBlU0tUU1tAEB4QYmoBBgoQCBBRXV8cRVNLVFNbQBxFU0tUU1tAEB4QYmoDA}GwcLEAhUU15BVx4QYmoBBgcQCAIeEGJKqAQcDEAgDCh4QYm7oBAAQQCBACAgICAgNTAh9UBVEFH1cHVgcfAgICAh8CAgICAgICAgICAgMQHhBiagEABRAIEAICAgICA3MCEB4QYmoBAAoQCBB2dwAKAAZxAwsDcQcHBAMFAQICdHZwcHZwdHAHAXR0BQd2BgcCcHZzEB4QYmoDAAIKEAgQaW8QHhB7iagADAAMLEAgQSU8QT09v&uuid=c111b2c2-8455-415e-9b14-111ba0626e66&appId=(redacted)&tag=mobile&ftag=22&stag=5.0.0&sts=1604064986000
Reversing the payload encoding
So the first thing I want to dig into is what encoding/encryption is being used for the payload parameter since that's where all the collector keys live, then we can establish whether or not the collector keys we reversed last time stand, or if they've rotated them.
I'll be searching for PX3 as last time that's what some of the JSON keys started with, so we'll see how that goes.
Bingo, the JSON key names are still in plaintext so we can work backwards. We need to find what builds the payload parameter.
JSONObject jSONObject11 = new JSONObject();
jSONObject11.putOpt(j30.b.f76988j.a(), str8);
jSONObject11.putOpt(j30.b.f76981d.a(), qVar3.f70004b);
jSONObject11.putOpt(j30.b.f76992m.a(), qVar3.f70003a.f62389b);
jSONObject11.putOpt(j30.b.f76982e.a(), "mobile");
jSONObject11.putOpt(j30.b.f76983f.a(), "22");
jSONObject11.putOpt(j30.b.f76984g.a(), HumanSecurity.INSTANCE.sdkVersion());
jSONObject11.putOpt(j30.b.f76985h.a(), qVar3.f70003a.f62394g);
This is exactly what we need. str8 must be the payload parameter, let's find what str8 is.
String str8 = t72.n.LIST_PREFIX + jSONObject + ']';
String str9 = qVar3.f70004b;
String str10 = qVar3.f70003a.f62394g;
str9.getClass();
str10.getClass();
if (str8.length() > 0) {
String c14 = m0.c(m0.b(str10), 10);
String b14 = m0.b(m0.c(str8, 50));
int length = b14.length();
String c15 = m0.c(m0.b(str9), 10);
ArrayList arrayList5 = new ArrayList();
int length2 = c14.length();
int i19 = -1;
int i24 = 0;
while (i24 < length2) {
int charAt = c15.charAt((i24 / c15.length()) + 1) * c15.charAt(i24 >= c15.length() ? i24 % c15.length() : i24);
if (charAt > i19) {
i19 = charAt;
}
i24++;
}
int length3 = c14.length();
for (int i25 = 0; i25 < length3; i25++) {
int charAt2 = c15.charAt((i25 / c15.length()) + 1) * c15.charAt(i25 % c15.length());
if (charAt2 >= length) {
charAt2 = (int) (((charAt2 / i19) * (length - 1)) + 0);
}
while (arrayList5.contains(Integer.valueOf(charAt2))) {
charAt2++;
}
arrayList5.add(Integer.valueOf(charAt2));
}
List B0 = z.B0(arrayList5);
char[] charArray = c14.toCharArray();
charArray.getClass();
StringBuilder sb4 = new StringBuilder();
int length4 = c14.length();
int i26 = 0;
for (int i27 = 0; i27 < length4; i27++) {
sb4.append(b14.substring(i26, (((Number) B0.get(i27)).intValue() - i27) - 1));
sb4.append(charArray[i27]);
i26 = (((Number) B0.get(i27)).intValue() - i27) - 1;
}
sb4.append(b14.substring(i26));
str8 = sb4.toString();
}
Now we've got that, but there's still a few other things we need. First, I want to know what that t72.n.LIST_PREFIX is.
public static final String LIST_PREFIX = "[";
Now we can look at what happens to str8 inside the following block:
String c14 = m0.c(m0.b(str10), 10);
String b14 = m0.b(m0.c(str8, 50));
At first glance this looks pretty confusing, so let's reverse the two helper functions.
First, m0.b():
public static String b(String str) {
str.getClass();
Base64.Encoder encoder = Base64.getEncoder();
byte[] bytes = str.getBytes(h03.b.f71948b);
bytes.getClass();
String encodeToString = encoder.encodeToString(bytes);
encodeToString.getClass();
return encodeToString;
}
This is simply base64 encoding, so m0.b(str) is equivalent to Base64(str).
Okay and now time for m0.c():
public static String c(String str, int i14) {
ArrayList arrayList = new ArrayList(str.length());
for (int i15 = 0; i15 < str.length(); i15++) {
arrayList.add(Character.valueOf((char) (str.charAt(i15) ^ i14)));
}
return vw2.z.c0(arrayList, "", null, null, null, 62);
}
The important part is:
str.charAt(i15) ^ i14
This XORs every character in the string with the supplied integer.
Therefore:
m0.c(str, 50)
XORs every character with 50, while:
m0.c(str, 10)
XORs every character with 10.
There is also a third value:
String c15 = m0.c(m0.b(str9), 10);
which follows the exact same Base64 -> XOR 10 transformation as c14.
At this point we've confirmed that the payload isn't simply Base64 anymore. The original JSON is first XOR obfuscated with a fixed value of 50, then base64 encoded.
But we're not done yet.
The code then uses c15 to calculate positions inside b14, and characters from c14 are inserted into those positions. This means there is another layer of obfuscation happening after the XOR + Base64 step.
str8 is the JSON payload, str10 is the sts value 1604064986000 hardcoded into the Java:
public volatile String f62394g = "1604064986000";
Finding the UUID seed
Next we need to go after str9.
Okay so, str9 is:
String str9 = qVar3.f70004b;
We need to find f70004b.
public q(d40.a aVar, String str, String str2, String str3, p pVar, JSONArray jSONArray) {
aVar.getClass();
str.getClass();
pVar.getClass();
jSONArray.getClass();
this.f70003a = aVar;
this.f70004b = str;
this.f70005c = str2;
this.f70006d = str3;
this.f70007e = pVar;
this.f70008f = jSONArray;
}
Now we need to find the calls to q.
new g30.q(this.f62463j, str38, str3, null, pVar2, (JSONArray) obj);
Okay, now we need to find what str38 is.
str2 = w.f62549j;
String str38 = str2;
Now we need to figure out what f62549j is.
String uuid = UUID.randomUUID().toString();
uuid.getClass();
f62549j = uuid;
Okay perfect. So str9 is just a randomly generated UUID. This has me thinking though, if this were truly a randomly generated UUID and it wasn't fed into the request somewhere, there'd be no way for the server to decode the payload parameter to get the signals which wouldn't make sense.
I have a hunch it's the UUID in the request payload, but let's not speculate and find out ourselves. I'm going to go back to where the request payload is built and find the uuid placement.
jSONObject11.putOpt(j30.b.f76981d.a(), qVar3.f70004b);
We've confirmed it. We've already established that f70004b is the randomly generated UUID, and now we've confirmed that the UUID parameter in the request payload is the same UUID and that's how PX is able to decode the payload parameter.
Writing the unscrambler
So now we can move on to actually creating the unscrambler, I'll be writing it in NodeJS.
After about 30 minutes, I finally wrote the decoder. The decompiler mangled the collapse branch. The real line scaled the position in floating point:
(charAt2.toDouble() / i19) * (length - 1)
then .toInt(), but the decompiler dropped the (double) promotions on the operands while keeping the outer (int) cast, so it rendered as:
(int)(((charAt2 / i19) * (length - 1)) + 0)
Read as Java, charAt2 / i19 is now integer division, and since charAt2 <= i19 always, that only ever yields 0 or 1, collapsing every injected position to the front or end instead of spreading them proportionally across the payload.
The decoder port I made round tripped fine on test data but refused to decode the real string. The tell was in the markers: 20 STS chars get injected, but only the 5 that XOR into non-base64 symbols are visible, the other 15 land back inside the base64 alphabet and hide. Those 5 visible markers sat at indices ~916โ1768, the back half, nowhere near the front/end cluster that integer division produces. Swapping to float division (Math.trunc((charAt2 / i19) * (length - 1))) put all 20 computed positions in that back half spread, the 5 visible ones matched their observed indices exactly, and the payload decoded clean. So let's dive into it.
[
{
"t": "PX315",
"d": {
"PX330": "new_session",
"PX21241": [
{
"event": "last_app_launch",
"duration_ms": 420
},
{
"event": "last_app_foreground",
"duration_ms": 137
},
{
"event": "last_app_background",
"duration_ms": -1
},
{
"event": "last_app_inactive",
"duration_ms": -1
},
{
"event": "last_api_request",
"duration_ms": -1
},
{
"event": "last_user_touch",
"duration_ms": -1
}
],
"PX1214": "(android id, redacted)",
"PX21239": "android_id",
"PX91": 1080,
"PX92": 2138,
"PX21215": 4,
"PX316": false,
"PX318": "34",
"PX319": "4.19.278-g7b0944645172-ab10812814",
"PX320": "SM-N935F",
"PX339": "samsung",
"PX321": "gracerlte",
"PX21243": "samsungexynos8890",
"PX21244": "redfin",
"PX21245": "samsung",
"PX21246": "r3-0.6-10489834",
"PX21247": "UP1A.231105.001.B2",
"PX21248": "samsung/gracerltexx/gracerlte:8.0.0/R16NW/N935FXXS4BRK2:user/release-keys",
"PX21249": 1703829142000,
"PX21250": "android-build",
"PX323": 1790863467,
"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": 60,
"PX413": "good",
"PX416": "USB",
"PX414": "charging",
"PX419": "Unknown",
"PX418": 25.4,
"PX420": 3.828,
"PX340": "v5.0.0",
"PX342": "7.27.2",
"PX341": "(app name, redacted)",
"PX348": "(package name, redacted)",
"PX1159": false,
"PX345": 0,
"PX351": 18,
"PX326": "000001a0-f7c7-e5d5-0000-000000000001",
"PX327": "000001A0",
"PX328": "DE2824C191C55617300FDBBDBFB53FF75D450BDA",
"PX1208": "[]",
"PX21219": "{}"
}
}
]
Okay so, the good news is the keys we reversed for v4.0.1 are the same. I won't be re-reversing the keys we already did in the first blog post as that'll just be wasting time, and instead I'll be diff-checking my v4.0.1 payload with this new one to figure out what keys are new.
{ PX21239, PX21241, PX21243, PX21244, PX21245, PX21246, PX21247, PX21248, PX21249, PX21250 }
Perfect, now we have something to work with. PX21217 is now an array instead of a string literal with "[]", and the t value also changed from PX329 to PX315. Now let's start digging into these new keys.
The new keys
The first one we'll be going after is PX21239.
jSONObject3.putOpt("PX21239", gVar2.f85471b);
So PX21239 comes from f85471b, let's find out what that is.
public g(String str, String str2, int i14, int i15, int i16, String str3, boolean z14, b40.b bVar, Integer num, String str4, int i17, String str5, String str6, String str7, String str8, String str9, String str10, String str11, String str12, String str13, String str14, long j14, String str15, boolean z15, boolean z16, boolean z17, boolean z18, boolean z19, boolean z23, boolean z24, a aVar, String str16, long j15, boolean z25, boolean z26, ArrayList arrayList, ArrayList arrayList2) {
str.getClass();
str2.getClass();
str5.getClass();
str16.getClass();
arrayList.getClass();
arrayList2.getClass();
UUID.randomUUID().toString().getClass();
this.f85470a = str;
this.f85471b = str2;
this.f85472c = i14;
this.f85473d = i15;
this.f85474e = i16;
this.f85475f = str3;
this.f85476g = z14;
this.f85477h = bVar;
this.f85478i = num;
this.f85479j = str4;
this.f85480k = i17;
this.f85481l = str5;
this.f85482m = str6;
this.f85483n = str7;
this.o = str8;
this.f85484p = str9;
this.f85485q = str10;
this.f85486r = str11;
this.f85487s = str12;
this.f85488t = str13;
this.f85489u = str14;
this.f85490v = j14;
this.f85491w = str15;
this.f85492x = z15;
this.f85493y = z16;
this.f85494z = z17;
this.A = z18;
this.B = z19;
this.C = z23;
this.D = z24;
this.E = aVar;
this.F = str16;
this.G = j15;
this.H = z25;
this.I = z26;
this.J = arrayList;
this.K = arrayList2;
}
Okay so f85471b is str2 in the calls, the second argument. Now we need to find what creates g.
new g(str35, str41, i33, i34, i24, str30, z43, bVar2, num5, str33, i25, str32, str26, str27, str34, str31, str28, str29, str36, str37, str38, j17, str39, z44, z48, z45, i26 == 0, z46, z47, z49, aVar5, str40, j18, z53, z39, arrayList, arrayList3);
Brilliant, so now we need to find str41.
String str56 = dVar2.f85449m;
str41 = str56;
So str41 comes directly from dVar2.f85449m.
Now we need to find where f85449m gets its value.
This is where the coroutine state object comes into play. f85449m is one of the fields stored inside the generated coroutine continuation:
public String f85449m;
We can see that the value is saved into this field before a suspension point:
dVar2.f85449m = str802;
and later restored from it:
String str71 = dVar2.f85449m;
So f85449m isn't actually generating the value itself. It's just being used by the coroutine to preserve str802 across a suspension.
Following str802 backwards gets us to:
String str802 = (String) mVar2.f109994b;
So now we know that f85449m ultimately contains the second value from mVar2.
Now let's find out where mVar2 comes from.
m mVar2 = (m) obj;
This obj is the result returned from the previous suspend call, so we need to follow that call back to its source.
Eventually we reach:
Serializable a14 = a(context2, dVar2);
So a() is what produces the m object.
Inside a() we find:
return new m(
Settings.Secure.getString(
context.getContentResolver(),
sspppss.sspppps.o006Foo006Foo
),
sspppss.sspppps.o006Foo006Foo
);
The m class is just a simple pair, with f109993a being the first value and f109994b being the second.
Therefore the second value, which eventually becomes f85449m, comes from:
sspppss.sspppps.o006Foo006Foo
Let's check what that constant actually contains.
public static final String o006Foo006Foo = "android_id";
Therefore:
"PX21239": "android_id"
is simply the identifier type, telling us that PX1214 is the Android ID.
Interestingly, the exact same constant is also passed into Settings.Secure.getString(), meaning the first value of the pair is retrieved using the "android_id" key:
Settings.Secure.getString(
context.getContentResolver(),
"android_id"
)
So we've now got both sides of this pair:
PX1214 -> Android ID value
PX21239 -> "android_id"
That's PX21239 solved. Now we need to find PX21241.
jSONObject2.putOpt("PX21241", oVar.f69989a.f70008f);
Now we need to find f70008f.
public final JSONArray f70008f;
public q(d40.a aVar, String str, String str2, String str3, p pVar, JSONArray jSONArray) {
aVar.getClass();
str.getClass();
pVar.getClass();
jSONArray.getClass();
this.f70003a = aVar;
this.f70004b = str;
this.f70005c = str2;
this.f70006d = str3;
this.f70007e = pVar;
this.f70008f = jSONArray;
}
Now we need to find the usage of q, and look for the same argument amount and we're after the last argument.
g30.q qVar = new g30.q(this.f62463j, str38, str3, null, pVar2, (JSONArray) obj);
Now we need to look for obj.
obj = gVar.d(zVar);
Now let's find gVar.d().
public final Object d(ax2.d dVar) {
a aVar;
int i14;
d.b bVar;
if (dVar instanceof a) {
aVar = (a) dVar;
int i15 = aVar.f95793m;
if ((i15 & Integer.MIN_VALUE) != 0) {
aVar.f95793m = i15 - Integer.MIN_VALUE;
Object obj = aVar.f95791k;
zw2.a aVar2 = zw2.a.f122258a;
i14 = aVar.f95793m;
if (i14 != 0) {
p.b(obj);
b bVar2 = new b(2, null);
aVar.f95793m = 1;
obj = i.f(f95804b, bVar2, aVar);
if (obj == aVar2) {
return aVar2;
}
} else {
if (i14 != 1) {
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
p.b(obj);
}
m mVar = (m) obj;
Map map = (Map) mVar.f109993a;
long longValue = ((Number) mVar.f109994b).longValue();
JSONArray jSONArray = new JSONArray();
bx2.c cVar = h.f95817j;
cVar.getClass();
bVar = new d.b();
while (bVar.hasNext()) {
h hVar = (h) bVar.next();
Long l14 = (Long) map.get(hVar);
jSONArray.put(new JSONObject().put("event", hVar.f95818a).put("duration_ms", l14 != null ? longValue - l14.longValue() : -1L));
}
return jSONArray;
}
}
aVar = new a(this, dVar);
Object obj2 = aVar.f95791k;
zw2.a aVar22 = zw2.a.f122258a;
i14 = aVar.f95793m;
if (i14 != 0) {
}
m mVar2 = (m) obj2;
Map map2 = (Map) mVar2.f109993a;
long longValue2 = ((Number) mVar2.f109994b).longValue();
JSONArray jSONArray2 = new JSONArray();
bx2.c cVar2 = h.f95817j;
cVar2.getClass();
bVar = new d.b();
while (bVar.hasNext()) {
}
return jSONArray2;
}
}
Perfect, now we can conclude that PX21241 just generates an array of app-event timing information. On to PX21243.
jSONObject3.putOpt("PX21243", gVar2.f85489u);
Okay now we need to find f85489u.
this.f85489u = str14;
Now we can find what str14 is by matching the argument amount.
str14 is str38 so let's find that.
str38 = str44;
Now str44.
String str44 = dVar2.B;
Okay, since B is one of the fields in the generated coroutine state object, we need to find where the value stored in it comes from.
String str12;
str12 = dVar2.B;
String str72 = str12;
dVar2.B = str72;
This is just the coroutine saving and restoring a local variable across a suspension point. B isn't the actual source of the value, it is just being used as temporary storage.
Following str12 backwards gives us:
str12 = Build.HARDWARE;
So the actual source of PX21243 is Build.HARDWARE.
The important thing to understand here is that these dVar2.* fields are compiler generated coroutine state. When you see something like:
dVar2.B = str72;
you shouldn't start tracing B itself. Instead trace the variable assigned to it. Now let's map the rest. The remaining seven keys all follow this exact pattern, a Build.* value stashed in a coroutine-state field (dVar2.*) and restored across a suspension point. Rather than walk the identical trace eight times, here's where each one lands:
| Key | Source |
|---|---|
| PX21244 | Build.BOARD |
| PX21245 | Build.BRAND |
| PX21246 | Build.BOOTLOADER |
| PX21247 | Build.DISPLAY |
| PX21248 | Build.FINGERPRINT |
| PX21249 | Build.TIME |
| PX21250 | Build.USER |
The second request and appc
We've now reversed the new JSON keys in the collection payload, and we can move to the second request to confirm whether or not appc has changed and if there's any new keys in the second request. First I'll be decoding it.
[
{
"t": "PX329",
"d": {
"PX330": "new_session",
"PX21241": [
{
"event": "last_app_launch",
"duration_ms": 10141
},
{
"event": "last_app_foreground",
"duration_ms": 9858
},
{
"event": "last_app_background",
"duration_ms": -1
},
{
"event": "last_app_inactive",
"duration_ms": -1
},
{
"event": "last_api_request",
"duration_ms": -1
},
{
"event": "last_user_touch",
"duration_ms": -1
}
],
"PX1214": "(android id, redacted)",
"PX21239": "android_id",
"PX91": 1080,
"PX92": 2138,
"PX21215": 4,
"PX316": false,
"PX318": "34",
"PX319": "4.19.278-g7b0944645172-ab10812814",
"PX320": "SM-N935F",
"PX339": "samsung",
"PX321": "gracerlte",
"PX21243": "samsungexynos8890",
"PX21244": "redfin",
"PX21245": "samsung",
"PX21246": "r3-0.6-10489834",
"PX21247": "UP1A.231105.001.B2",
"PX21248": "samsung/gracerltexx/gracerlte:8.0.0/R16NW/N935FXXS4BRK2:user/release-keys",
"PX21249": 1703829142000,
"PX21250": "android-build",
"PX323": 1790863467,
"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": 60,
"PX413": "good",
"PX416": "USB",
"PX414": "charging",
"PX419": "Unknown",
"PX418": 25.4,
"PX420": 3.828,
"PX340": "v5.0.0",
"PX342": "7.27.2",
"PX341": "(app name, redacted)",
"PX348": "(package name, redacted)",
"PX1159": false,
"PX345": 0,
"PX351": 18,
"PX326": "000001a0-f7c7-e5d5-0000-000000000001",
"PX327": "000001A0",
"PX328": "DE2824C191C55617300FDBBDBFB53FF75D450BDA",
"PX259": 1790863491595,
"PX256": "fef57eae31c0043be740c0ae6ef9daa46720e0157fa8753bd49fab079ce19948,fef57eae31c0043be740c0ae6ef9daa46720e0157fa8753bd49fab079ce19948",
"PX257": "1409263586",
"PX1208": "[]",
"PX21219": "{}"
}
}
]
Diffing the keys gives us no new keys, so now we just need to look if the arithmetic VM is the same as the last version we reversed.
public static int a(int i14, int i15, int i16, int i17) {
int i18 = i17 % 10;
int i19 = i18 != 0 ? i16 % i18 : i16 % 10;
int i24 = i14 * i14;
int i25 = i15 * i15;
switch (i19) {
case 0:
return i24 + i15;
case 1:
return i14 + i25;
case 2:
return i24 * i15;
case 3:
return i14 ^ i15;
case 4:
return i14 - i25;
case 5:
int i26 = i14 + 783;
return (i26 * i26) + i25;
case 6:
return (i14 ^ i15) + i15;
case 7:
return i24 - i25;
case 8:
return i14 * i15;
case 9:
return (i15 * i14) - i14;
default:
return -1;
The arithmetic VM is the exact same, just different variable names, but the function b has moved from the class, so we need to find that to verify it's still the same.
a40.b bVar2 = cVar5.f1235g;
if (bVar2 != null) {
String str47 = cVar9.f80284a.f85482m;
str47.getClass();
int i19 = bVar2.f1223c;
int i24 = bVar2.f1224d;
int i25 = bVar2.f1226f;
int i26 = bVar2.f1228h;
int a14 = a40.b.a(a40.b.a(i19, i24, i25, i26), bVar2.f1225e, bVar2.f1227g, i26);
byte[] bArr = new byte[4];
try {
byte[] bytes = str47.getBytes(h03.b.f71948b);
bytes.getClass();
bArr = bytes;
} catch (UnsupportedEncodingException unused) {
}
int i27 = (bArr.length < 4 ? 0 : ByteBuffer.wrap(bArr).getInt()) ^ a14;
We've now confirmed that the appc challenge is exactly the same. We've completely reversed 5.0.0 SDK, and it was only a few extra keys plus the permutation + XOR scramble of the payload.
Every collector, mapped
[
{
"t": "PX329", // payload type tag (second request; first request = PX315)
"d": {
"PX330": "new_session", // new session marker
"PX21241": [ /* app-event timings */ ], // per-event duration_ms since: last_app_launch / _foreground / _background / _inactive, last_api_request, last_user_touch
"PX1214": "(android id, redacted)", // Android ID value (Settings.Secure "android_id")
"PX21239": "android_id", // identifier TYPE for PX1214 (the "android_id" constant)
"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-...", // kernel/system-OS version (systemOsVersion - not traced)
"PX320": "SM-N935F", // device model
"PX339": "samsung", // device manufacturer
"PX321": "gracerlte", // device codename
"PX21243": "samsungexynos8890", // Build.HARDWARE
"PX21244": "redfin", // Build.BOARD
"PX21245": "samsung", // Build.BRAND
"PX21246": "r3-0.6-10489834", // Build.BOOTLOADER
"PX21247": "UP1A.231105.001.B2", // Build.DISPLAY
"PX21248": "samsung/gracerltexx/...", // Build.FINGERPRINT
"PX21249": 1703829142000, // Build.TIME
"PX21250": "android-build", // Build.USER
"PX323": 1790863467, // 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 (now a JSON array)
"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": 60, // battery percentage
"PX413": "good", // battery health
"PX416": "USB", // power source type (AC / USB / Wireless)
"PX414": "charging", // battery charging status
"PX419": "Unknown", // battery technology
"PX418": 25.4, // battery temperature (รท10)
"PX420": 3.828, // battery voltage (รท1000)
"PX340": "v5.0.0", // SDK version
"PX342": "7.27.2", // app version
"PX341": "(app name, redacted)", // app name
"PX348": "(package name, redacted)", // package name
"PX1159": false, // is the app an Android Instant App
"PX345": 0, // application resume counter
"PX351": 18, // elapsed milliseconds since Kf.a initialization
"PX326": "000001a0-f7c7-...", // generated time-based UUID (uniqueString)
"PX327": "000001A0", // derived from PX326 (upper-cased first segment of uniqueString)
"PX328": "DE2824C1...", // SHA-1 of deviceModel + uniqueString + PX327
"PX259": 1790863491595, // timestamp from the appc challenge (list[2])
"PX256": "fef57eae...", // hash component from the appc challenge (list[3])
"PX257": "1409263586", // appc result (deviceModel through the arithmetic VM, XORed)
"PX1208": "[]", // not traced, empty
"PX21219": "{}" // not traced, empty
}
}
]
Now we can move on to the defensive side.
The defensive look
Everything from my 4.0.1 SDK stands. They added the permutation + XOR scramble, and a few more JSON keys. As I said in my last post, this only buys them time from attackers, and the permutation + XOR scramble bought roughly 30 minutes of attacker time. Rotating the keys would've been a nice add, but ultimately it would've only taken a persistent reverser a few hours tops to re-reverse the keys. Rotating the keys/values (the XOR constant, the permutation seed, JSON keys) is weak because a parameterized solver just re-derives them on each rotation, the logic didn't change, only the inputs. What actually raises the cost is rotating the logic itself.
Now I want to bring this back, as I still think that the best course of action for PerimeterX is to move the logic into a bytecode VM, and making the client emit expensive to forge signals, then having it rotate faster than a solver can keep up, this is what truly raises the cost.
As you can see in this payload, the behaviour signals are also turned off for this app, which was the same for the last app we took a look at, and I stated previously that these 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.
The new keys we reversed are all low entropy, spoofable device strings. All the v5.0.0 engineering effort went into obfuscating the payload (~30 minutes for an attacker), and collecting more weak signals, while still leaving the behavioural signals off by default. They iterated the wrong axis. More obfuscation and more spoofable fields, none of which changes the forge resistance math, while the only thing that actually would (behavioural), stays disabled.
That's it for this post, hopefully you guys learned something, stay tuned for the next one.