I broke the cipher AppLovin wraps around its ad-mediation traffic and decrypted several thousand real requests captured on my consented mobile-traffic research panel. The conclusion is straightforward: The encrypted bid request carries enough device data to deterministically re-identify the same iPhone across apps from different publishers, even when user denies ATT. That payload reaches AppLovin plus around 12 downstream ad networks on every banner load, every ~30 seconds, for as long as the user is playing. The assumption that ATT is the only way to deterministically identify a user is wrong. Fingerprinting the device works just as well.
The cipher
Every AppLovin mediation request is HTTPS POST sent to ms4.applovin.com/1.0/mediate . Inside the TLS layer, the payload is wrapped in a second cipher AppLovin built. After base64 decoding, the wire envelope is:
2:8a2387b7dbed018e5e485792eac2b56833ce8a3a:T7NreIR729giTKR-thJPcKeT6JXevACogl57SIFzwKp-1BASwpBT6v:<binary>
Three colon-separated fields then ciphertext:
A version tag ( 2 ) A 40-character protocol id, A 54-character suffix of the publisher's AppLovin SDK key. The SDK key is the shared secret AppLovin issues to each publisher app at signup, stored in plaintext in Info.plist on iOS or AndroidManifest.xml on Android.
The cipher takes two ingredients: a salt and that SDK key. The salt is a 32-byte constant baked into every AppLovin SDK binary, 21 meaningful bytes followed by 11 zero bytes. The bytes are identical across every IPA and APK I checked (Solitaire Associations Journey, Hypermarket3D, Ludo Star, Yik Yak on iOS; Hypermarket3D on Android). The 40-character protocol-id field on the wire is sha1(salt).hex() .
The cipher:
salt = (universal 32-byte constant, baked into the SDK) sdk_key = (per-publisher 86-char string, baked into the app bundle) dk = SHA-256(salt || sdk_key[:32]) # 32-byte per-publisher derived key protocol_id = SHA-1(salt).hex() # constant identifying the version counter = System.currentTimeMillis() # 8-byte LE — wall clock at encrypt time masked_ctr = counter ⊕ uint64(dk[0:8]) # what appears on the wire for i in 0..N-1: if i % 8 == 0: x = (counter + i) x = (x ⊕ (x >>> 33)) * 0xC2B2AE3D27D4EB4F x = (x ⊕ (x >>> 29)) * 0x85EBCA77C2B2AE63 ks = x ⊕ (x >>> 32) ciphertext[i] = plaintext[i] ⊕ ((ks >> ((i % 8) * 8)) & 0xFF) ⊕ dk[i % 32]
A few facts about this construction:
... continue reading