--- We trust open source. We `implementation()` a library, scan the README, maybe check the stars count, and move on. Nobody audits the merged manifest. Nobody decompiles the `.aar` to trace what the library actually registers in your app. We treat "open source" as "someone else already reviewed it" — and most of the time, nobody did. This is a story about what happens when a widely-used image cropping library ships `android:exported="true"` on an Activity and root-scoped `<paths>` on a FileProvider as its *defaults*. Not as a misconfiguration. Not as a dev mistake. As the library's intended manifest. And every app that imported it — fintech, e-commerce, social, telco — inherited that attack surface without writing a single line of vulnerable code. --- ## Root cause: the library, not the integrator [android-image-cropper](https://github.com/CanHub/Android-Image-Cropper) (`com.canhub:android-image-cropper`, the maintained fork of the old ArthurHub/edmodo cropper) ships an `AndroidManifest.xml` that gets merged verbatim into every consuming app: ```xml <!-- cropper/src/main/AndroidManifest.xml --> <provider android:name="com.canhub.cropper.CropFileProvider" android:authorities="${applicationId}.cropper.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/library_file_paths"/> </provider> <activity android:name="com.canhub.cropper.CropImageActivity" android:exported="true"/> ``` and the paired `library_file_paths.xml`: ```xml <paths> <files-path name="images" path="."/> <!-- files/ , entire subtree --> <cache-path name="cached_files" path="."/> <!-- cache/ , entire subtree --> <external-files-path name="my_images" path="/"/> <!-- external files/, entire root --> </paths> ``` Read that again. `exported="true"` on the activity plus root-scoped `<paths>` on its grant-capable `FileProvider` — those are the library's *defaults*, not an integration mistake. Every downstream app that pulls the dependency without an explicit manifest override (`tools:node="merge"` and a narrower `<paths>`) inherits both. In the wild that turned out to be nearly every app that used it: no theming, no permission wrapper, no caller check. Manifest merger just accretes what the library declares. --- ## The chain `CropImageActivity.onCreate()` pulls two Parcelables straight out of the launching Intent, with no signature check, no `getCallingPackage()` verification, nothing: ```kotlin val bundle = intent.getBundleExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE) cropImageUri = bundle?.parcelable(CropImage.CROP_IMAGE_EXTRA_SOURCE) cropImageOptions = bundle?.parcelable(CropImage.CROP_IMAGE_EXTRA_OPTIONS) ?: CropImageOptions() ``` `CropImageOptions` is a public, fully attacker-populated Parcelable. Two fields matter: `customOutputUri` and `skipCropMenu` (`skipEditing` in newer releases — auto-triggers the crop with zero UI once the source image loads). Any installed app can explicit-intent this activity — `exported="true"` needs no permission — hand it a `content://` source URI inside the victim's own `.cropper.fileprovider` authority (readable, thanks to the root-scoped `<paths>`), a `customOutputUri` pointing at an *attacker-owned* provider, and `skipCropMenu = true`. The activity flashes, decodes, "crops," writes to the attacker's URI, and finishes — no tap required. Reverse the source/destination and it's a write instead of a read: overwrite anything the same `<paths>` roots expose, at the victim app's UID. --- ## Pushing the primitive: ATO and RCE You've got a file I/O primitive. The obvious next question: how far does it go? ### Account takeover via config corruption Most apps store their backend config on disk somewhere — API base URL in SharedPreferences, auth endpoint in a config XML, token storage paths. The idea: find the file, overwrite it with a version that points to your server, intercept the next auth request. The `.bak` angle makes this more interesting than a straight overwrite. `SharedPreferencesImpl` uses an atomic-write pattern internally — before persisting new data, it renames the current file to `.bak`. If the process dies mid-write (force-stop, OOM kill, crash), the next `loadFromDisk()` call sees the `.bak` file and automatically promotes it: ```java // SharedPreferencesImpl recovery logic if (mBackupFile.exists()) { mFile.delete(); mBackupFile.renameTo(mFile); // .bak becomes the real file } ``` So the play: overwrite `app_config.xml.bak` via the cropper primitive, wait for a force-stop (or trigger one — battery optimization, memory pressure, whatever), and on next launch the app loads your config. Auth traffic redirected to your endpoint. Session tokens, refresh tokens, credentials — all yours. That's the theory. Keep reading for why it doesn't work cleanly in practice. ### Code execution via `.so` replacement The other path: drop a malicious `.so` into `data/data/{package}/lib/` or wherever the app does `System.loadLibrary()` / `dlopen()` from. Next load, your code runs in the app's process. SELinux says no. The `neverallow` rules on AOSP explicitly block execution from `app_data_file` contexts: ``` neverallow { domain -appdomain -dumpstate -shell -system_server -zygote } { file_type -system_file -exec_type }:file execute; ``` W^X on the data partition, `noexec` mount flag as a second layer. Even if you land a perfect ELF, the linker won't map it executable. On stock AOSP, this is a hard wall. But here's the thing — some developers don't load their native libs the normal way. If you read the code carefully enough (and you should), you'll find apps that ship `.so` files packed inside zip archives, stored in `code_cache` or similar writable locations. The app extracts them at runtime and loads from there. That's a developer choice that sidesteps the usual `/data/data/{pkg}/lib/` path where the system manages permissions more tightly. If you can overwrite that zip (or the extracted `.so` sitting in `code_cache`) before the app loads it — and the app doesn't verify integrity — you've got a path to code execution that SELinux's default `neverallow` wasn't designed to block, because the developer built their own loading mechanism outside the standard model. Separately — I recently achieved RCE through a file overwrite primitive, but via a different chain: WebView file access bypass, not this cropper bug. Full writeup coming once the vendor's remediation window closes. Different entry point, same class of impact. --- ## Can we write anything — or just photos? This one had me stuck for a while. I had a file overwrite primitive — I could pick any path under the FileProvider roots. So naturally I tried to drop a crafted XML, a modified config, a `.so`. None of it worked. The file would land at the right path, but the content was always... an image. I kept thinking I was doing something wrong. Turns out I wasn't. The library literally can't write anything else. There's exactly one write path in the entire library, no exceptions: ```kotlin // BitmapUtils.kt — every write goes through here fun writeBitmapToUri(context, bitmap, compressFormat, compressQuality, customOutputUri): Uri { val newUri = customOutputUri ?: buildUri(context, compressFormat) return context.contentResolver.openOutputStream(newUri, "wt").use { bitmap.compress(compressFormat, compressQuality, it) newUri } } ``` See the problem? `bitmap.compress()`. Everything gets decoded into a `Bitmap` object first (`BitmapFactory.decodeStream` — if it's not a valid image, it throws `FailedToDecodeImage`), then re-encoded as JPEG, PNG, or WebP on the way out. You never touch raw bytes. You control *where* the file lands, but the *content* is always a valid image. **So you can't just overwrite `shared_prefs/config.xml` with custom XML — what actually lands there is a PNG with image headers and pixel data.** If the app tries to parse that as XML, it chokes. If it tries to load it as an ELF, the linker rejects it. You can't build a polyglot file that's both "valid PNG" and "valid XML" — no parser on earth accepts that. You pick the path. The codec picks the content. That's the boundary. --- ## The fix v4.7.0 locked it down in two PRs. [PR #680](https://github.com/CanHub/Android-Image-Cropper/pull/680) added `validateOutputUri` — only `content://` URIs allowed now (`file://` is dead), plus extension validation against the compress format. [PR #659](https://github.com/CanHub/Android-Image-Cropper/pull/659) ripped out a FileProvider compat shim that was letting `file://` URIs slip through. [Expo patched separately](https://github.com/expo/expo/pull/37223) by setting `android:exported="false"` on `CropImageActivity`. ![validateOutputUri fix](./fix.png) *The `validateOutputUri` function added in v4.7.0 — content:// only, extension must match compress format.* --- ## The reports I reported this across a few bounty programs. Knowing the impact is bounded by the bitmap limitation, I expected P4 or P3 at best — and that's exactly where most of them landed. The overwrite is real, zero-interaction, and silent, but the content constraint keeps it from escalating past corruption/DoS in most cases. ![HackerOne reports](./reports.png) --- Credit to [yn33](https://github.com/yn33) who originally reported this finding on the [CanHub GitHub repository](https://github.com/CanHub/Android-Image-Cropper).
back to write-ups