A deep dive into Akamai’s mobile bot management SDK, from SSL pinning bypass to a working generator at scale. All the dead ends included.


Introduction

I’ve been meaning to look into Akamai’s mobile bot management for a while now. Most of the public research focuses on their web sensor, but their mobile SDK is a different beast entirely. After spending time digging through Android apps protected by Akamai BMP, I figured I’d document what I found - including the unexpected challenges that came with scaling.


Starting Point

The goal here is simple: understand how the X-Acf-Sensor-Data header is generated on Android. This header gets sent with API requests to prove the client is a legitimate mobile app and not some script.

First thing I did was grab the target APK and set up my intercept environment. Pixel 7a running Android 16, Burp proxy, Frida for runtime hooks. The usual setup.

Proxying the traffic immediately hit a wall - SSL pinning. Nothing special here, I just used a generic TrustManager hook to bypass it:

Java.perform(function() {
    var TrustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl');
    TrustManagerImpl.verifyChain.implementation = function() {
        return Java.use('java.util.ArrayList').$new();
    };
});

With that sorted, I could see the requests flowing through Burp. And there it was - the X-Acf-Sensor-Data header showing up on API calls.


First Look at the Payload

The header value looked something like this:

2,a,BM79GnKFX1J5NxN9p2lZhh...=,CRLsTODUZHrDs1Z/vak...=$SssjP1/LzqrdoxUwtFn0mxO6...=$1000,0,1000$$

Breaking this down visually:

  • Starts with 2,a,
  • Two base64 blobs separated by comma
  • Dollar sign delimiter
  • Another big base64 blob
  • Dollar sign
  • Some numbers
  • Double dollar sign at the end

The structure suggested encryption. Those first two base64 blocks are roughly 172 characters each, which is suspiciously close to what you’d get from RSA-1024 encryption (128 bytes -> ~172 base64 chars). The third block varies in size, probably the actual encrypted payload.


Finding the SDK

Time to decompile. Threw the APK into JADX and started poking around. Searching for “sensor” led me to com.akamai.botman - a package with about 30 obfuscated classes (single letter names like a, b, c…).

The entry point was easy to find: com.cyberfend.cyfsecurity.CYFMonitor. This is the public API that the app calls. Looking at getSensorData():

public static synchronized String getSensorData() {
    return f9382a.a();
}

Where f9382a is an instance of class i. So class i is our main target.


Hooking the Plaintext

Before diving deep into static analysis, I wanted to see what the plaintext looked like before encryption. Figured I’d hook around the crypto layer.

After some trial and error, I found that hooking Cipher.doFinal() right before it encrypts gave me the goods:

var Cipher = Java.use('javax.crypto.Cipher');
Cipher.doFinal.overload('[B').implementation = function(input) {
    var inputStr = Java.use('java.lang.String').$new(input);
    if (inputStr.indexOf('-94,-100') !== -1) {
        console.log('=== PLAINTEXT SENSOR DATA ===');
        console.log(inputStr);
    }
    return this.doFinal(input);
};

Bingo. The plaintext came through:

3.2.4-1,2,-94,-100,-1,uaend,-1,2219,1080,1,100,1,en,16,0,Pixel%207a,
lynx-16.3-13642542,lynx,-1,com.example.app,-1,-1,
b8b691969187b067,-1,0,1,REL,14331693,36,Google,lynx,release-keys,
user,android-build,BP3A.251005.004.B2,lynx,google,lynx,
google/lynx/lynx:16/BP3A.251005.004.B2/14331693:user/release-keys,
a997691c6787,BP3A.251005.004.B2...

Now we’re getting somewhere. The format uses -1,2,-94,<number> as section delimiters. Each number identifies a different type of data.


Understanding the Encryption

Back to JADX. Class af turned out to be the crypto handler. Here’s the key generation:

