How Android Apps Leak Device Identifiers Across the Native-Web Boundary
In a recent ~40-minute play session with the Android game Wordscapes, I observed the device’s Advertising ID appearing in 859 outgoing Web API events across numerous advertising and measurement endpoints. It turns out Android code injected it into an embedded WebView, where an ironSource ad-player script transmitted it to Unity’s advertising infrastructure.
Ordinary web pages cannot directly access Android Advertising IDs. Native Android SDKs can. These SDKs have the ability to introduce those identifiers into WebView-based JavaScript environments, where they can be transmitted out to the web by various parties through conventional web APIs.
Back in 2025, we built WebViewTracer to observe what kind of data was being passed across this route. The resulting paper, Cross-Boundary Mobile Tracking: Exploring Java-to-JavaScript Information Diffusion in WebViews, appeared at NDSS 2026.
Building WebViewTracer
Getting this visibility was much harder than simply throwing together a setup in an afternoon. The original code for VisibleV8 was built for desktop Chromium, where it assumed a desktop browser with desktop speeds and capabilities. Android WebView runs inside an app, so those assumptions almost immediately broke. First off, we had to make some changes to make VisibleV8 more performant at logging on a small Android device.
However, even after that, we hit a brick wall. The Android security model actively prevented us from writing log files from inside the V8 renderer process to a file. We tried various techniques: sending the logs over the network to a local server, logging them in the Android system logs, and even performing IPC with a separate system process that we would grant elevated privileges to. However, each of these approaches turned out to be slower and more finicky than the one we eventually adopted.
We found a flag hidden in Chromium’s source code that, if set to true, tells Android to disable the isolation and sandboxing around Chromium! Its original use case appears to be to make builds and installs faster when testing Chromium internally. Either way, a few Chromium compiles later, we had a custom system WebView provider that spoofed Google beta WebViews and transparently logged all JavaScript API calls being executed inside WebViews. To our knowledge, this is the first time anyone has had this level of visibility into system-wide activity inside Android WebViews.
VisibleV8, however, only gave us the JavaScript part of the story. We only saw which script executed, which bridge value it received, and which fetch, XHR, or tracking-pixel URL it constructed. That, however, did not tell us which Android component put the value there. We therefore had to run Frida alongside it to instrument native WebView calls such as evaluateJavascript, loadUrl, and loadDataWithBaseURL and record their arguments and Java stack traces. Joining those two views gave us a complete view from the app or SDK on the Android side, through the JavaScript that handled the value, to the web request being made.
What we found in our study
In our study, we sampled 10,000 Google Play apps from AndroZoo: 1,000 games and 9,000 non-games across the free, top-grossing, and trending charts. The crawl ran in February and March 2025 on four rooted Pixel 4a phones with Android 13.
| Result | Apps |
|---|---|
| Initial sample | 10,000 |
| Loaded at least one WebView during the crawl | 4,597 |
| Performed some form of Java/WebView injection | 4,366 |
| Injected context-restricted data | 2,989 |
| Transmitted context-restricted data | 2,711 |
So 65.0% of the apps in which we observed a WebView injected at least one category of context-restricted data. Of those injectors, 90.7% also made requests to web servers that contained data that typically would not be seen on the web.
Some of the data we saw was fairly generic stuff—device IDs, battery levels, etc.—that in bulk represented a significant increase in fingerprinting data. However, some of it included very strong identifiers, location data, and account-related categories, reported as a percentage of the 4,597 WebView-using apps:
| Identifier, location, or account value | Injected | Transmitted | Both observed in the same app |
|---|---|---|---|
| Advertising ID | 23.30% | 18.19% | 17.68% |
| Location | 9.61% | 7.34% | 3.99% |
| ZIP code | 8.12% | 12.54% | 4.40% |
| City | 5.27% | 1.68% | 0.89% |
| Network provider | 2.02% | 1.11% | 0.89% |
| Google Account name | 0.11% | 0.20% | 0.04% |
When we looked at it, we found that third-party code dominated the libraries that injected the code: 96% of observed injections originated in bundled libraries, while only 3.6% came from first-party app logic. Google Play Services/Ads appeared most often, followed by SDKs including Unity, ironSource, InMobi, AppLovin, and Fyber. Overall, 25 SDKs injected data that scripts sent to 152 external parties.

