~/posts/mobile/flutter-armv7-proxy-tls-analysis.md

Flutter ARMv7 traffic: separate routing from TLS verification

Separate proxy routing from TLS trust in a 2019 Android ARMv7 Flutter build. Trace handshake errors and string references to a Thumb entry point, then check return values, side effects, and plugin-layer boundaries.

date[31:24]
read[23:16]
9 min
cat[15:8]
Mobile
Contents
  1. 0x00Establish that the request reaches the proxy
  2. ·With source: set findProxy explicitly
  3. ·Without source: check the forwarding mode
  4. 0x01Follow the handshake error to the TLS implementation
  5. 0x02Distinguish a Boolean from a verification enum
  6. 0x03Trace the string to a Thumb entry point
  7. 0x04Check address semantics before attaching
  8. 0x05Close the evidence chain with a request
  9. 0x06Pinning can also live in a plugin
  10. 0x07Reuse the reasoning, not the fixed signature

An Android app can reach its backend while leaving no requests in the proxy. That does not immediately implicate certificate pinning. Routing determines where a connection goes; TLS verification determines whether the peer is accepted. Establish each path separately.

The 2019 Android ARMv7 Flutter test app uses dart:io, with its native TLS path in libflutter.so. Jeroen Beckers' experiment separates proxy selection from certificate-chain validation. The analysis is specific to that build; its trust-store behavior should not be generalized to every later Flutter release.

Illustration of Flutter and Burp traffic analysis

Editorial illustration. It represents the tools, not a network topology or an experimental result.

Establish that the request reaches the proxy

The test app starts from the counter example. Its button callback creates a request with HttpClient.getUrl() and sends it with request.close(). A changing counter establishes that the callback ran; a response or log is needed to establish request completion. Test HTTP first, then HTTPS against the same target, to avoid changing several conditions at once.

Interface illustration of the Flutter counter at 0 and ProxyDroid with its proxy switch on

Interface illustration. The left card shows the test app with its counter at 0 and a plus button. On the right, the proxy switch is ON, Auto Setting is unchecked, the port is 8888, and the type is HTTP. 192.0.2.10 is a documentation-only example address.

With source: set findProxy explicitly

In the historical record, the app logged a successful request but Burp saw no corresponding traffic. Without findProxy, this client connected directly; Android's Wi-Fi proxy setting did not automatically configure this Dart path. The Dart findProxy documentation also distinguishes direct connections from a resolver returning PROXY host:port.

Explicit proxy configuration (example address)dart
final client = HttpClient();
client.findProxy = (uri) => 'PROXY 192.0.2.10:8888';

The HTTP request record follows, with the host redacted as target.example. It shows request fields, not a response:

Historical HTTP request · host replaced
Request
GET / HTTP/1.1
user-agent: Dart/2.4 (dart:io)
Accept-Encoding: gzip, deflate
content-length: 0
host: target.example
Connection: close

This establishes proxy selection, not TLS acceptance. findProxyFromEnvironment instead depends on the environment of the app process. Setting http_proxy in a desktop shell does not establish that an Android app launched by zygote inherited it.

Without source: check the forwarding mode

The historical test used ProxyDroid with root access to change the connection path through iptables. An explicit HTTP proxy and transparent forwarding are different arrangements: TCP redirection does not manufacture a CONNECT exchange. The listener mode, recovery of the original destination, and forwarding rules must agree.

At this stage the established path is button → Dart client → TCP route → proxy. HTTPS certificate-chain verification remains a separate question.

Follow the handshake error to the TLS implementation

After switching the URL to HTTPS, the proxy observed a connection but the handshake failed. The historical device already trusted the proxy CA at the system level. This Dart/Flutter build, however, used a separate root collection and BoringSSL verification, so the system-store change did not directly affect that path.

Excerpt from the historical errorlog
CERTIFICATE_VERIFY_FAILED: self signed certificate in certificate chain(handshake.cc:352)

