~/posts/mobile/flutter-ios-routing-tls-analysis.md

iOS Flutter: verify routing and TLS as separate boundaries

Separate VPN routing, transparent proxying, Mach-O address translation, and TLS decisions in a 2020 ARM64 Flutter sample. Check constant-search candidates, a 32-byte prefix, and correlated proxy and device results.

date[31:24]
read[23:16]
7 min
cat[15:8]
Mobile
Contents
  1. 0x00Establish an HTTP baseline first
  2. 0x01Redirection and transparent parsing are different jobs
  3. 0x02Identify the IPA image and its address spaces
  4. 0x03A constant search only produces candidates
  5. 0x04Bound the search and require an unambiguous match
  6. 0x05Close the evidence chain at both ends
  7. 0x06References

An empty proxy history is first a routing question. A connection that reaches the proxy but fails over HTTPS needs a separate TLS investigation. This 2020 iOS test application uses Dart HttpClient for both HTTP and HTTPS requests, allowing the two boundaries to be tested independently.

The analysis concerns the bundled ARM64 Flutter engine, not every Flutter version or plugin. Requests carry Dart/2.7 (dart:io), but that header alone does not identify the full engine commit or build configuration.

Establish an HTTP baseline first

The minimum evidence chain has three observation points: device, gateway, and proxy. An application success message establishes that a response arrived. To establish the intended route, correlate that operation with the inbound connection and proxy entry.

Layered verification matrix4 rows
Observation point Required evidence Check first if missing
Gateway ingress A connection from the test device Wi-Fi, VPN, interface, routing, and DNS
HTTP proxy Host, path, and response status Redirection, bind address, transparent parsing
TLS handshake Connection arrival and handshake start Destination recovery, SNI, server reachability
Application result A response matching the proxy record Certificate, hostname, and application checks

The device can reach a Linux gateway through the analysis machine's hotspot or through OpenVPN. The goal is to place that gateway on the actual forwarding path, not merely fill in the system HTTP proxy again. Dart exposes its own proxy-selection interface, findProxy; its documentation specifies direct connections when that function is unset. Platform channels and other clients still need separate inspection. Dart HttpClient.findProxy

Import the VPN profile and check connectivity4 steps
  1. 1

    Prepare a dedicated profile

    Transfer only the configuration needed for this test through a controlled channel. Do not expose a whole home directory through an HTTP file server, including shell history, key directories, or unrelated files.

  2. 2

    Hand the file to the client

    Open the configuration file with OpenVPN. The historical interface shows a file-import page and an ADD action to finish importing; exact labels vary with client versions.

  3. 3

    Connect the tunnel

    Confirm the profile was imported and reaches CONNECTED. Moving traffic counters establish tunnel activity, not that a particular application request reached the proxy.

  4. 4

    Send HTTP first

    Trigger HTTP Request and correlate that operation across device, gateway, and proxy before investigating HTTPS.

Redirection and transparent parsing are different jobs

The gateway setup sends TCP ports 80/443 from a dedicated ingress to a local listener on port 8080. Names such as tun0, wlan0, and eth0 are environment-specific. Confirm forwarding, NAT, firewall backend, and routing against the actual topology. Match only the test device or dedicated ingress to avoid redirecting the proxy's own outbound connections back into itself.

The important listener states are summarized below. 192.0.2.1 is a documentation address, not a deployment value.

Transparent proxy listener settings5 rows
Setting Illustrative value or state
Bind to port 8080
Bind to address A specific gateway ingress address, such as 192.0.2.1
Support invisible proxying Enabled
Force use of TLS Unchecked
Redirect to host / port Blank in this setup

An explicit HTTP proxy expects a full URL; HTTPS clients normally identify the destination through CONNECT first. A transparently redirected client may instead send an origin-form request or begin TLS immediately. Burp's invisible proxying handles these inputs: HTTP destination selection depends on Host, while certificate selection during the handshake can depend on SNI. Missing information calls for explicit destination handling, not an assumption that NAT has solved every protocol detail. PortSwigger: invisible proxying

Identify the IPA image and its address spaces

The framework in this sample is located at:

Flutter image inside the IPA
  • Payload/
    • Runner.app/
      • Frameworks/
        • Flutter.framework/
          • Flutter
4 directories, 1 file

The sample contains ARMv7 and ARM64 slices; this analysis uses ARM64. Confirm the slice actually loaded and whether its code is suitable for analysis. A byte-pattern hit has little explanatory value if encrypted code has not first become credible instructions.

File offsets, image virtual addresses, and runtime addresses are separate coordinate systems. Within a file-backed segment, the relationship is:

Address translation within one segmenttext
image_va = segment_vmaddr + file_offset - segment_fileoff
runtime_va = image_va + slide
segment_fileoff <= file_offset < segment_fileoff + segment_filesize

Apple's Mach-O definitions establish the roles of vmaddr, fileoff, and filesize. Zero-filled regions have no corresponding file bytes. Adding an arbitrary file offset directly to the module base is not a general translation rule. Apple loader.h

A constant search only produces candidates

Searching for scalar 0x186, decimal 390, yields two locations:

ARM64 static search results2 rows
Location Instruction Function label
0x0007c320 mov w3, #0x186 FUN_0007c178
0x00406a08 mov w3, #0x186 FUN_004068c8