Blue nodes represent third-party libraries that injected data; green nodes represent companies to which the data was transmitted. Each edge is an observed relationship between an injecting library and a recipient.
This means that in our case, an app developer might not necessarily be to blame for these kinds of privacy diffusion. The app developer may integrate one monetization SDK. That SDK embeds a mediation stack. A winning bidder supplies a creative. The creative loads measurement code from still more companies. A native bridge can make data available at the center of that graph, and the WebView’s network APIs distribute it to actors that have no Java library in the APK.
Coming back to it almost a year later
After the 10,000-app study was published, I got engaged in other work. Only recently did I find time to take a look at the ecosystem again. I decided to set up a portion of our tooling (particularly the VisibleV8 WebViews) and run the pipeline over 7–8 apps that I picked based on popularity. This was a small, hand-selected follow-up to test whether the bridge patterns were still present. I’m going to present some interesting cases of what I saw.
Note
The snippets below come from the September follow-up. Since VisibleV8 trace logs are not the most intuitive to parse, I have attempted to distill the traces into readable JavaScript that is equivalent to the operations VisibleV8 recorded, rather than using VisibleV8’s internal event syntax. I have also shortened long URLs and replaced values with labels such as
<GAID>and<PUBLIC_IP>where they might reveal my personal information.
A complete flow: Wordscapes to Unity to Amazon to ExciteAds
Wordscapes is an app that in 2017 was considered one of the top 100 apps on the Android app store. To this day, it still has over 100 million downloads on the Play Store, even though some recent reviews mention excessive ads being shown while playing the game. To do my experiment, I downloaded the app onto a freshly reimaged rooted phone and spent some time playing the various puzzles offered by the app. Once I felt like I had done enough scrolling, I decided to close the app and look at the logs collected.
My first Wordscapes run contained a very vivid example of the problems posed by how WebViews are used in the current Android space. One of the first ads to be loaded in the WebView was an ad from ironSource Exchange (a subsidiary of Unity), loaded from a local ads cache served by Supersonic Ads (another company that was formerly owned by Unity). Once the ad started up, we saw the following callback being executed from a “null script” within the same domain.
window.ssa.onMessageReceived({
"data": {
"bundleId": "com.peoplefun.wordcross",
"deviceId": "<GAID>",
"url": "https://o.isx.unity3d.com/d?...&ifa=<GAID>&..."
}
});
The null domain is interesting. This was one of the ways we had distinguished JavaScript evaluated by the native side from a script fetched by the page in our original study.
After a bit of time, the ad was made visible to the user, and then ironSource’s local ad-player bundle, file:///data/user/0/com.peoplefun.wordcross/cache/supersonicads/ad-player/index.js, was activated and started executing code. Reconstructing the code from the traces, the code that was executed roughly corresponded to:
const measurementUrl = new URL(
"https://o.isx.unity3d.com/d" +
"?...&bid=com.peoplefun.wordcross" +
"&ifa=<GAID>&db=Google&dm=pixel+4a+(5g)&..."
);
const pixel = new Image();
pixel.src = measurementUrl.toString();
This effectively means:
- the native-side code supplied the GAID to the WebView as
deviceIdthrough an injection - a local ad-player script injected by Supersonic Ads accessed the same data
- the script later constructed a
URLcontaining the same GAID - the script assigned the URL to
Image.src, which triggered the request to the Unity-owned host loading an ironSource Exchange-based ad about Amazon.com
The full URL that was sent to Unity also contained the app package, app name, device brand and model, carrier, public IP, IP-derived latitude/longitude, SDK version, bidder and campaign fields, and several event/session identifiers.
https://o.isx.unity3d.com/d
?a=aix&mfp=0.01000000&sid=<SESSION_ID>&os=android&osv=14.0.0
&ts=<TIMESTAMP>&c=US&ai=162269&oct=4&cid=579061504844774440&d=amazon.com
&si=AMZNZ261W5AYGP4V&cri=579061504844774440&bn=amazona9-uswest-banner
&sv=b0f&ss=m&bid=com.peoplefun.wordcross&oh=50&ow=320&ifa=<GAID>
&an=Wordscapes&resct=d&tt=banner&li=0&rts=<REQUEST_TOKEN>&p3=new
&p8=false_0.00&p9=5&p10=PublisherOverride&ofp=0.01000000&cip=<PUBLIC_IP>
&vv=2&bli=f_1&fmi=minByCountry_0.00_N&ih=50&iw=320&au=BN&mak=6f8774bd
&db=Google&dm=pixel+4a+%285g%29&dt=4&ct=2&rw=320&rh=50
&car=<ISP_OR_CARRIER>&sta=<US_STATE>&iid=4285948&sdv=9.4.3&sd=17&lmt=0
&em=Max&com=s&lon=<LONGITUDE>&lat=<LATITUDE>&bcat=IAB22&pub=196683
&ad=AmazonDSP&sty=b&asi=<AD_SESSION_ID>&askrc=false&askisx=false
&medv=9.4.3&sip=rtb-seller-64b9d88748-z7qpc&mm=0.55000000
&aui=<AUCTION_ID>&sa=0&iat=false&stca=0&stcs=0&ig=0&uc=-1
&mmid=t_bn_d_d_row_55&ogrs=1.42968750&dst=traffic_blocker_v1
&tbtt=traffic_blocker_v1&pt=adPlayer&esdc=1&spdc=1
&rcri=<CREATIVE_RESPONSE_ID>&iatg=optable_segment_filtering_control
&ili=90&evtts=<EVENT_TIMESTAMP_WITH_MS>&cr=1&dl=en&eid=1&samid=623527
&pl=1&fds=37261&tsol=240&p=<EVENT_ID_OR_NONCE>&rand=<RANDOM_NONCE>
&apet=<EVENT_TIMESTAMP_MS>
Wordscapes’s WebView separately also contained 859 outgoing Web API calls with the GAID. The largest recipient/API groups were:
| Recipient host | Web API | GAID-bearing events |
|---|---|---|
2618.e.serverbid.com |
fetch |
315 |
streamkey.excitead.com |
fetch |
305 |
events.audioplace.me |
fetch |
123 |
logic.smartborad.com |
fetch |
40 |
eb2.3lift.com |
HTMLImageElement.src |
28 |
sdk-webview.mobilefuse.com |
XMLHttpRequest.open |
22 |
servedby.flashtalking.com |
HTMLScriptElement.src |
7 |
view.adjust.com |
Image.src |
6 |
slrtb.adk2.com |
fetch |
5 |
o.isx.unity3d.com |
Image.src |
1 |
Overall, the rows account for 852 requests. Seven additional requests were spread across Index Exchange, Aarki, DoubleClick, and another Smartborad endpoint. Most of these are companies that I had never really encountered before examining the traces, much less knowingly agreed to share my device’s advertising identifier with.
The exciteads.com case in particular is really interesting and warrants further digging. Many of the streamkey.excitead.com fetches came from the ad that was loaded by the Amazon Publisher Services code, which was itself loaded by the Unity ironSource Exchange code. In particular, after ironSource Exchange handed off to Amazon, an Amazon URL loaded the following into the WebView:
<script
type="module"
src="https://cdn.excitead.com/play/e_player/w.js?a=00130">
</script>
The script then went on to make roughly the following fetch call:
fetch(
"https://streamkey.excitead.com/ad2?" +
"app_name=Wordscapes" +
"&bundle=com.peoplefun.wordcross" +
"&ifa=<GAID>" +
"&lat=<LATITUDE>" +
"&lon=<LONGITUDE>" +
"&country=US" +
"&schain=<SUPPLY_CHAIN>" +
"&instanceid=<EXCITEAD_INSTANCE_ID>",
{
method: "GET",
mode: "cors",
headers: { /* ... */ }
}
);
Another flow: MX Player to InMobi to Branch and AppsFlyer
MX Player (com.mxtech.videoplayer.ad, version 3.2.5) is an app now owned by Amazon that is fairly popular (over 1 billion downloads) for streaming videos and file transfer (heck, it even has an Editor’s Choice award on the Play Store). I spent a total of 12 minutes tapping through the UI of the app and ended up finding some very clear examples of cross-party data sharing within WebViews.
VisibleV8 attributed an ad being loaded from https://ac.onedsp.inmobi.com/cache?id=<CREATIVE_ID>. This domain subsequently loaded a script that created an image object and assigned it a Branch impression.link URL carrying the test GAID under $aaid. The observed operation is equivalent to:
const branchPixel = new Image();
branchPixel.src =
"https://impression.link/impression" +
"?branch_key=<BRANCH_KEY>" +
"&$3p=a_inmobi_dsp" +
"&$household_measurement=false" +
"&$aaid=<GAID>" +
"&user_agent=Mozilla/5.0+(Linux;+Android+14;" +
"Pixel+4a+(5G)+Build/<BUILDID>;+wv)..." +
"&~ad_set_id=<AD_SET_ID>" +
"&~campaign=<CAMPAIGN_NAME>" +
"&...";
The same InMobi host supplied two additional creative scripts. Each created an image request to AppsFlyer. One was an impression for the Mistplay app:
const appsFlyerPixel = new Image();
appsFlyerPixel.src =
"https://impression.appsflyer.com/com.mistplay.mistplay" +
"?pid=inmobidsp_int" +
"&c=Mistplay_InMobi_Android_US_tROAS_iap_spend_all_v2" +
"&af_siteid=com.mxtech.videoplayer.ad" +
"&af_c_id=<CAMPAIGN_ID>" +
"&af_adset=<AD_SET_NAME>" +
"&af_ad=<CREATIVE_NAME>" +
"&click_id=<INMOBI_CLICK_ID>" +
"&advertising_id=<GAID>" +
"&af_lang=en" +
"&af_ua=Mozilla/5.0+(Linux;+Android+14;" +
"Pixel+4a+(5G)+Build/<BUILD_ID>;+wv)...";
The other AppsFlyer request targeted the advertised app and carried the same categories of fields. Creating an Image is a common tracking-pixel technique: no visible image needs to appear, but assigning its src property makes Chromium issue a GET request. In all three cases, the GAID came with the MX Player package name or publisher placement, campaign and creative identifiers, and a detailed user agent containing the device model and Android Build ID. This is cross-party advertising data flow in a very transparent form. The executing script was fetched from an InMobi host, the recipients were Branch and AppsFlyer, and the advertised apps were separate companies again.
Interestingly, at the time of writing, MX Player’s Google Play Data safety section states that no data is shared with third parties. However, in my test of version 3.2.5, I observed JavaScript loaded from InMobi infrastructure constructing requests to Branch and AppsFlyer containing the device’s Advertising ID.
Google’s Data safety documentation explicitly requires developers to account for data transfers performed through third-party SDKs and app-controlled WebViews, subject to certain exceptions. Whether these particular transfers qualify for an exception is something I cannot determine from the traces alone, but the discrepancy raises questions about how accurately app-store privacy disclosures capture the behavior of embedded advertising infrastructure.
Royal Smash sent the GAID to Adjust and Tenjin
Royal Smash (com.cyphergames.royalsmash), a game with over 10 million downloads that ranked 7th among top games on the Google Play Store, also exhibited some interesting transmissions of data. I played the game for approximately an hour (mostly, unfortunately, getting stuck on the ad minigames). When I looked at the logs, I saw that they contained six Image.src events to view.adjust.com and two to track.tenjin.io with the GAID.
The Adjust events came from a null script and took the form:
setTimeout(function () {
const img = new Image();
img.src =
"https://view.adjust.com/impression/..." +
"&idfa=<GAID>" +
"&gps_adid=<GAID>" +
"&ip_address=<PUBLIC_IP>" +
"&campaign_id=..." +
"&applovin_click_id=..." +
"&applovin_event_id=..." +
"&android_id_lower_sha1=<ANDROID_ID_SHA1>" +
"&os_version=14.0.0";
}, 500);
Although the parameter is named idfa, its value was the Android test device’s GAID; the same value was duplicated under gps_adid. The request also carried the public IP, campaign and AppLovin event identifiers, and a SHA-1 value labeled as the lower-cased Android ID. However, the presence of the IDFA parameter makes me wonder whether this same tracking behavior is also present in iOS. Unfortunately, iOS is a walled garden with very little visibility into WebView internals, which makes this level of instrumentation basically a pipe dream.
The trace records both the timer callback and the later Image.src assignment, confirming that the request executed after the 500-millisecond delay. The active code was an inline script coming from a local URI, indicating it had been injected by an app-side loadDataWithBaseURL(...) function that is used to “spoof” local URIs. The URL parameters indicate that this might be an AppLovin campaign of some kind, but the recipients of this highly trackable data were two companies that are relatively unknown to the average consumer, both of which purport to work in mobile growth measurement.
I’m unsure why Royal Smash decided to show me fewer ads compared to the other counterparts and have me do more gameplay. It is possible that they wanted me to be initially hooked on the game before showing me more ads, or it could be that the advertising overlords had decided that I had had enough ads for the day and didn’t bid to serve me more ads.
The conflicting policy problem
Browser privacy controls are built around principles such as origin and frame. Android permission controls are built around UID, package, and user grants. A WebView sits at the intersection between these two worlds, facilitating a conversation between the two.
For what it’s worth, the browser may correctly deny any origin access to geolocation, battery information, or the local network. It obviously completely forbids ADID access. From Chromium’s perspective, it correctly enforced the policy. However, if an app defines a way to leak this data into the WebView, there is little Chrome can do to stop it. From Android’s perspective, the app already had access. Each ecosystem is therefore behaving as designed while their actions together lead to a privacy-compromising outcome.
Resettable identifiers make this especially consequential. The Advertising ID is intended to give users some control over cross-app tracking. Combining it with build details, device model, storage sizes, local network data, web cookies, and canvas/WebGL signals gives a recipient both a stable join key and fallback fingerprinting material. It also connects app activity to the web advertising ecosystem.
Closing the blind spot
WebViews are not merely small browsers, and they are not merely UI widgets in an APK. They are a composition of two large security systems with deliberately programmable holes between them. Advertising and analytics SDKs appear to be making heavy use of those holes, often several layers below the app developer who shipped them.
The problem here is not just that the data is going out into the wild. Like, sure, we kinda always suspected that our phones were tracking us. But the problem with WebViews is that they are effectively opaque to basically anyone analyzing just the Java code to look for privacy-violating behavior. Heck, take the Wordscapes example: today, my information flowed from isX to Amazon to ExciteAds; an hour later, Amazon might decide to load a different ad, and my data will flow to a completely different entity; the next day, it might flow to even more people. The makers of the browsers knew this; they accounted for this by not giving access to extremely persistent device-level identifiers. However, with WebViews, these assumptions break down, creating an unregulated environment of data-sharing chaos.
We need to do better.
The paper and presentation containing the larger-scale study and a full evaluation are public, as are the WebViewTracer tool and the VisibleV8 patches. The VisibleV8 tool and WebViewTracer are fully open source and ready to use, modify, or extend (though setting them up in a consumer setting is a bit of a hassle because they require a rooted phone).