public final synchronized void b() {
    KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
    keyGenerator.init(128);
    this.f7048a = keyGenerator.generateKey();
    
    KeyGenerator keyGenerator2 = KeyGenerator.getInstance("HmacSHA256");
    keyGenerator2.init(256);
    this.f7049b = keyGenerator2.generateKey();
    
    RSAPublicKey rSAPublicKey = ai.a("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC4sA7vA7N...");
    this.f7050c = Base64.encodeToString(ai.a(this.f7048a.getEncoded(), rSAPublicKey), 2);
    this.f7051d = Base64.encodeToString(ai.a(this.f7049b.getEncoded(), rSAPublicKey), 2);
}

So the flow is:

  1. Generate random AES-128 key
  2. Generate random HMAC-SHA256 key
  3. RSA encrypt both keys with Akamai’s hardcoded public key
  4. Store the base64-encoded encrypted keys

Then in the encryption method:

public final synchronized String a(String str) {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(1, this.f7048a);
    byte[] ciphertext = cipher.doFinal(str.getBytes());
    byte[] iv = cipher.getIV();
    
    // Combine: IV + Ciphertext
    byte[] combined = new byte[iv.length + ciphertext.length];
    System.arraycopy(iv, 0, combined, 0, iv.length);
    System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
    
    // HMAC sign
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(this.f7049b.getEncoded(), "HmacSHA256"));
    byte[] hmac = mac.doFinal(combined);
    
    // Final: IV + Ciphertext + HMAC
    byte[] finalPayload = new byte[combined.length + hmac.length];
    System.arraycopy(combined, 0, finalPayload, 0, combined.length);
    System.arraycopy(hmac, 0, finalPayload, combined.length, hmac.length);
    
    return "2,a," + this.f7050c + "," + this.f7051d + "$" + 
           Base64.encodeToString(finalPayload, 2) + "$" + timingMetrics + "$$";
}

This explains the payload structure:

  • 2,a - version/type identifier
  • First base64 block - RSA(AES key)
  • Second base64 block - RSA(HMAC key)
  • Third base64 block - IV (16 bytes) + AES ciphertext + HMAC (32 bytes)
  • Timing metrics at the end

The server has Akamai’s RSA private key, so they can decrypt the AES/HMAC keys, then use those to decrypt and verify the payload. Clever scheme.


Mapping the Sections

Now for the tedious part - figuring out what each section contains. The section IDs I found:

Section Purpose
-100 Device fingerprint
-101 Feature flags
-102 Text timing (empty in 3.2.4)
-108 Text events (empty in 3.2.4)
-117 Touch events
-144, -142, -145, -143 Sensor checksums (DCT encoded)
-115 Timestamps and statistics
-70, -80 New in 3.2.4 (empty)
-120 Proof of Work response
-112 Performance metrics
-121 New in 3.2.4 (empty)
-103 Background events
-150 New in 3.2.4 (flags)

Let me walk through the important ones.


Device Fingerprint

This is the big one. Looking at CYFMonitor.b(), I could trace exactly how this string gets built:

StringBuilder sb = new StringBuilder();
sb.append("-1");
sb.append(",uaend,-1,");
sb.append(heightPixels);    // Screen height
sb.append(",");
sb.append(widthPixels);     // Screen width
sb.append(",");
sb.append(isCharging ? 1 : 0);
sb.append(",");
sb.append(batteryLevel);
// ... continues for 30+ more fields

The full field order:

Index Field Example
0 Header -1,uaend,-1
1 Screen height 2219
2 Screen width 1080
3 Is charging 1
4 Battery level 100
5 Orientation 1 (portrait)
6 Language en
7 Android version 16
8 Accelerometer rotation 0
9 Model Pixel%207a
10 Bootloader lynx-16.3-13642542
11 Hardware lynx
12 Separator -1
13 Package name com.example.app
14-15 Separators -1,-1
16 Android ID b8b691969187b067
17 Separator -1
18 Has keyboard 0
19 ADB enabled 1
20 Version codename REL
21 Version incremental 14331693
22 SDK int 36
23 Manufacturer Google
24 Product lynx
25 Tags release-keys
26 Type user
27 User android-build
28 Display BP3A.251005.004.B2
29 Board lynx
30 Brand google
31 Device lynx
32 Fingerprint google/lynx/lynx:16/...
33 Host a997691c6787
34 Build ID BP3A.251005.004.B2