handshake.cc is a locating clue; 352 is not a fixed binary offset. Following the source leads to a failure branch in ssl_verify_peer_cert that records the error and then sends a fatal alert:

Failure branch in ssl_verify_peer_certcpp
if (ret == ssl_verify_invalid) {
    OPENSSL_PUT_ERROR(SSL, SSL_R_CERTIFICATE_VERIFY_FAILED);
    ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
}

Overriding the outer function's return value on exit leaves the earlier alert intact. Changing a return value does not undo side effects. Follow the control flow further upstream before choosing the interception point.

Distinguish a Boolean from a verification enum

The outer function maps a Boolean chain-validation result to a verification enum:

Mapping chain validation to the verification enumcpp
ret = ssl->ctx->x509_method->session_verify_cert_chain(
          hs->new_session.get(), hs, &alert)
          ? ssl_verify_ok
          : ssl_verify_invalid;
Two return conventions in one call chain2 rows
Layer Success value What to check at the interception point
Outer verification result ssl_verify_ok = 0 Whether a fatal alert has already been sent
session_verify_cert_chain true = 1 Whether execution is still before the outer failure handling

The sample uses the inner function because this build's relevant failure path primarily records an error, before the outer alert is sent. That narrower side-effect claim needs support from both source and the sample. It is not a guarantee about every function with the same name.

The error macro also leaves an anchor in a stripped binary:

