I broke AppLovin's mediation cipher protocol

lmbbuchodi1 pts0 comments

I broke AppLovin's mediation cipher protocol.

Sign in<br>Subscribe

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.<br>The cipher<br>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:<br>2:8a2387b7dbed018e5e485792eac2b56833ce8a3a:T7NreIR729giTKR-thJPcKeT6JXevACogl57SIFzwKp-1BASwpBT6v:Three colon-separated fields then ciphertext:<br>A version tag (2)<br>A 40-character protocol id,<br>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.<br>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().<br>The cipher:<br>salt = (universal 32-byte constant, baked into the SDK)<br>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<br>protocol_id = SHA-1(salt).hex() # constant identifying the version

counter = System.currentTimeMillis() # 8-byte LE — wall clock at encrypt time<br>masked_ctr = counter ⊕ uint64(dk[0:8]) # what appears on the wire

for i in 0..N-1:<br>if i % 8 == 0:<br>x = (counter + i)<br>x = (x ⊕ (x >>> 33)) * 0xC2B2AE3D27D4EB4F<br>x = (x ⊕ (x >>> 29)) * 0x85EBCA77C2B2AE63<br>ks = x ⊕ (x >>> 32)<br>ciphertext[i] = plaintext[i] ⊕ ((ks >> ((i % 8) * 8)) & 0xFF) ⊕ dk[i % 32]<br>A few facts about this construction:<br>The keystream is a SplitMix64 finalizer — Sebastiano Vigna's 2014 PRNG. SplitMix64 is the kind of randomness language standard libraries ship for fast random-number generation in games and simulations. It passes statistical-randomness tests; it does not pass cryptographic-security tests, and it isn't claimed to.<br>There is no MAC, no AEAD, no authentication at the cipher layer. An attacker can tamper with the ciphertext.<br>The cipher counter is System.currentTimeMillis(). Every encrypted envelope on the wire leaks the device's wall-clock time at encryption, to the millisecond, before decryption: recover the masked counter, XOR with uint64(dk[0:8]), get the timestamp.<br>I have successfully decrypted 5,394 envelopes from one app and a few thousand more across five other apps with zero failures.<br>What gets shipped<br>The decrypted plaintext is gzip-compressed JSON with about thirty top-level keys. Two of them carry the privacy weight:<br>device_info — AppLovin's own copy of the device's fingerprint payload. ~50 fields.<br>signal_data[] — an array of opaque tokens, one per demand-partner ad network installed in the publisher's app.<br>A real device_info from one ATT-denied request:

Field<br>Value<br>What it is

revision<br>iPhone14,3<br>Hardware model code (iPhone 13 Pro Max)

os<br>18.6.2<br>OS patch version

tm<br>5918212096<br>Total RAM in bytes (= 5.51 GB)

ndx × ndy<br>1284 × 2778<br>Native pixel screen dimensions

kb<br>en-US,es-ES<br>Installed keyboards

font<br>UICTContentSizeCategoryXXXL<br>Accessibility text size

tz_offset<br>-4<br>Timezone

volume<br>40<br>System audio volume

mute_switch<br>Physical mute switch position

bt_ms_2<br>1770745989000<br>Device boot time (ms epoch)

dnt / idfa<br>true / 00000…<br>ATT denied — IDFA zeroed

idfv<br>81E958C3-…-51DE7CE11819<br>Per-app-vendor stable id

Plus another 35 fields: screen safe-area insets, free memory, carrier code, country code, locale, orientation, status bar height, monotonic clock, battery flags, secure-connection state. Effectively every system property iOS exposes to third-party code.<br>The user denied ATT. IDFA is zeroed. Everything else flows.<br>The mini-envelopes<br>A typical publisher app has ~18 demand-partner SDKs compiled in: Meta, Google, Mintegral, Vungle, ironSource, Unity, InMobi, BidMachine, Fyber, Moloco, TikTok, Pangle, Chartboost, Verve, MobileFuse, Bigo, Yandex, plus AppLovin's own. When a banner needs filling, the AppLovin SDK calls each of those locally, and asks "prepare a bid signal." Each demand SDK independently constructs an opaque token containing whatever device data its publisher backend wants. The AppLovin SDK bundles them...

applovin cipher publisher device salt counter

Related Articles