Most of these come from android.os.Build. The android_id comes from Settings.Secure. Notably, they check adb_enabled which could be used to flag debugging.


Sensor Checksums: DCT Compression

The sections -144, -142, -145, -143 had me confused at first. Looking at class ak, turns out they’re using Discrete Cosine Transform compression on the sensor data.

public static Pair<String, Long> a(float[] data, float threshold) {
    // Normalize data
    Pair<Float, Float> minMax = findMinMax(data);
    float[] normalized = normalize(data, minMax);
    
    // Quantize to character buckets (A-}, 60 buckets)
    String quantized = quantize(normalized);
    
    // Run-length encode
    String encoded = runLengthEncode(quantized);
    
    // CRC-32 checksum
    long checksum = crc32(encoded);
    
    // Format: "2;{min};{max};{checksum};{data}"
    return "2;" + min + ";" + max + ";" + checksum + ";" + encoded;
}

The 2; prefix indicates raw encoding (as opposed to 1; for DCT-compressed). For the small sample sizes used in mobile sensors (~8-16 readings), raw encoding is almost always smaller.

Run-length encoding compresses repeated characters: AAABBC becomes 3A2BC.


Performance Metrics (Section -112)

This section contains CPU benchmark results:

17,1297,59,1666,126700,1275,33400,333,64354

Nine values from five different micro-benchmarks. More on why this matters later.


Building the Generator - First Attempt

At this point I figured I had enough to build a generator. Wrote up the Go code, implemented the encryption, filled in all the sections based on my static analysis.

Ran it against a protected endpoint…

403 Forbidden

Shit.


The Debugging Spiral

This is where things got messy. A 403 tells you nothing about what’s actually wrong. Could be:

  • Bad sensor format
  • Wrong encryption
  • TLS fingerprinting
  • Header order
  • Cookie issues
  • Something completely different

Time to start eliminating possibilities.

Dead End #1: TLS Fingerprinting

My first thought was TLS fingerprinting. Akamai is known for checking JA3/JA4 signatures.

Spent a few hours setting up a proper HTTP client with Android Chrome impersonation. Verified the JA3 matched real traffic. Got HTTP/2 working properly.

Still got 403.

Generated a curl command from my Go code and ran it manually - same error. So it wasn’t TLS. The problem was in the request content itself.

Dead End #2: Header Order

Maybe header order mattered? HTTP/2 preserves header order, and Akamai might check it.

Compared my headers against Burp captures character by character. Reordered everything to match exactly.

Still 403.

Dead End #3: Comparing Plaintexts

Time to go back to Frida and compare plaintexts directly.

Modified my hook to dump the plaintext right before encryption, then triggered a request on my phone while also running my generator. Side by side comparison:

Real app -115 section:

11024,30701,6853327236,20386301886,27239670847,31430,14,43,128,128,1000,15000,1,1412483272300581288,1766079924995,0

My generator -115 section:

1234,5678,1234567890,128,128,1000,15000,1,0

Completely different format. I had 9 fields, the real thing had 16. My static analysis was incomplete.


Fixing the Format Issues

Went back to JADX and traced through class i more carefully. Updated -115 to have all 16 fields:

touchVel,someHash,cumulative1,cumulative2,cumulative3,totalSteps,orientCount,motionCount,128,128,r1,r2,1,feistel,timestamp,0

Also found that the encryption suffix format was wrong:

Real: $1000,0,0$$
Mine: $90374,0,0$$

Fixed the suffix. Ran again…

Still 403.


Getting desperate. Went back to Burp and looked at the full request flow, comparing every single byte.

Something caught my eye. The real protected request had no Cookie header.

I had been passing cookies from an earlier request. Seemed logical - establish a session first. But the real app doesn’t do that.

Removed the cookie jar:

