One tap, no trace: silent KYC image exfiltration from Android apps
Summary
During bug bounty research over the past months, we found the same vulnerability pattern in three major fintech Android apps: a neobank, an investment platform and a self-custodial crypto wallet. Each app bundled an outdated build of a popular open-source image-cropping library, and each had configured it in a way that turned a cropping tool into a data exfiltration channel for cached KYC images — passports, national ID cards, driver's licenses and liveness selfies.
The attack needs a malicious app co-installed on the victim's device. One tap on that app is all it takes: no permission prompts, no UI in the victim app, no root, and nothing beyond the INTERNET permission that every app already requests. The victim app silently reads its own private KYC captures and writes them out to the attacker. All three findings were reported through the programs' bug bounty channels and have been fixed. Combined bounties across the three reports exceeded $10,000, and this write-up is redacted to the vulnerability class: no app names, no exploit code, no payload details.
Impact
The data at stake is the most sensitive category a fintech app ever holds: government ID photos, document scans from OCR flows, and liveness verification videos. These are exactly the artefacts that identity-document verification and liveness SDKs cache on disk during a KYC flow. An attacker who can read them out of the sandbox obtains a complete identity kit — document number, photo, address, date of birth — the raw material for account-opening fraud and synthetic identity abuse, and a reportable privacy incident for the company under GDPR-class regimes.
The same mechanism also works in reverse: instead of reading files out, the attacker can write attacker-chosen bytes into the victim app's private storage at a known filename. In one of the three apps this was enough to corrupt a cached document the app later rendered, a reliable denial of service on that content. The read direction is the one that matters, but defenders should understand both exist.
We scored the findings in the 8.2–8.4 High range (CVSS 3.1) across the three apps. The representative vector from the neobank and investment-platform reports:
CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N → 8.2 High
A local vector with no privileges required, no user interaction, and high confidentiality and integrity impact. The practical severity is higher than the number suggests for self-custodial wallet apps, where the same over-broad file exposure reaches key material and where loss is irreversible.
Root cause
Three independent misconfigurations, each harmless-looking on its own, stack into a sandbox escape. The victim app is tricked into acting as a confused deputy against itself. The library at the center is CanHub's Android-Image-Cropper — the maintained successor of the classic ArthurHub cropper, bundled by thousands of production apps — and the class maps cleanly to CWE-284 (Improper Access Control) with a family resemblance to the Android URI-permission gaps previously seen in CVE-2019-2114, CVE-2020-0188 and CVE-2021-0514.
1. An exported activity with no caller validation. The library's CropImageActivity was declared android:exported="true" in each app's merged manifest, with no android:permission guard and no signature-level protection. Any app on the device can launch it directly with an explicit intent. Exporting an activity is not a vulnerability by itself — doing it without any compensating control on a component that accepts URIs is.
2. An over-broad FileProvider. Each app's CropFileProvider mapped path="." for both <files-path> and <cache-path>. That single character exposes the entire getFilesDir() and getCacheDir() trees — which is precisely where the KYC SDKs stage their document captures before, during and after upload. A FileProvider scoped to a dedicated scratch directory would have contained the blast radius to nothing worth stealing.
3. A library version predating the fix. The cropping library added output-URI validation in version 4.7.0, which rejects output URIs whose authority does not match the host app's own provider. All three apps shipped builds older than that, so the activity blindly trusted the caller-supplied source and output URIs. One app had even R8-obfuscated the options class — the validation hooks were simply absent.
The enabler that stitches these together is a subtle gap in Android's IPC security model. When an activity is started, the framework's URI-grant validation — UriGrantsManagerService — inspects Intent.getData() and Intent.getClipData(); a content:// URI pointing at another app's private provider placed there is rejected with a SecurityException. But URIs embedded inside Parcelable objects in the Bundle extras are not checked. The cropper receives its source image and its output destination as URIs wrapped in a CropImageOptions Parcelable that serializes roughly seventy fields in a fixed order — and an attacker only needs two of them: customOutputUri (the output sink) and skipEditing (suppress the UI). The launch is never blocked, the activity resolves those URIs from inside the victim's own process, and access to its own FileProvider is implicit — same UID, no questions asked.
A final detail completes the silence: the skipEditing flag skips the editing UI entirely and auto-crops the full source image on launch. Set that, and the whole cycle — read private image, decode, re-encode, write to the attacker's sink — completes in roughly 150–300 ms with zero pixels rendered to the victim's screen, and the completion result code is delivered straight back to the attacker's process.
The attack never needs grantUriPermission(), never triggers a framework security exception, and never surfaces anything to the user. The sandbox held — the app simply walked its own private data out the front door.
How it was found
No zero-day creativity was required, which is exactly the point. The pattern fell out of the three cheapest checks we run against every Android target:
- Manifest review of exported components. Decompile the APK, list every
android:exported="true"component that is not obviously a launcher or a deep-link handler, and ask of each one: what inputs does it trust, and what can those inputs reach? An exported cropping activity that accepts URIs is a textbook candidate. - FileProvider scope audit. Read
res/xml/*file_paths*. Anypath="."entry on<files-path>or<cache-path>is a red flag worth mapping against what the app actually stores in those directories — especially when document-verification, liveness or OCR SDKs are in the dependency tree. - Dependency version check. Confirm whether the bundled library version predates the 4.7.0 output-URI validation. R8 obfuscation hides class names but not the absence of the validation hooks.
In tooling terms, the whole audit is two shell commands over a decoded APK:
apktool d release.apk -o out/
# exported components that are neither launchers nor deep-link handlers
grep -n 'android:exported="true"' out/AndroidManifest.xml
# provider scope — any root mapping is a finding
grep -rn 'path="."' out/res/xml/
Three static checks, two shell commands, zero dynamic testing needed to confirm the pattern is live. Any team can run this audit on their own release build in under an hour — and almost none do. Exported-component analysis and provider-scope review are absent from most mobile CI security gates, and static analyzers that model URI flows inside code do not model manifest merging or provider path configuration. That blind spot is precisely where this class of vulnerability lives.
The fix
If your app bundles this library, close all three holes — any one of them left open keeps the door ajar.
-
Upgrade to 4.7.0 or later. The newer versions validate the output URI against the host app's own provider authority and reject foreign sinks. If your build R8-obfuscates the library, add keep rules for the validator classes, or the fix strips out with everything else:
-keep class com.canhub.cropper.CropImageOptionsValidator { *; } -keep class com.canhub.cropper.CropException$BadOutput { *; } -
Stop exporting the activity. If no external app has a legitimate reason to invoke your cropper, set
android:exported="false". If something does, require a signature-level permission so only apps signed with your key can call it:<permission android:name="your.app.package.permission.CROP_IMAGE" android:protectionLevel="signature" /> <activity android:name="com.canhub.cropper.CropImageActivity" android:exported="true" android:permission="your.app.package.permission.CROP_IMAGE" /> -
Scope the FileProvider. Replace every
path="."with a dedicated scratch subdirectory the cropper actually needs — for example<files-path name="cropper" path="cropper-internal/" />. The KYC SDK's capture directories must never be reachable through any provider path, and no provider entry should ever map a storage root. -
Make KYC captures ephemeral. Defense in depth on the data itself: delete document captures from local storage as soon as the verification flow completes, and prefer SDK configurations that stream directly rather than stage files on disk. If there is nothing cached, there is nothing to exfiltrate.
-
Treat Parcelable-embedded URIs as untrusted input. The framework gap is not yours to patch, but you can refuse to be its victim: any exported component that receives URIs from callers should validate the authority of both source and destination against an allowlist of your own providers and the system's, inside the component, before use.
Timeline
All three findings were reported privately through the companies' bug bounty programs. Each report included the full root-cause analysis above, a working proof of concept for the affected app, and the remediation guidance. The programs triaged, confirmed and fixed the issue in the apps, and the bounties were awarded — more than $10,000 combined across the three reports. Vendor and program details stay redacted here; the class-level detail is what matters to defenders.
Lessons for defenders
exported="true"is a promise to trust strangers. Audit every exported component that accepts URIs, paths or file handles. If it acts on caller-supplied destinations, it is a confused deputy waiting to happen.path="."in a FileProvider is never fine. It converts your entire private storage into addressable content. Scope every provider entry to the narrowest directory that works, and verify after every SDK upgrade that a new vendor hasn't merged a broader path in.- The framework does not check URIs inside Parcelables.
startActivity()inspectsIntent.dataandclipDataonly. Any security model that assumes "the framework blocks foreign URIs at the boundary" has a hole exactly the shape of a Bundle extra. - Dependency hygiene is a manifest problem too. The library fixed this class in 4.7.0, and the fix is stripped out by obfuscators unless you keep it. Knowing the patched version is not enough — verify the validation code survives your build pipeline.
- KYC data should have no dwell time. Every hour a passport image sits in a cache is an hour it is one confused deputy away from leaving. Delete on completion, and audit what your verification SDKs write to disk and where.
This exact audit — exported components, provider scope, dependency version — is the first thing we run on every Android engagement. It costs an hour, and in three fintech apps it surfaced the highest-severity findings in the report.