I broke AppLovin's mediation cipher protocol.

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:

  1. A version tag (2)
  2. A 40-character protocol id,
  3. 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:

  • 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.
  • There is no MAC, no AEAD, no authentication at the cipher layer. An attacker can tamper with the ciphertext.
  • 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.
  • I have successfully decrypted 5,394 envelopes from one app and a few thousand more across five other apps with zero failures.

What gets shipped

The decrypted plaintext is gzip-compressed JSON with about thirty top-level keys. Two of them carry the privacy weight:

  • device_info — AppLovin's own copy of the device's fingerprint payload. ~50 fields.
  • signal_data[] — an array of opaque tokens, one per demand-partner ad network installed in the publisher's app.

A real device_info from one ATT-denied request:

Field Value What it is
revision iPhone14,3 Hardware model code (iPhone 13 Pro Max)
os 18.6.2 OS patch version
tm 5918212096 Total RAM in bytes (= 5.51 GB)
ndx × ndy 1284 × 2778 Native pixel screen dimensions
kb en-US,es-ES Installed keyboards
font UICTContentSizeCategoryXXXL Accessibility text size
tz_offset -4 Timezone
volume 40 System audio volume
mute_switch 1 Physical mute switch position
bt_ms_2 1770745989000 Device boot time (ms epoch)
dnt / idfa true / 00000… ATT denied — IDFA zeroed
idfv 81E958C3-…-51DE7CE11819 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.

The user denied ATT. IDFA is zeroed. Everything else flows.

The mini-envelopes

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 all into signal_data[] and ships the whole thing inside its encrypted envelope. AppLovin's server then forwards each token to that bidder's bid server via server-to-server OpenRTB.

The device makes one outgoing network call. The data reaches a dozen separate ad-tech companies.

In the request I'll quote throughout, twelve of the eighteen adapters returned bid signals, ranging from 29 bytes (Verve, probably just a fetch token) to 14.4 KB (Unity Ads). Of those twelve, four are themselves readable inside the AppLovin envelope; eight are encrypted to the destination bidder, opaque to AppLovin, only decodable on the recipient's bid server.

The four readable ones are the ones worth dwelling on. The InMobi bid signal — 36 URL-encoded key=value pairs, decoded in full:

d-devicemachinehw          = iPhone17,5
os-v                       = 26.2.1
h-user-agent               = Mozilla/5.0 (iPhone; CPU iPhone OS 18_7…)
d-language                 = en-US
d-localization             = en_US
d-key-lang                 = ["en-US"]
d-device-screen-density    = 3
d-device-screen-margins    = {"right":0,"left":0,"bottom":34,"top":47}
d-media-volume             = 15
d-drk-m                    = 1
d-bat-lev                  = 25
d-bat-sav                  = 0
d-bat-chrg                 = 0
d-av-disk                  = 6275 MB
d-tot-disk                 = 116837 MB
u-app-orientations         = 1
u-appbid                   = com.hitappsgames.wordsolitaire
u-appdnm                   = Solitaire Associations: Journey
u-appver                   = 1.9.0
u-tracking-status          = 3
u-age-restricted           = 0
s-skan                     = -1

InMobi's token contains signals AppLovin's own device_info doesn't: available disk space in megabytes (6,275 — varies hour to hour, very high entropy), total disk space, battery level, charging state, dark-mode preference, the model-specific safe-area inset dimensions. The downstream bidder collects more device data than the mediator forwarding it.

BidMachine's signal contains, additionally: the IANA timezone string (America/New_York, more specific than the numeric offset), carrier code, the SKAdNetwork acceptance list, and a separate 36-character UUID that's BidMachine's own per-user identifier. They've built their own persistent cross-app key, stored in their SDK's UserDefaults, shipped alongside Apple's IDFV on every request.

Fyber's signal is the most conservative: User-Agent, bundle, model, OS, IDFA, IDFV, locale, plus a stack of internal A/B test flag names.