These addresses belong to the analysis database, not the running device. The second candidate's context includes ssl_x509.cc, ssl_server, ssl_client, and certificate-processing branches. The combination of constants, string references, and call relationships supports identifying an outer certificate-chain decision function.

32-byte prologue of FUN_004068c8
0x004068c8ff 03 05 d1subsp, sp, #0x140
0x004068ccfc 6f 0f a9stpx28, x27, [sp, #0xf0]
0x004068d0f8 5f 10 a9stpx24, x23, [sp, #0x100]
0x004068d4f6 57 11 a9stpx22, x21, [sp, #0x110]
0x004068d8f4 4f 12 a9stpx20, x19, [sp, #0x120]
0x004068dcfd 7b 13 a9stpx29, x30, [sp, #0x130]
0x004068e0fd c3 04 91addx29, sp, #0x130
0x004068e408 0a 80 52movw8, #0x50

The visible control flow returns uVar5, assigning 0 on failure and 1 on success. Other BoringSSL interfaces may return an enum instead: the first member of ssl_verify_result_t, ssl_verify_ok, corresponds to 0. "Set every verification result to one" is therefore not a valid cross-function rule. BoringSSL ssl.h

Bound the search and require an unambiguous match

The five-image comparison found four matches and one miss; some matching addresses remained stable, while others moved. Full versions and image hashes are absent, so this establishes only limited reuse across those builds, not reliable cross-version coverage.

Shortening a prefix may improve recall while increasing false matches. Each candidate still needs to fall inside the correct executable image, identify an entry point or understood call site, execute on the current HTTPS path, and have a confirmed return convention. Uniqueness is only one of those checks.

This offline test covers segment bounds, empty signatures, zero and multiple matches, and the 32-byte prefix length. Its address-translation values are illustrative. It is neither a complete Mach-O scanner nor a device test.

flutter_ios_models.pypython
def fileoff_to_runtime(fileoff, segment_fileoff, segment_filesize,
                       segment_vmaddr, slide):
    if not segment_fileoff <= fileoff < segment_fileoff + segment_filesize:
        raise ValueError("offset outside file-backed segment")
    return segment_vmaddr + fileoff - segment_fileoff + slide

def unique_signature_offset(blob, signature):
    if not signature:
        raise ValueError("empty signature")
    hits = [i for i in range(len(blob) - len(signature) + 1)
            if blob[i:i + len(signature)] == signature]
    if len(hits) != 1:
        raise ValueError(f"expected one match, got {len(hits)}")
    return hits[0]

assert fileoff_to_runtime(0x2340, 0x2000, 0x1000,
                          0x100004000, 0x200000) == 0x100204340
for offset in (0x1fff, 0x3000):
    try:
        fileoff_to_runtime(offset, 0x2000, 0x1000, 0x100004000, 0)
    except ValueError:
        pass
    else:
        raise AssertionError("out-of-range offset accepted")
assert unique_signature_offset(b"ABCDWXYZ", b"WXYZ") == 4
for blob, sig in ((b"ABAB", b"AB"), (b"AB", b"X"), (b"AB", b"")):
    try:
        unique_signature_offset(blob, sig)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid signature match accepted")
prefix = bytes.fromhex(
    "ff 03 05 d1 fc 6f 0f a9 f8 5f 10 a9 f6 57 11 a9 "
    "f4 4f 12 a9 fd 7b 13 a9 fd c3 04 91 08 0a 80 52")
assert len(prefix) == 32
assert 0x004068c8 + len(prefix) == 0x004068e8
print("PASS: segment bounds; unique/zero/multiple/empty matches; 32-byte prefix")
Offline-check result
$ python flutter_ios_models.py
PASS: segment bounds; unique/zero/multiple/empty matches; 32-byte prefix

Changing a return value leaves the function body's side effects intact, unlike replacing the function entirely. An outward success result may therefore coexist with inconsistent session state, errors, or later checks. Dynamic validation must continue until the application receives a response, not stop when the hook fires.

Close the evidence chain at both ends

The proxy capture uses Burp Suite Community Edition 2020.2.1. Both entries are GET requests for / with status 200. Hosts below are documentation domains. The lengths preserve the proxy list's Length column and are not asserted to be response-body sizes.

Request-list excerpt2 rows
Protocol Host Method and path Status Length MIME
HTTP example.com GET / 200 1833 HTML
HTTPS secure.example.com GET / 200 43137 HTML

The visible HTTPS request headers are shown below with Host replaced. No missing response content has been filled in.

Request
GET / HTTP/1.1
user-agent: Dart/2.7 (dart:io)
Accept-Encoding: gzip, deflate
content-length: 0
host: secure.example.com
Connection: close

Interface illustration: HTTP and HTTPS request buttons and their respective success states

Interface illustration: the HTTP baseline and HTTPS check are shown side by side, not as simultaneous operations.

The device displays success for HTTP and HTTPS, while the proxy contains readable requests. Together these support the two-boundary interpretation for this sample. They do not prove that the system trust store was configured successfully or that application-specific signatures, pinning, and response validation were also handled.

For another sample, record the engine version, architecture, image hash, candidate location, return semantics, and before/after request state again. The transferable result is the validation order, not this build's absolute address or prologue bytes.

References

NORMAL~/posts/mobile/flutter-ios-routing-tls-analysis.md§--
0%en