Analyzing Akamai BMP 4.1.3 - Part 3
| PART 1 | PART 2 | App showcase: Iberia 14.81.0 |
In part 2 we reversed the native crypto pipeline β AES-128-CBC, HMAC-SHA256, RSA key exchange, MT19937. We know how the data is encrypted. Now going look at java layer.
1. The Encoding Pipeline
Sensor float arrays (accelerometer, gyroscope, magnetometer) go through a multi-stage compression pipeline before serialization:
float[] raw_events
β
βΌ
truncate to prev_power_of_2(count)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββ
β Try BOTH paths, pick shorter β
β (threshold: 20 chars) β
βββββββββββββββββββββ¬βββββββββββββββββββββββ€
β Path A (prefix 2) β Path B (prefix 1) β
β Direct quantize β DCT-II + shrink(0.6) β
β β extract DC coef β
β find min/max β find min/max of rest β
β quantize(60,'A') β quantize(60,'A') β
β RLE encode β RLE encode β
β CRC32(rle) β CRC32(rle) β
βββββββββββββββββββββΌβββββββββββββββββββββββ€
β 2;min;max;crc;rle β 1;min;max;dc;crc;rle β
βββββββββββββββββββββ΄βββββββββββββββββββββββ
Relevant classes:
| Class | Alias | Role |
|---|---|---|
C0020KE |
EG.KE | Encoding orchestrator |
C0015Gx |
EG.Gx | Quantize / shrinkCoef |
C0040R |
EG.R | RLE encode/decode |
C0033Ks |
EG.Ks | DCT-II transform |
C0049VO |
EG.VO | Custom CRC32 |
1.1 CRC32
It uses a hardcoded 256-entry lookup table with a custom polynomial. The CRC is computed on the RLE-encoded string
CRC32_TABLE = [
3523407757, 2768625435, 1007455905, 1259060791,
3580832660, 2724731650, 996231864, 1281784366,
# ... 256 entries total
]
def akamai_crc32(s):
crc = 0
for ch in s:
idx = (crc & 0xFF) ^ ord(ch)
crc = ((crc >> 8) ^ CRC32_TABLE[idx]) & 0xFFFFFFFF
return crc
The initial value is 0, you canβt use any ots-CRC library β you need the exact table.
1.2 RLE Encoding
Simple one, standard run-length encoding on the quantized character string:
def rle_encode(s):
# "AAABBC" β "3A2BC"
# count=1 β no prefix
1.3 Quantization
Maps float values to 60 bins across ASCII range 65β125 (A through }):
Characters . (46) and \ (92) are swapped via a function called vYfAM() to avoid serialization conflicts. Since \ falls within the range [65, 125] but . doesnβt, in practice only \ gets swapped to ..
Effective charset: A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ . ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z { | }
1.4 DCT-II Transform
C0033Ks.dqjarL() implements a standard Type-II Discrete Cosine Transform using the split-radix recursive algorithm. Requires power-of-2 input length.
The code is buried under hundreds of lines of anti-tamper dead code β blocks that check variables like JBm and jfT against constants, branching into unreachable infinite loops. Strip all of that and the actual DCT is textbook.
After DCT, shrinkCoef(coeffs, 0.6) zeros out any coefficient whose absolute value is below the 60th percentile β lossy compression that reduces the RLE output by 20+ characters.
The encoder (C0020KE) tries both raw quantize and DCT, picks the shorter output. If DCT doesnβt save at least 20 characters raw wins.
1.5 Orientation Data (keys -142, -144)
C0005GA (EG.GA) collects accelerometer, gyroscope, and magnetometer events.
Processing:
- Truncate events to
prev_power_of_2(count) - Compute orientation via
SensorManager.getRotationMatrix()+getOrientation() - Convert radians to degrees, negate azimuth and pitch
- Encode 4 arrays: azimuth, pitch, roll, timing deltas
Key -142 (data): {azimuth_enc}:{pitch_enc}:{roll_enc} β 3 axes joined by :
Key -144 (summary): timing delta encoding (same pipeline, shrink=0.0)
1.6 Motion Data (keys -143, -145)
C0009GN (EG.GN / McP.IIT) β same pipeline but 9 axes: gravity XYZ, accelerometer XYZ, gyroscope XYZ.
2. The Feistel Cipher
C0004F implements a 16-round Feistel network. for checksumming in key -115.
Round function:
\[f(v, k, r) = v \oplus \mathrm{ROL}_{32}(k, r)\]def feistel_encode(value_64, key_32):
lo, hi = value_64 & 0xFFFFFFFF, (value_64 >> 32) & 0xFFFFFFFF
for r in range(16):
hi, lo = lo, (hi ^ f(lo, key_32, r)) & 0xFFFFFFFF
return (hi << 32) | lo
Used by CYFManager.GQRJZH():
def gqrjzh(touch_duration, event_count, elapsed_time):
packed = (touch_duration << 32) | event_count
return feistel_encode(packed, elapsed_time)
The class name is literally "FeistelCipher" (decoded from the obfuscated string) "pzD~s\\FJSoM\`L" via yet another XOR table β this one using constant +111 instead of kpRβs +88
2.1 stringToInt β ASCII Checksum
C0034Kw.stringToInt() β used in key -100:
def string_to_int(s):
return sum(ord(c) for c in s if ord(c) < 128)
Thatβs Sum of ASCII values. Appended to the device fingerprint as integrity check:
\[\text{key}_{-100} = \text{fingerprint} \;\Vert\; \text{","} \;\Vert\; \text{stringToInt(fp)} \;\Vert\; \text{","} \;\Vert\; \text{Random.nextInt()} \;\Vert\; \text{","} \;\Vert\; \lfloor\text{initTS} / 2\rfloor\]2.2 Touch Events (key -117)
C0022KG (EG.KG). Format per event β 8 fields
{type},{delta_ms},{x},{y},{pointer_count},1,{tool_type},-1;
The 1 (source) and -1 (edge_flags) are hardcoded baked into kpR-decoded separator strings:
kpR("3&*")β",1,"kpR("3:7\u001f")β",-1;"
Event types:
| Android action | type |
|---|---|
ACTION_DOWN (0), ACTION_POINTER_DOWN (5) |
2 |
ACTION_UP (1), ACTION_POINTER_UP (6) |
3 |
ACTION_MOVE (2) |
1 |
Delta is relative to the previous eventβs MotionEvent.getEventTime(). Coordinates rounded to integer via DecimalFormat("#").
Max: 50 MOVE + 50 UP/DOWN events.
2.3 Lifecycle Events (key -103)
{event_type},{timestamp_ms};
Resume = 3, Pause = 2. Timestamp is System.currentTimeMillis() β absolute wall clock, not uptime. Max 10 events.
2.4 Text Input (key -108)
{event_type},{delta_ms},{view_id_checksum}[,{key_type}];
key_type omitted when -1 (focus events). Values: 0=lowercase, 3=space, 4=delete, 5=uppercase, 1=paste. Password fields force key_type=0.
2.5 Device Fingerprint (key -100)
C0028KT.getInfo() builds ~39 comma-separated fields. Some are URL-encoded via C0034Kw.encode() β a custom percent-encoder that passes printable ASCII (33β126) except ", %, ', ,, \.
The fingerprint includes: screen dimensions, battery, locale, Build properties (MODEL, MANUFACTURER, FINGERPRINT, HARDWARE, TAGS, TYPE, etc.), Android ID, keyboard status, ADB status, and more.
2.6 Three String Deobfuscation Variants
All three use the same structure β XOR table with different constants:
| Method | Class | Constant | Used for |
|---|---|---|---|
kpR |
C0018K |
+88 |
Most Java strings |
kfF |
C0004F |
+111 |
Feistel internals |
GVJ |
C0040R |
+120 |
DCT/quantize internals |
def build_table(constant, size=32767):
table, prev = [0] * size, 3
for i in range(size):
val = prev ^ i
prev = (prev + val + constant) % 63
table[i] = prev
return table
3. Server-side things
Well, things are a bit out of my league here. I donβt work with JavaScript or web logic; I only work in the mobile field. Also, I donβt know if these fields are trivial and if the server accepts a sensor without them. You should test this and check it yourself. In Iberia, two of these fields come from the request responses, and the other of them is from the output of an Akamai JavaScript that runs on a webview.
ServerSignal = serversidesignal from GET /_bm/get_params
CPRToken = token from GET /_bm/get_info + ~ + result from js challenge
CPRSignal = locally generated: System.currentTimeMillis() + "|" + SystemClock.uptimeMillis() + "|"
For testing i used a Frida script that captures the cprtoken for me; perhaps there are solvers for handle it, I have no idea.
'use strict';
var done = false;
Java.perform(function() {
var Mon = Java.use('com.cyberfend.cyfsecurity.CYFMonitor');
Mon.getSensorData.implementation = function() {
var header = this.getSensorData();
if (!done) {
done = true;
var parts = header.split('$');
var s6 = parts[6] || '';
var p6 = s6.split('&&&');
console.log('SERVER_SIGNAL=' + (parts[5] || ''));
console.log('CPR_SIGNAL=' + (p6[0] || ''));
console.log('DEVICE_ID=' + (p6[1] || ''));
console.log('CPR_TOKEN=' + (p6[2] || ''));
}
return header;
};
console.log('[CPR] Ready β do the login');
});
You can capture a CPR token from a real device and then use the generator without problems; it worked for 150 login requests using 5 cprtokens from 5 real devices
4. Finally, end
The full sensor data flow:
Java collectors β 28 Pair<String,String>
β
βΌ
native buildN (Part 2)
β serialize with "-1,2,-94," separator
β append -164,SECURITY_PATCH
β append -170,MT_verification
β AES-128-CBC + HMAC-SHA256 + base64
βΌ
"6,a,{rsa1},{rsa2}${b64}${timing}"
β
βΌ
Java buildSensorData
β append $[3] PoW
β append $[4] CCA token
β append $[5] server signal
β append $[6] cpr_signal&&&device_id&&&cpr_token&&&4.1.3
βΌ
X-acf-sensor-data header
4.1 PoW
A simple generator that produces valid sensor data, golang writed, fully cryptographically valid
Notes: I barely had time to test this, i did some tests, but not many, unfortunately Iβm very busy working on paid projects, and at the same time I didnβt want to leave you waiting a month for part 3. There might be something to improve if youβre looking for a perfect solver. This is just an educational article, it serves as a guide. This doesnβt mean that bmp413.go doesnβt work, it just means that itβs definitely not something I recommend putting into large scale. Thank you to the more than 300 readers, I love you all.
Telegram: @vxigl Discord: @xve_e