Error reporting retains the source filenamecpp
#define OPENSSL_PUT_ERROR(library, reason) \
  ERR_put_error(ERR_LIB_##library, 0, reason, __FILE__, __LINE__)

__FILE__ brings the source filename into the binary. The search narrows from the entire TLS implementation to code that references ssl_x509.cc.

Trace the string to a Thumb entry point

Ghidra string-search result4 rows
Field Visible content
Filter x509.cc
Location 0x000815c2
Len 48
String View ../../third_party/boringssl/src/ssl/ssl_x509…

The string view truncates the path. The ellipsis denotes the part not shown; it is not part of the filename. That location has four cross-references (XREFs):

Cross-references to 0x000815c24 rows
Index Reference address
1 0x002fd9c6
2 0x0034b3ec
3 0x0034b546
4 0x0034b65a

Four reference addresses do not imply four distinct functions. After comparing the enclosing functions' arguments, branches, and error reporting, the historical analysis identified FUN_0034b330 as the candidate. That is an address-derived Ghidra label, not a stable exported symbol.

ARMv7 Thumb · FUN_0034b330 entry excerpt
0x0034b3302d e9 f0 4fpush{r4,r5,r6,r7,r8,r9,r10,r11,lr}
0x0034b334a3 b0subsp,#0x8c
0x0034b33682 46movr10,r0
0x0034b33850 20movr0,#0x50
0x0034b33a10 70strbr0,[r2,#0x0]
0x0034b33cda f8 98 70ldr.wr7,[r10,#0x98]
0x0034b34000 2fcmpr7,#0x0
0x0034b3424c d0beqLAB_0034b3de
0x0034b34438 68ldrr0,[r7,#0x0]
0x0034b34600 28cmpr0,#0x0

The first 12 entry bytes provide a candidate-search signature:

Historical entry signature (12 bytes)
0034B3302DE9F04FA3B0824650201070
  1. push0x34B330–0x34B333
  2. sub0x34B334–0x34B335
  3. mov0x34B336–0x34B337
  4. mov0x34B338–0x34B339
  5. strb0x34B33A–0x34B33B

A unique match establishes uniqueness within the scan, not function identity. Optimization, stack-frame size, and register allocation can all change these bytes. Call relationships and semantics still establish what the function does.

Check address semantics before attaching

The example below scans for a candidate and checks the ARM architecture and executable mapping. Before running it, review the current binary's function semantics and confirm that libflutter.so has actually loaded. The code example alone is not a device-validation result.

Hook logic for the historical ARMv7 samplejavascript
if (Process.arch !== 'arm') throw new Error('Expected 32-bit ARM');
const mod = Process.findModuleByName('libflutter.so');
if (mod === null) throw new Error('libflutter.so is not loaded');

const pattern = '2d e9 f0 4f a3 b0 82 46 50 20 10 70';
const matches = Memory.scanSync(mod.base, mod.size, pattern);
if (matches.length !== 1) {
  throw new Error('Expected exactly one reviewed candidate');
}

const candidate = matches[0].address;
const range = Process.findRangeByAddress(candidate);
if (range === null || !range.protection.includes('x')) {
  throw new Error('Candidate is not in executable memory');
}

const entry = candidate.or(1);
Interceptor.attach(entry, {
  onLeave(retval) {
    retval.replace(1);
  }
});
  1. The low bit 1 denotes Thumb state; it does not skip the first instruction byte. This follows Frida's Interceptor address convention.
  2. The replacement is the Boolean success value of session_verify_cert_chain, not the outer enum's ssl_verify_ok = 0.

A synchronous scan throws on an unreadable page. Inspect the mapping rather than interpreting that exception as an absent target. A fixed one-second delay also does not establish that the module has loaded. An AArch64 build needs fresh locating work and the appropriate instruction-set semantics.

Close the evidence chain with a request

After routing and the verification point were handled, the historical Burp list showed an HTTPS request. Its visible content follows, with the same host substitution:

Historical HTTPS request · host replaced
Request
GET / HTTP/1.1
user-agent: Dart/2.4 (dart:io)
Accept-Encoding: gzip, deflate
content-length: 0
host: target.example
Connection: close
Scope of the HTTP and HTTPS request evidence2 rows
Observation stage Transport in the history list Direct observation
HTTP comparison http The proxy received a GET request
HTTPS result https The proxy displayed a decrypted GET request

The raw requests look identical because the HTTP request line does not encode TLS state. The distinction comes from Burp's transport field. These request records provide neither a complete response nor a continuous timeline, so they do not establish that every business request succeeded or constitute a strict single-variable experiment.

Pinning can also live in a plugin

One implementation loads selected trust material into a SecurityContext:

Restricting HttpClient trust materialdart
final context = SecurityContext(withTrustedRoots: false);
context.setTrustedCertificatesBytes(certificateBytes);
final client = HttpClient(context: context);

This assumes certificateBytes has already been read from an app resource. The configuration restricts accepted trust material; whether it amounts to certificate or public-key digest pinning depends on certificate type, chain construction, and additional checks. The test on this sample still depended on the same native chain-validation path.

Another arrangement uses a separate check request through ssl_pinning_plugin. Android's checkConnexion returns a result, and the app then sends a business request. The successful check needs an explicit binding to the identity of the subsequent connection. A Boolean check does not automatically place later traffic on the same verified TLS session.

Identify the connection object, verification entry point, trust material, and failure handling for each request. The plugin and libflutter.so are separate interception layers. Reaching one does not establish coverage of every networking path.

Reuse the reasoning, not the fixed signature

Checks to repeat for another build4 steps
  1. 1

    Identify the actual client

    Distinguish dart:io from platform plugins and other libraries. Record the build and processor architecture.

  2. 2

    Establish routing first

    Use an HTTP comparison to confirm that the request reaches the proxy before introducing HTTPS.

  3. 3

    Trace the verification entry point

    Use errors, source, and string references to locate the function. Check its return convention and alert side effects.

  4. 4

    Verify the candidate and the result

    Review function semantics, mapping permissions, and instruction-set state. Check the proxy request and app response separately.

The reusable chain is proxy selection → TLS implementation → error side effects → binary location → return convention → request result. 0x0034b330 and the 12-byte signature belong to the historical sample. Re-establish that mapping for each new build.

NORMAL~/posts/mobile/flutter-armv7-proxy-tls-analysis.md§--
0%en