// Fresh client with no cookie jar
httpClient := &http.Client{}
🎉 200 OK!

Finally. Single requests working.


Final Working Flow

1. Initial request
   - No Akamai sensor needed
   - Gets ak_bmsc and bm_sz cookies
   - DON'T USE THESE COOKIES FOR PROTECTED REQUESTS
   
2. Protected request
   - Fresh HTTP client (no cookies!)
   - Include X-Acf-Sensor-Data header
   - All sections present with correct formats
   - Returns expected response

The complete plaintext structure:

3.2.4-rc3
-1,2,-94,-100,{35+ field device fingerprint}
-1,2,-94,-101,do_en,dm_en,t_en
-1,2,-94,-102,{empty}
-1,2,-94,-108,{empty}
-1,2,-94,-117,{touch events}
-1,2,-94,-144,{orientation timestamp checksum - RLE encoded}
-1,2,-94,-142,{orientation value checksums - RLE encoded}
-1,2,-94,-145,{motion timestamp checksum - RLE encoded}
-1,2,-94,-143,{motion value checksums - RLE encoded}
-1,2,-94,-115,{16 field stats section}
-1,2,-94,-70,{empty}
-1,2,-94,-80,{empty}
-1,2,-94,-120,{empty unless PoW required}
-1,2,-94,-112,{performance benchmarks}
-1,2,-94,-121,{empty}
-1,2,-94,-103,{background events}
-1,2,-94,-150,1,0

Single requests working. Time to scale.


The Scale Testing Wall

Built a concurrent worker pool for load testing:

const WORKERS = 10

func worker(jobs <-chan Request, results chan<- Result) {
    device := pickRandomDevice()
    generator := NewGenerator(device)
    
    for req := range jobs {
        sensor := generator.Generate()
        result := sendRequest(req, sensor)
        results <- result
        
        time.Sleep(randomDelay(2000, 5000))
    }
}

First run with 100 requests:

[6s]  ✅Pass:8  | 🚫Blocked:3  | Rate:1.8/s
[12s] ✅Pass:12 | 🚫Blocked:15 | Rate:1.5/s
[18s] ✅Pass:14 | 🚫Blocked:31 | Rate:1.2/s
...
[45s] ✅Pass:18 | 🚫Blocked:82 | Rate:0/s

The pattern was clear:

  1. First 8-15 requests pass (sensor accepted)
  2. Then 403s start appearing
  3. After ~30 requests, everything returns 403
  4. Have to wait 30 minutes before single requests work again

The sensor format was correct (individual requests passed), but something about the pattern triggered behavioral detection.


Behavioral Detection Analysis

I logged every request with device ID and response:

[PASS] 200 OK | device: sm_g960f
[PASS] 200 OK | device: sm_g960f
[PASS] 200 OK | device: sm_g960f
[BLOCK] 403 Forbidden | device: sm_g960f
[BLOCK] 403 Forbidden | device: sm_g960f
...

Same device ID, same fingerprint, repeated requests. From Akamai’s perspective, this looked like automation - a single device making dozens of requests in rapid succession.

First fix: rotate devices between requests.

func worker(jobs <-chan Request, devices []Device) {
    for req := range jobs {
        // Pick random device for each request
        device := devices[rand.Intn(len(devices))]
        generator := NewGenerator(device)
        // ...
    }
}

Results improved slightly:

Sensor Pass Rate: 45% → 52%

Better, but still degrading over time. The bans correlated with specific device IDs - some devices got banned repeatedly while others worked fine.

Analyzing which devices failed most:

Device Ban Analysis (403s):
  stk_lx1:      10+ bans
  sm_g950f:     10+ bans
  redmi_7:      8+ bans
  in2020:       8+ bans
  sm_a320fl:    8+ bans

Device Ban Analysis (428 sensor rejections):
  redmi_note_8_pro:  5+ rejections
  sm_a315f:          4+ rejections
  yal_l41:           4+ rejections

Budget phones and older models. Meanwhile, flagship devices had much lower ban rates.

