vermilion10

Back

Note

Everything here is against apps I installed on my own device (Galaxy S24 FE, Android 16), for the purpose of deciding whether to trust them with my own credentials. No servers were touched that weren’t mine or the vendor’s own public endpoints. The fake AWS keys used in the traffic test are the well-known documentation examples.

In the last post, on cloning apps into Samsung’s dual-app profile, I mentioned almost in passing that I’d gone looking for a trustworthy rclone/S3 client for Android and that “closed-source options were an easy no.” That was a lazy heuristic and I knew it while writing it. “Closed source” is not the same as “untrustworthy,” it just means the cost of finding out is higher. So I decided to actually pay that cost.

The app is RcloneView Mobile ↗, a proprietary rclone GUI that had just landed on Google Play. It looked genuinely good. It also wanted the one thing I’m least willing to hand to a black box: my S3 access key and secret.


The claims on the record#

Before touching a disassembler, it’s worth knowing what you’re testing. Someone had already asked the obvious question on the vendor’s own forum. In a thread bluntly titled “Safety, closed source” ↗, a user named Stan_Manioc asked:

Why should we feel safe since you are closed source and you could be leaking credentials and private cloud files?

After a week of silence he pushed again, saying he couldn’t understand how to safely hand “config password, cloud api info, to a black box.” On 4 January 2026, the RcloneView account replied with four specific, falsifiable claims:

  1. “RcloneView itself is only a GUI layer.”
  2. “All actual operations, authentication, encryption, data transfer, and file handling, are performed entirely by the rclone binary.”
  3. “Credentials and config data are stored locally using the OS’s secure storage mechanisms (such as Keychain on macOS or equivalent).”
  4. “We do not transmit your credentials or file data to any external servers, nor do we have visibility into your cloud contents.”

Their marketing says the same thing in softer words: the security checklist post ↗ describes a “local-first” design where “data flows directly between your machine and your clouds, no intermediary” and “no third-party server touches your data.”

That’s a good set of claims because every one of them is checkable. And crucially, that forum thread is about the desktop app. The mobile app is new, version 1.0.0, and nobody had checked it at all.


Getting the app apart#

The app was already installed, so no sketchy APK mirrors were needed:

$ adb shell pm path com.bdrive.rcloneviewmobile
package:/data/app/~~yOrvTTLcKbYUtCx9JNxnsQ==/...base.apk
package:/data/app/~~.../split_config.arm64_v8a.apk
package:/data/app/~~.../split_config.en.apk
plaintext

Six splits, pulled with adb pull. The arm64 split is 99 MB, which is the first interesting fact. Nothing about a file browser needs 99 MB of native code unless it’s carrying an entire Go program.

$ unzip -l split_config.arm64_v8a.apk | sort -rn
 70058008   lib/arm64-v8a/libgojni.so
 11316480   lib/arm64-v8a/libflutter.so
 10879920   lib/arm64-v8a/libapp.so
  1717000   lib/arm64-v8a/libsqlite3.so
plaintext

So it’s three layers:

LayerWhat it is
classes.dexThin Flutter plugin glue, wrapped in PairIP (Google Play’s anti-tamper)
libapp.soDart AOT snapshot, all the app logic
libgojni.soA real rclone, compiled to a shared library via gomobile

The dex is nearly worthless for analysis, it’s plugin registration and PairIP scaffolding. The interesting parts are the two blobs that decompilers don’t touch.

Tip

Flutter release builds are AOT-compiled Dart, so jadx gives you almost nothing. But the AOT snapshot retains library and symbol names. A plain strings pass over libapp.so is far more productive than any decompiler.

That turned out to be the whole ballgame:

$ python strings.py libapp.so 6 | grep -oE "package:rcloneview_mobile/[a-z_/]+\.dart" | sort -u | wc -l
246
plaintext

246 source file paths, giving me the app’s entire architecture for free:

api/secure_storage_service.dart
api/drive_credentials_extractor.dart
api/remote_config_injector.dart
api/license_client.dart
api/device_guid.dart
pairing/api/qr_crypto.dart
util/diag_log.dart
...
plaintext

Where the credentials actually live#

Two findings settle claim 1 and claim 2 together.

First, the JNI bridge. The Go layer exports 113 symbols under a package called ndwrap, and the config-related ones are exactly what you’d hope:

ndwrap.SetRemoteConfig
ndwrap.SetRemoteConfigObscured
ndwrap.GetRemoteConfig
ndwrap.inMemoryStorage
plaintext

That last symbol is the important one. Combined with RemoteConfigInjector and a rclone-inject tag on the Dart side, it says the rclone configuration is injected into the engine in memory. There is no rclone.conf written to disk at all. I confirmed the negative case by searching the device:

$ adb shell "find /sdcard -iname '*rclone*' -o -iname '*bdrive*'"
(nothing)
plaintext

Second, persistence. The app stores state through flutter_secure_storage under these keys, all recovered from the snapshot:

rcloneview.remotes.ini
rcloneview.license.info
bdrive.auth.token
bdrive.session.key
bdrive.device.guid
plaintext

And both of that plugin’s Android backends are present in the dex: Tink-backed EncryptedSharedPreferences (AES-256-GCM), plus the legacy keystore cipher:

public final Cipher p() {
    return Cipher.getInstance("RSA/ECB/OAEPPadding", "AndroidKeyStoreBCWorkaround");
}
java

Either path wraps the data with a key held in the AndroidKeyStore, which on an S24 FE is hardware-backed and non-exportable. So claim 3 holds: credentials are ciphertext at rest, unlocked by a key that can’t leave the device.

Warning

One caveat worth understanding. SetRemoteConfigObscured maps to rclone’s obscure function, and rclone’s “obscure” is reversible AES with a hardcoded key. It is obfuscation, not encryption. That’s fine here, because the obscured blob is itself inside Keystore-backed storage, but the obscuring layer contributes exactly zero real protection on its own.


What it sends home#

Static analysis found only two first-party domains in the entire Dart snapshot:

https://rcloneview.com
https://license.rcloneview.com
plaintext

Everything else was a cloud provider you’d expect (graph.microsoft.com, api.dropboxapi.com, s3.amazonaws.com). More importantly, I went looking for telemetry SDKs and found none: no Firebase Analytics, no Crashlytics, no AppsFlyer, Adjust, Sentry, Amplitude, or Mixpanel. The firebase-*.properties files in the APK are transitive ML Kit dependencies pulled in by the QR scanner, not analytics.

That’s a good static result. But static analysis can only prove what code exists, never what it does at runtime. Claim 4 needed a real test.


Actually watching the traffic#

Here’s where I hit the wall I expected. The app’s network security config declares no user CA trust anchors, so a normal MITM proxy can’t decrypt anything, and PairIP exists specifically to make patching the APK unpleasant. I initially wrote this off as unverifiable.

That was wrong, and the fix is that I didn’t need decryption. I only needed to know who it talks to, and TLS SNI is sent in the clear. PCAPdroid captures per-app traffic through a local VPN with no root at all:

$ adb shell am start -n com.emanuelef.remote_capture/.activities.CaptureCtrl \
    -e action start -e app_filter com.bdrive.rcloneviewmobile
$ adb shell ip addr show tun0
87: tun0: <POINTOPOINT,UP,LOWER_UP> mtu 10000 ...
plaintext

With the capture live and filtered to just that one package, I force-stopped RcloneView, cold-started it, let it idle, then added an S3 remote using the canonical fake AWS documentation keys and hit save and verify. It returned “Access denied,” which is precisely right, that’s a genuine HTTP 403 coming back from AWS.

Every connection the app made, for the entire session:

TimeDestinationSNISentRcvd
15:04:0852.219.37.6s3.ap-southeast-1.amazonaws.com2,735 B7,230 B
15:04:1452.219.37.6s3.ap-southeast-1.amazonaws.com2,815 B7,270 B
15:05:433.5.147.222s3.ap-southeast-1.amazonaws.com2,775 B7,310 B
15:05:533.5.147.222s3.ap-southeast-1.amazonaws.com2,695 B7,230 B

Four connections. All to AWS S3. Zero contact with license.rcloneview.com or rcloneview.com across a cold start, three minutes of idle UI use, credential entry, and browsing.

The byte counts are worth a glance too: roughly 2.7 KB outbound is about right for a TLS handshake plus a SigV4-signed S3 request. There’s no room in there for bulk exfiltration, and nowhere for it to go regardless, since AWS was the only peer.

The trap I nearly fell into#

I run AdGuard as a Private DNS resolver, and I very nearly published the above as a clean bill of health without thinking it through. The problem: if AdGuard had been blocking license.rcloneview.com, the app would never have opened a TCP connection to it, and my capture would look identical to a clean one. I’d be reporting “no telemetry” when the real finding was “my DNS filter hid the telemetry.”

So I tested the resolver directly rather than trusting the absence:

$ curl -s -H 'accept: application/dns-json' \
    "https://dns.adguard.com/resolve?name=license.rcloneview.com&type=A"
{"Answer":[{"data":"lb-bdweb-230694326.us-east-1.elb.amazonaws.com."},
           {"data":"100.59.159.229"},{"data":"32.194.61.192"}],"Status":0}
plaintext

AdGuard resolves it, identically to Cloudflare. Nothing was being filtered into invisibility. The silence in the capture was real silence.

Important

This is the part I’d emphasise to anyone doing this kind of testing. An absence of evidence in a packet capture is only meaningful if you’ve independently proven that the evidence could have appeared. DNS filtering, VPN routing, and app filters all silently manufacture clean results.


The verdict on RcloneView#

All four of the developer’s claims held up under testing. Credentials go straight to AWS with no vendor server in the path, they’re stored under a hardware-backed key, rclone does the actual work, and the app did not phone home once.

That’s a better result than I expected, and I want to state it plainly because my starting position was prejudicial. Still, the findings weren’t uniformly good:

  • allowBackup="true" with no dataExtractionRules and no backup agent. App data is eligible for cloud and device-to-device backup. The practical impact is limited, since Keystore keys never leave the device so restored ciphertext is undecryptable, but for an app holding credentials this should be off.
  • MANAGE_EXTERNAL_STORAGE, all-files access. Defensible for a file manager, but it’s the broadest permission in the manifest.
  • No ACCESS_LOCAL_NETWORK. On Android 17+ that permission gates local network access, so SMB to a NAS will break on upgrade until they ship a fix.
  • rclone 1.73.4, a few releases behind.
  • Developer paths left in the binary: /Users/tsjeong/go, /Users/tsjeong/workspace. Cosmetic, but it means no -trimpath.
  • Version 1.0.0, and the forum’s Android bug thread ↗ from July 2026 has a user reporting 250 of 1,300 uploads silently failing, no bulk retry, and no transfer history. Staff acknowledged all of it. The security posture is fine; the maturity isn’t there yet.

And the structural point that no single test can fix: today’s clean capture says nothing about version 1.1.0. With a closed-source app you’re not verifying once, you’re re-verifying forever.


The open-source comparison#

For contrast I did the same review on RSAF ↗, where “review” means actually reading the code.

chenxiaolong/RSAF Loading repository info… ★ –⑂ –

Its credential handling is a straightforwardly stronger design. rclone.conf lives in app-private storage and is encrypted with rclone’s own native config encryption, not merely obscured. The password for that is a randomly generated 128-byte value, which is itself wrapped by Tink AES-256-GCM under an AndroidKeyStore master key:

private val hardwareWrappedPassword: Password
    get() {
        ...
        passwordStore.password = RandomUtils.generatePassword(128)
kotlin

Two real layers instead of one. There are also small signs of a careful author throughout, like the password type refusing to render itself into a log line:

data class Password(val value: String) {
    override fun toString(): String = "<password>"
}
kotlin

The backup handling is the standout, though. Rather than a bare manifest flag, RSAF ships a BackupAgentHelper that refuses to back up at all unless the transport is verifiably encrypted:

if (data.transportFlags and FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED != 0) {
    Log.i(TAG, "Client-side encrypted backup")
} else if (data.transportFlags and FLAG_DEVICE_TO_DEVICE_TRANSFER != 0) {
    Log.i(TAG, "Device-to-device transfer")
} else {
    Log.e(TAG, "Plain-text backup is not allowed")
    return
}
kotlin

It’s also gated behind a preference that’s off by default. The README is honest about why: if you do enable it, the config leaves the device in plaintext and you’re trusting the backup transport, and the author notes that “some older OEM Android builds have backup systems that lie about end-to-end encryption.”

Telemetry: none. The only URLs anywhere in the app source are documentation links in comments.

I verified the shipped 4.12 build matches its claims. It carries rclone 1.75.1, it leaks no developer paths (so -trimpath is working), it embeds an assets/archive.tar snapshot of its own source, and the signature checks out against the digest published in the README:

$ apksigner verify --print-certs RSAF-4.12-arm64-v8a-release.apk
V2 Signer: certificate DN: CN=Andrew Gunnerson, OU=RSAF
V2 Signer: certificate SHA-256 digest:
  b2506499bea1c5a6e658f07be6773fe486999dc124204c6522af7407503ac9f9
plaintext

Why my build failed#

I’d tried to build RSAF from source and couldn’t, which is what sent me down the “just sideload something” path in the first place. The answer is unglamorous: upstream only builds on Linux. The CI is runs-on: ubuntu-latest, full stop.

The Windows failure is specific. RSAF puts a shim go.exe first on PATH (rcbridge/gowrapper/go.go ↗) so it can strip absolute paths out of go list -json output for reproducible builds. gomobile’s newer tool-directive check doesn’t survive that shim, and you get a misleading error:

gomobile bind requires golang.org/x/mobile in the current module,
but it is not in the module dependency graph.
plaintext

That message sends you straight to editing go.mod, which is the wrong move. The module is fine, as running the query directly proves:

$ go list -m golang.org/x/mobile
golang.org/x/mobile v0.0.0-20260821190718-4776eadac327
plaintext

Build it in a clean Linux clone and the problem evaporates. Don’t copy a Windows working tree across either, that trips Gradle’s committed dependency verification metadata.


Side by side#

RcloneView 1.0.0RSAF 4.12
SourceProprietary, PairIP-hardenedGPL-3.0, readable
Config at restObscured INI in Keystore storageEncrypted rclone.conf + 128-byte Keystore-wrapped password
BackupallowBackup=true, no rulesAgent refuses plaintext, off by default
TelemetryLicense server exists, never fired in testingNone
rclone1.73.41.75.1
Android 17 readyMissing ACCESS_LOCAL_NETWORKDeclared
Build hygieneDev paths present-trimpath, embedded source
VerifiableOnly by re-testing each releaseSignature + reproducible build

One honest asymmetry: these aren’t drop-in equivalents. RSAF is a Storage Access Framework provider, it exposes remotes to other apps rather than giving you a browser UI. RcloneView is a full file manager with photo backup and a rather clever encrypted QR pairing flow for importing remotes from desktop (PBKDF2 plus AES-GCM, chunked across multiple QR codes, PIN-authenticated, entirely offline). If you want the app itself to be the interface, they don’t compete.


What I’d actually tell you#

I set out to catch a closed-source app doing something wrong and it didn’t. The developer’s public claims were accurate, at least for the version I tested, and the “closed source is an easy no” instinct I started with was intellectually lazy. Verification beats vibes.

But I’m still putting my keys in RSAF, for a reason that has nothing to do with RcloneView’s behaviour: the cost of staying confident is completely different. Auditing RcloneView means repeating this entire exercise on every release. Reading RSAF means reading a diff.

Caution

None of this replaces bounding your blast radius. Use a dedicated IAM user scoped to a single bucket, never account-wide or root keys, and rotate them. Then the worst case is bounded no matter which app you pick, and no amount of reverse engineering is a substitute for that.

What I could not verify#

To be explicit about the limits, since a security writeup without them is marketing:

  • Payload contents. No CA trust means no decryption. I know who the app talked to, not what it said. The byte counts constrain the possibilities but don’t eliminate them.
  • That the license server is never contacted. LicenseClient and DeviceGuidGenerator exist in the binary. A six-minute window proves they don’t fire at startup or during ordinary S3 use, nothing more.
  • On-disk ciphertext. The device isn’t rooted and the app isn’t debuggable, so I inferred the storage format from the code rather than reading shared_prefs directly.

Tooling#

Everything here used free tools and an unrooted phone:

  • jadx ↗ for the dex and manifest
  • A 20-line Python strings extractor, since Git Bash ships without strings
  • adb for pulling splits, permission state and dumpsys flags
  • PCAPdroid ↗ for rootless per-app capture
  • apksigner from the Android SDK build-tools
  • DNS-over-HTTPS queries to sanity-check the resolver

The highest-leverage trick by far was realising that Flutter AOT snapshots keep their symbol names. That one grep turned an opaque 10 MB blob into a file listing of the entire application.


Sources: Safety, closed source ↗ · RcloneView Android App issues ↗ · Encrypt config file? ↗ · Cloud Storage Security Checklist ↗ · RSAF ↗ · Google Play listing ↗

Reverse Engineering RcloneView Before Trusting It With My S3 Keys
https://pure.vermilion10.dev/blog/reverse-engineering-rcloneview-before-trusting-it-with-s3-keys
Author vermilion10
Published at September 6, 2026