How a popular Android image cropping library silently exposed thousands of apps to Arbitrary File Overwrite (AFO).
---
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`.

*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.

---
Credit to [yn33](https://github.com/yn33) who originally reported this finding on the [CanHub GitHub repository](https://github.com/CanHub/Android-Image-Cropper).