Across the four readable mini-envelopes, the device fingerprint reaches four ad-tech companies in four different schemas. The other eight ship comparable payloads to eight more companies, encrypted in ways we can't inspect from outside. The fingerprint fans out on every banner load.

The api_did sentinel

Inside app_info there's an 18-character hex field called api_did. AppLovin's server assigns it on first SDK init — the SDK POSTs the full device_info + app_info to applovin.com/2.0/device once on fresh install, the server returns a device_id, the SDK caches it and echoes it as api_did on every subsequent request. Server-issued, persistent, designed to be cross-app.

Across six distinct physical iPhones I observed with ATT denied — different hardware revisions, different IDFVs, different apps from different publishers — 100% of envelopes had api_did starting with the same eight hex characters: 10badd1d. Read those bytes as ASCII: 0xBADD1D = BADDID = "Bad Device ID." It's a sentinel — AppLovin's server returns the same prefix for every ATT-denied caller, with a random trailing salt per app. For ATT-granted users I observed three different prefixes (1023c..., 10621..., 10ebf...) — one per IDFA, confirming that when ATT is granted, api_did is a deterministic transformation of the IDFA. When ATT is denied, the prefix carries no device-identifying information.

AppLovin's own server-issued cross-app identifier respects ATT cleanly. Credit where due. This is real and worth saying out loud.

The fingerprint, with IDFA and api_did excluded

api_did is one field. IDFA is one more. The encrypted envelope contains ~48 additional device_info fields, plus 12 mini-envelopes each carrying its own copy of the fingerprint. ATT zeroes one identifier. It does not touch:

  • the hardware model code
  • the OS patch version
  • the screen dimensions in points and native pixels
  • the screen safe-area insets (notch + home-indicator pixels — model-specific)
  • the total RAM in bytes
  • the available disk space in megabytes (varies hour-to-hour, very high entropy)
  • the battery level and charging state
  • the system audio volume
  • the system mute switch state
  • the dark-mode preference
  • the accessibility text-size setting
  • the list of installed keyboards
  • the timezone offset (and, in some bidders' tokens, the IANA timezone string)
  • the device boot time in epoch milliseconds (stable until reboot)
  • the IDFV
  • whatever per-bidder UUID each demand SDK has independently minted in its own storage

I built a SHA-256 fingerprint from nine of those fields — revision + os + tm + ndx + ndy + kb + font + locale + tz_offset — over the ten distinct physical iPhones in my decrypted corpus. Result: ten distinct fingerprints for ten distinct devices. 100% uniqueness in a panel that included multiple iPhones of the same hardware model.

For one ATT-denied user whose device appeared in three different apps from three different publishers, the fingerprint hash was identical across all three apps: 321d60c4d72ddf2a. Different bundle ids. Different IDFVs. Different SDK versions.

That is the privacy argument, made by direct construction: looking only at the device-info payload that flows in every encrypted mediation request, with IDFA zeroed and api_did excluded from consideration, the data is sufficient to deterministically re-identify the same physical iPhone across apps from different publishers, with ATT denied throughout. That payload reaches AppLovin and ~12 demand partners simultaneously, on every banner refresh, every ~30 seconds.

What ATT actually controls

ATT is a control over the Apple-issued cross-app identifier (IDFA). It is honored on the wire, IDFA is genuinely zeroed when the user denies. AppLovin's own server-issued identifier (api_did) is honored too, the BADDID sentinel returns the same value to every denied user. Both of those controls operate at the identifier layer.

The protocol AppLovin and the twelve downstream ad networks chose to send each other doesn't operate at the identifier layer. It operates at the device-fingerprint layer, which iOS does not gate, which Apple has no mechanical control over, and which ATT does not touch. Every encrypted mediation request carries 50 device fields to AppLovin, then 18 different bid signals to 18 different ad networks, then by server-to-server fan-out to those bidders' own downstream DSPs. Each of those parties has its own device fingerprint, its own identifier, its own ability to relink the same physical iPhone across publishers and sessions.

Subscribe to Buchodi's Threat Intel

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe