DEX tracing in flows
flows originally read one thing: the strings inside an app's native
libraries (.so files). That works well for apps whose audio pipeline is
compiled native code, but it misses apps that do the same work in
Java/Kotlin — and it turns out many do.
The clearest case is Otter, a transcription app. Its universal APK holds
2,662 files and 12 native libraries, and every one of those libraries is
graphics code. There is no audio library at all: Otter records through
Android's AudioRecord API in Java/Kotlin and transcribes server-side. So
flows used to report camera and file for a voice-recording app, and no
microphone.
DEX tracing closes that gap. It reads the app's compiled Java/Kotlin
bytecode (the classes*.dex files) and looks for calls to the Android
framework APIs that capture audio and send data over the network.
Nothing changes in how you run flows; the tracing is on by default.
cim-apps flows --apk app.apk --outdir results/
What it detects
Audio capture. A call to AudioRecord, MediaRecorder, or
MediaProjection in the app's bytecode. Otter now reports microphone
alongside camera and file.
Network egress. Calls to the HTTP and socket libraries an app uses to
send data off the device: OkHttp, Retrofit, gRPC, HttpURLConnection,
Volley, raw sockets, and WebView.loadUrl.
The evidence is always an actual call, never a mention. A class name
appearing as a string constant does not count; neither does a permission.
An app can declare RECORD_AUDIO and never record — the permission is a
capability, so on its own it never creates a link. When a permission does
accompany a real call, it is recorded as corroboration and adds to the
score.
Reading the output
Two kinds of entry appear, both marked "source_layer": "dex" so you can
tell at a glance that the evidence came from bytecode rather than a native
library.
A capture entry:
{
"modality": "audio",
"stage": "capture",
"module": "app_code",
"input": "microphone",
"source_layer": "dex",
"operations": ["AudioRecord"]
}
And the corresponding link, showing the permission corroborating the call:
{
"source": "microphone",
"target": "app_code",
"kind": "feeds",
"score": 3,
"evidence": {
"dex_api": ["AudioRecord"],
"source_layer": "dex",
"permissions": ["android.permission.RECORD_AUDIO"]
}
}
Note the target is app_code, not a named library. That is deliberate: the
evidence is in the app's own bytecode, so the graph says "app code captures
audio" rather than attributing it to a library that is not responsible.
An egress entry looks like this:
{
"modality": "audio",
"stage": "output",
"module": "app_code",
"input": "microphone",
"source_layer": "dex",
"operations": ["okhttp3", "retrofit2"],
"evidence": {
"proximity": "method",
"relation": "co-occurrence",
"capture": ["AudioRecord"]
}
}
proximity — the field that matters most
This is the part to read carefully, because it is what stops the egress finding being over-claimed.
The trace reports co-occurrence, not dataflow. It tells you that capture and network APIs are called near each other in the code — it does not prove that the recorded audio is what gets sent. Proving that requires tracking the actual bytes through the program (taint analysis), which this toolkit does not do.
proximity grades how close the two are:
| Value | Meaning | How to read it |
|---|---|---|
"method" |
One method calls both a capture API and a network API | Strongest. The recording and sending code are in the same function. |
"class" |
The same class does both, in different methods | Moderate. Plausibly one component handling both. |
null |
Both APIs exist in the app, but in unrelated classes | Weakest. The app records, and the app networks. Nothing links them. |
Real Otter returns null — recording and uploading live in separate
classes, which is exactly how a well-structured app is written. That is an
honest result, not a failure of detection. Otter certainly does upload
audio; the trace simply cannot evidence the connection from static
structure alone, and says so rather than implying a link it cannot support.
Treat null as "both capabilities present, relationship unknown". Treat
"method" as a strong lead worth manual inspection. In writing, phrase
findings accordingly: "the app records audio and contacts these
endpoints" is supportable; "the app sends recordings to these
endpoints" is not, unless you have confirmed it another way.
Filtering DEX-sourced findings
Because every DEX entry is tagged, separating the two evidence layers is straightforward:
import json
rec = json.load(open("results/app_flows.jsonl"))
dex = [e for e in rec["chain"] if e.get("source_layer") == "dex"]
native = [e for e in rec["chain"] if e.get("source_layer") != "dex"]
To find apps in a corpus where capture and egress are strongly co-located:
strong = [
e for e in rec["chain"]
if e.get("stage") == "output"
and e.get("evidence", {}).get("proximity") == "method"
]
Cost, and turning it off
The egress trace is a second full pass over every method in every DEX file. On a large app this takes roughly 40 seconds (measured on Otter, seven DEX files, ~60,000 methods in the first alone). Capture detection is cheap; it is the egress trace that carries the cost.
For corpus runs where you only need the inputs, switch it off:
cim-apps flows --apk-dir corpus/ --outdir results/ --no-dex-trace
From a notebook:
from cim_app_histories.analyse import analyse_flows
graph = analyse_flows("app.apk", dex_trace=False)
Capture detection still runs with --no-dex-trace; only the
capture-to-egress trace is skipped.
Limitations
Static, not runtime. A call existing in the bytecode does not mean it runs, or runs often. It may sit behind a condition that never fires.
Co-occurrence, not dataflow. Restated because it matters: proximity
describes code layout, not data movement. See above.
MediaRecorder can over-claim. MediaRecorder records video with audio
as well as audio alone, so an app that only films may be reported as
capturing audio. Bounded and known; check the app before relying on it.
Obfuscation. R8/ProGuard renames application classes but cannot rename
framework classes, so AudioRecord and HttpURLConnection survive
obfuscation. Third-party library prefixes (okhttp3, retrofit2) survive
ordinary builds, but a fully shaded build could hide them. Reflection —
calling an API by name at runtime — evades detection entirely. All of these
cause under-reporting, which is the safe direction: the tool claims
less than the truth, never more.
Microphone only, for now. Camera and location capture in Java/Kotlin are not yet detected through the DEX. The mechanism is table-driven, so adding them is a small change.
See also
- Working with results — loading JSONL into pandas
- CLI reference — all flags
- Visualising flows — the Sankey viewer