Something about those device fingerprints was triggering detection.


The Device Data Breakthrough

I’d been generating device fingerprints from a dataset I’d built - manufacturer, model, build fingerprint, screen dimensions, etc. But I was missing something.

Searching GitHub for other implementations, I found a repository with a devices.json file. The structure was different from mine:

{
  "BUILD": {
    "MANUFACTURER": "samsung",
    "MODEL": "SM-G965F",
    "FINGERPRINT": "samsung/star2ltexx/star2lte:10/QP1A.190711.020/G965FXXSGHWB1:user/release-keys",
    "HARDWARE": "samsungexynos9810",
    "BOARD": "exynos9810",
    "BOOTLOADER": "G965FXXSGHWB1",
    ...
  },
  "SCREEN": {
    "heightPixels": 2960,
    "widthPixels": 1440,
    "density": 4.0
  },
  "PERF_BENCH": [
    "16,338,59,115,134000,1348,80600,805,9535",
    "17,1043,59,1679,84500,853,51900,518,2059",
    "17,1839,59,2506,621700,6225,107200,1071,24020"
  ]
}

Two things jumped out:

  1. Complete BUILD fields - My dataset had model and manufacturer, but was missing hardware, board, bootloader, and proper fingerprint strings. These are all fields that go into section -100.

  2. PERF_BENCH - Performance benchmarks. Looking at section -112 in my sensors:

-1,2,-94,-112,17,1569,59,3534,243700,2445,155700,1556,6160

Nine comma-separated values. The exact same format as PERF_BENCH.

I’d been generating random values for section -112. But these aren’t random - they’re CPU benchmark results from real devices.


Understanding Performance Benchmarks

Traced section -112 generation in JADX. Class g method h():

public static String h() {
    long start = SystemClock.uptimeMillis();
    int hits1 = 0, iters1 = 0;
    
    // Benchmark 1: Integer modulo operations
    for (int i = 1; i < 1000000; i++) {
        if (((4508713 % i) * 11) % i == 0) hits1++;
        iters1++;
        if (i % 100 == 0 && SystemClock.uptimeMillis() - start > 2) break;
    }
    
    // Benchmark 2: Float operations
    // Benchmark 3: Square root
    // Benchmark 4: Trigonometry  
    // Benchmark 5: Simple loop
    
    return hits1 + "," + iters1 + "," + hits2 + "," + iters2 + ...;
}

Five different CPU micro-benchmarks, measuring how many operations complete in a fixed time window. The results are deterministic for a given CPU - a Snapdragon 855 will produce similar results across multiple runs, but completely different from an Exynos 9810.

This is why my budget phone fingerprints were failing. Akamai correlates the performance benchmarks with the claimed device model. A “Samsung Galaxy S9” claiming budget phone benchmark results gets flagged immediately.

The repository I found had captured real benchmark data from physical devices - multiple runs per device to account for variance.


The Fix

Converted the device data to my format:

import json

with open('source_devices.json') as f:
    source = json.load(f)

converted = []
for d in source:
    build = d.get('BUILD', {})
    screen = d.get('SCREEN', {})
    
    converted.append({
        "id": build.get('MODEL', '').lower().replace(' ', '_'),
        "manufacturer": build.get('MANUFACTURER', ''),
        "model": build.get('MODEL', ''),
        "fingerprint": build.get('FINGERPRINT', ''),
        "hardware": build.get('HARDWARE', ''),
        "board": build.get('BOARD', ''),
        "bootloader": build.get('BOOTLOADER', ''),
        # ... all BUILD fields ...
        "screen_height": screen.get('heightPixels', 1920),
        "screen_width": screen.get('widthPixels', 1080),
        "perf_bench": d.get('PERF_BENCH', [])
    })

# Result: 1,165 devices with complete fingerprints

Updated the generator to use real PERF_BENCH values:

