< ALL NOTES

Auditing a banking Android app without lying to yourself

5 min read

Four axes, and above all the traps that make a static APK audit report "compliant" when it checked nothing. Method applied to banking and mobile-money apps in the West African UEMOA zone.

androidMASVSmobile securityUEMOAstatic analysis

A banking or mobile-money app concentrates more risk per user than almost any other consumer software. Across the UEMOA zone it is often the only way to reach an account: no branch nearby, no usable website, no card. The phone is the bank.

Auditing those apps from the outside, with no source and no test environment, means static APK analysis. It is useful, it is fast, and it is dangerously easy to make say whatever you want.

This note describes the method I apply and, more importantly, the places where a naive audit produces a false verdict. It contains no finding about any identifiable app: audit results belong to the organisations that commissioned them.

The four axes

The split follows OWASP's MASVS, narrowed to what an APK alone can establish.

1. Storage. What the app writes to the device and who can read it back. android:allowBackup, exposed directories, unencrypted databases, preference files. The thing to watch: allowBackup defaults to true. Its absence from the manifest is not a neutral silence, it is a permission granted.

2. Network. Cleartext traffic, TLS configuration, certificate pinning. android:usesCleartextTraffic, the networkSecurityConfig, and the domains declared as exceptions.

3. Leakage. What escapes through unintended channels: logs, clipboard, screenshots, components that hand data to other apps.

4. Attack surface. Exported components — activities, services, receivers, content providers — and requested permissions, especially those with no relation to what the product does.

So far, entirely conventional. What follows is the part that matters.

Trap 1: certificate pinning is not in the manifest

A naive checker looks for network_security_config.xml and a <pin-set> element. Finding neither, it concludes "no pinning".

That is wrong in both directions.

Most serious Android apps pin in code, through OkHttp's CertificatePinner or a hand-rolled TrustManager. None of that appears in the manifest. A perfectly pinned app gets reported as vulnerable.

The reverse is just as misleading. Finding the string CertificatePinner among the DEX strings proves nothing: it may come from a bundled third-party library that is never instantiated, target a telemetry domain rather than the banking API, or be dead code left by an earlier version.

The only question that matters — is traffic to the production API pinned? — has no static answer. It is answered by interception, on a test device, under mandate.

What static analysis can honestly produce here: "no declarative pinning; pinning primitives present in code; dynamic verification required". Three states, not two.

Trap 2: usesCleartextTraffic depends on targetSdk, and gets overridden

The android:usesCleartextTraffic attribute looks like a binary answer. It is not.

  • Below targetSdkVersion 28 it defaults to true. Its absence therefore permits

cleartext HTTP.

  • From 28 onward the default is false. The same absence means the opposite.
  • And as soon as a networkSecurityConfig is declared, the attribute is ignored:

the configuration file decides, domain by domain.

A checker that reads the attribute without reading targetSdk and without following the networkSecurityConfig is wrong in both directions, and wrong silently. The three have to be resolved together, and per-domain exceptions reported explicitly — that is very often where the real problem sits: a cleartextTrafficPermitted="true" left on some forgotten subdomain.

Trap 3: exported has a default that depends on content

The most expensive case, because it governs attack surface.

android:exported is not simply true or false. Historically, a component without the attribute is exported if and only if it declares an <intent-filter>. Since targetSdkVersion 31 the attribute is mandatory on any filtered component — but a real-world app estate still contains builds targeting lower.

The consequence: a tool that counts components literally carrying exported="true" misses every implicitly exported component. It will return a reassuring verdict on an app whose sensitive activity is reachable by any other installed application.

The correct rule is computed:

exported = if the attribute is present → its value
           otherwise                   → an <intent-filter> is present

Three lines. Their absence turns the audit into decoration.

Trap 4: FLAG_SECURE does not live in the manifest either

Blocking screenshots on a balance screen or a one-time code is done at runtime, window by window:

getWindow().setFlags(LayoutParams.FLAG_SECURE, LayoutParams.FLAG_SECURE);

Searching for FLAG_SECURE in the DEX strings tells you a constant exists somewhere. It tells you neither which activities are protected, nor whether the screen showing the balance is among them. An app can protect its login screen and leave the transaction history exposed to the task switcher.

Same conclusion as pinning: static analysis directs dynamic verification, it does not replace it.

The principle underneath

All four traps share a structure. In each case the tool can produce "nothing found" for two entirely different reasons:

  1. the property is correctly implemented, or the absence is benign;
  2. the tool was not capable of seeing it.

An audit that writes the same thing in both cases measures nothing. It manufactures confidence, and manufactured confidence on a banking app is more expensive than no audit at all — because it closes the question.

Hence the rule I apply without exception: three states, never two.

StateMeaning
PASSchecked, and the result is good
FAILchecked, and the result is bad
NOT CHECKEDoutside what this method can establish — said out loud

A report with an empty third column is a report that lies by omission. And the number of NOT CHECKED rows is itself information: it tells the client what they have not bought yet.

What an APK alone genuinely establishes

To close, the honest list. With no test device and no interception mandate, an APK gives firm conclusions about:

  • android:debuggable in a production build — flat, unambiguous
  • allowBackup and exposure of data to system backup
  • requested permissions, and which have no relation to the function
  • exported components, provided the default is computed
  • declared per-domain cleartext exceptions
  • bundled third-party libraries and their versions

And it does not support conclusions about effective pinning, real protection of sensitive screens, the strength of encrypted storage, or runtime behaviour.

That boundary is not a weakness of the method. It is the method.


The manifest parser used for this work is public: github.com/rolandsanou/axmlite. It reads Android binary XML with the standard library alone, and it computes exported as described above — a sixteen-byte story explains why I no longer trust a parser I have not tested against the format itself.

OP_CENTER
STATUS: ENCRYPTED
SYS_BOOT.exeDOSSIER.exeREGISTRY.exeNOTES.logSERVICES.exeFORMATIONS.dirCV_DATA.dirSECURE_CONNECT