func (g *Generator) getPerfStats() string {
    if len(g.Device.PerfBench) > 0 {
        // Random selection from real captured benchmarks
        return g.Device.PerfBench[rand.Intn(len(g.Device.PerfBench))]
    }
    // Fallback for devices without data
    return "17,906,59,822,89000,898,46300,462,3269"
}

Device Rotation Strategy

With real device data, I implemented smarter rotation:

func worker(jobs <-chan Request, devices []Device) {
    var currentDevice Device
    var generator *Generator
    requestCount := 0
    
    rotateDevice := func() {
        currentDevice = devices[rand.Intn(len(devices))]
        generator = NewGenerator(currentDevice)
    }
    
    rotateDevice() // Initial device
    
    for req := range jobs {
        // Rotate every 3-5 requests (randomized)
        if requestCount > 0 && requestCount % (rand.Intn(3)+3) == 0 {
            rotateDevice()
        }
        requestCount++
        
        result := sendRequest(req, generator)
        
        // Immediate rotation on sensor rejection (428)
        if result.StatusCode == 428 {
            rotateDevice()
        }
        
        time.Sleep(randomDelay(2000, 5000))
    }
}

Key strategies:

  1. Randomized rotation interval - Not every N requests, but every 3-5 (random)
  2. Immediate rotation on 428 - Sensor rejected = device burned
  3. Per-worker device state - Each worker maintains its own device session
  4. Real benchmark data - No more random performance values

Final Results

Scale test with 20 workers, 1,000 requests:

╔═══════════════════════════════════════════════════════════╗
║                    FINAL RESULTS                          ║
╚═══════════════════════════════════════════════════════════╝
  Total Processed:  1001
  ✅ Passed:        416  (sensor accepted)
  🚫 Blocked:       559  (sensor rejected/rate limited)
  ⚠️  Errors:        26   (connection issues)
  Duration:         5m26s
  Avg Rate:         3.1 req/s

  📊 Sensor Pass Rate: 42.7% (416/975)

42.7% pass rate at 3.1 requests/second.

The remaining blocks are from:

  1. Specific device fingerprints with incomplete data
  2. Proxy IP reputation (rotating residential proxies get burned)
  3. Rate limiting on the endpoint itself

Filtering problematic devices should push the pass rate above 60%.


Lessons Learned

  1. Format correctness ≠ behavioral correctness. My sensor format was correct from the start. Single requests passed. But Akamai’s detection isn’t just checking format - it’s correlating device claims with expected behavior.

  2. Performance benchmarks are CPU fingerprints. Section -112 isn’t just metadata - it correlates to specific chipsets. Claiming to be a Galaxy S21 with budget phone benchmark results is an immediate red flag.

  3. Device data quality matters more than quantity. I had device fingerprints but they were missing critical fields like hardware, board, bootloader, and real benchmark data. The incomplete devices caused most of the blocks.

  4. Compare plaintexts early. I wasted hours on TLS fingerprinting and header order when a simple diff would have shown format issues immediately.

  5. Cookies aren’t always required. My assumption that session cookies were needed was wrong. Sometimes fresh requests work better.

  6. Debug at the right layer. I spent time on TLS fingerprinting (wasn’t the issue), header ordering (wasn’t the issue). The actual problem was device fingerprint quality - something I didn’t even consider until scale testing revealed the pattern.

  7. Open source research helps. The breakthrough came from finding another implementation’s device database. The security research community shares knowledge.


Future Improvements

Current limitations to address:

  1. Device data freshness - The benchmark database is ~2 years old. Newer devices aren’t represented.

  2. Proof of Work - Some endpoints require solving PoW challenges. The SDK implements SHA-256 puzzles that need actual computation.

  3. Proxy quality - Residential proxy reputation significantly impacts results. Datacenter IPs get blocked almost immediately.

  4. Multi-layer detection - Some applications combine Akamai BMP with other bot management solutions. Need to generate valid tokens for all layers simultaneously.


Tools used: Frida, JADX, Burp Suite, Go

Akamai BMP version: 3.2.4


Content on this site is licensed CC BY-NC-SA 4.0