Documentation / Dynamic analysis

Capturing every HTTP version

This page is a complete, end-to-end CLI walkthrough for reading an Android app's HTTPS traffic in cleartext across every HTTP transport version it might speak: HTTP/1.0 and HTTP/1.1, HTTP/2 (h2), and HTTP/3 over QUIC. For each one it gives the exact commands, prerequisites, how to trigger traffic, the expected output, and how to tell which version actually negotiated. It also covers the one version the system-proxy MITM cannot catch — QUIC — and shows exactly what still works there. For the routes themselves (unpin, capture --embed-trust, run --bespoke) and teardown, start with the Dynamic capture page; for the per-stack pinning matrix, the pinning-bypass coverage page.

Authorization comes first

Every command on this page touches a live device or process over adb / Frida or Rutken's in-house bespoke agent, and the pinned-traffic routes actively defeat the app's own TLS checks. Run them only against an app you own or are explicitly authorized to test.

The behaviour-changing hooks take their consent acknowledgements verbatim and fail closed without them. The ack phrases on this page are the literal strings each verb expects; a bad or missing ack exits 2 before anything touches the device. The HTTP version you are chasing never changes the consent surface — the same acks gate HTTP/1.1, HTTP/2, and the QUIC-adjacent routes identically.

The transport picture

There are two fundamentally different places Rutken can read an app's HTTP, and which HTTP versions each one covers is the whole story of this page:

  • The system-proxy MITM (the capture / unpin proxy) is a TCP, CONNECT-style, loopback-only listener that terminates TLS with a forged per-SNI leaf and re-dials the real origin. It decrypts and fully parses HTTP/1.0, HTTP/1.1, and HTTP/2 — the last via ALPN negotiation plus hyper's HTTP/2 server/client. It does not see HTTP/3, because HTTP/3 is QUIC over UDP and never arrives on a TCP proxy at all.
  • The native in-process tap (Way 2 — SSL_read / SSL_write inside the app's BoringSSL) reads cleartext before the app encrypts and after it decrypts. It is transport-agnostic for anything carried over a TCP TLS record layer — so it reads HTTP/1.x and HTTP/2 plaintext frames regardless of pinning or proxy. It is the differentiator, and it is also the only thing that gets anywhere near QUIC (with the caveat in the HTTP/3 section: QUIC does not flow through SSL_read/SSL_write).

So: three of the four HTTP versions you will meet are fully covered (1.0, 1.1, 2). HTTP/3 / QUIC takes a different route, covered below.

How the version is decided (ALPN)

HTTP/1.0 and HTTP/1.1 are decided by the request itself (there is no handshake-time negotiation — the client just sends GET / HTTP/1.0 or HTTP/1.1). HTTP/2 is chosen at TLS-handshake time via ALPN (Application-Layer Protocol Negotiation), and Rutken's proxy participates in that negotiation on both TLS legs.

  • Client-facing leg. The forged server config advertises h2 then http/1.1 over ALPN. Whatever the app's TLS stack picks, the proxy reads the protocol rustls actually negotiated back — it never assumes. h2 runs hyper's HTTP/2 server; anything else (including a client that offered no ALPN extension at all, the common HTTP/1.1 case) runs the HTTP/1.1 server path.
  • Proxy-to-origin leg. The single outbound client dials the real origin advertising the same h2-then-http/1.1 preference and, independently, reads back whichever the origin chose. An h2 origin gets hyper's HTTP/2 client; otherwise the HTTP/1.1 client path.

The two legs are decided separately, so a device speaking h2 to the proxy while the origin only speaks http/1.1 (or the reverse) both work — the proxy bridges the versions. Either way the request and response land in the exact same flow model, so a recorded .rcap flow looks identical regardless of which HTTP version carried it (how to recover that fact anyway is in Telling which version negotiated).

What falls through to opaque passthrough. Before terminating TLS, the proxy peeks the ClientHello and checks the offered ALPN list. If it contains neither http/1.1 nor h2 — for example a bespoke label, h2c (cleartext h2), or spdy/* — the connection is never decrypted; raw bytes are relayed byte-for-byte and the flow is marked with the opaque scheme. A client that offers no ALPN list at all is treated as HTTP/1.1 and decrypted normally.

Which plaintext path applies per version

Rutken can read a connection's plaintext at four layers; the first that fits the app wins. Which of the four is available depends on the HTTP version:

  • Proxy MITM (Way 1) — applies to HTTP/1.0, HTTP/1.1, and HTTP/2. Needs a reachable system proxy and an app that honours it. Does not apply to HTTP/3 (UDP, never reaches the TCP proxy).
  • Native in-process tap (Way 2) — transport-agnostic for any TCP TLS, so it reads HTTP/1.x and HTTP/2 plaintext frames in-process, immune to pinning and needing no proxy. For QUIC it is the only candidate, but with a real limit: QUIC does not use SSL_read/SSL_write (see HTTP/3).
  • Java app-layer hook (Way 3) — version-independent: it reads the app's own decrypt/secret methods before the bytes ever hit a socket, so it is unaffected by whether 1.1, 2, or 3 carries them.
  • TLS keylog (Way 4) — a CLIENT_RANDOM keyfile decrypts recorded TCP TLS (HTTP/1.x, HTTP/2) offline. QUIC uses a different, QUIC-specific key schedule, so a classic CLIENT_RANDOM keylog does not decrypt it (see HTTP/3).

Prerequisites (all versions)

  • Device connected: adb devices shows it as device (rutken devices lists everything ADB-visible).
  • The target APK on disk. For a third-party app, pull it first: rutken pull --package <pkg> --out-dir ./apks.
  • For the pinned/repack routes, a signing keystore to resign the repacked APK:
keytool -genkeypair -keystore signing.p12 -storetype PKCS12 \
  -storepass <pw> -alias k -keyalg RSA -keysize 2048 \
  -validity 3650 -dname "CN=Test"

The dynamic verbs are feature-gated: a default rutken --help does not list unpin, capture record, or the --bespoke flags — build with the dynamic features to get them. None of the version coverage below is selected by a flag — the HTTP version is negotiated on the wire (ALPN) or read off the request line; you capture the same way and Rutken handles whatever version shows up.

HTTP/1.0 & HTTP/1.1

These two are combined because Rutken captures them identically: the proxy's HTTP/1.1 server path parses both the HTTP/1.0 and HTTP/1.1 request lines, produces the same flow model, and applies the same header/body caps and truncation accounting. There is nothing version-specific to configure — an app that pins to HTTP/1.0 (rare, but legal) and one on HTTP/1.1 are read the same way.

Route 1 — unpinned / unprotected traffic, system proxy

If the app does not pin (or you have already unpinned it), the quickest path is the headless recorder standing up a host-only proxy and provisioning the device:

rutken <app>.apk capture record \
  --out ./cap \
  --device --serial <serial> \
  --ca-store user \
  --bodies \
  --duration 120 \
  --i-am-authorized "I am authorized to intercept traffic for this target"

--device installs the proxy's CA and points the device's http_proxy at the listener (both write-ahead ledgered and undone on exit). --bodies retains full request/response bodies. --i-am-authorized is the verbatim capture ack.

Route 2 — pinned traffic (Community: unpin)

When TLS pinning is in the way, drive the bundled Frida gadget, which neutralizes the app's trust check and records the decrypted result in one bounded window:

rutken app-gadget.apk unpin \
  --gadget --gadget-port 27042 --ca-store none --serial <serial> \
  --duration 120 --record out.rcap \
  --authorize <sha256-of-app-gadget.apk> \
  --i-am-authorized "I am authorized to intercept traffic for this target" \
  --i-am-authorized-subvert "I am authorized to subvert this target's defenses"

(See the Dynamic capture page for embedding the gadget first with embed-gadget.) For a Frida-hardened or non-debuggable release target, swap in the in-house agent via capture --embed-trust — same traffic, same flow model, no Frida fingerprints.

The trigger-after-proxy-live gotcha (critical)

Wait until adb shell settings get global http_proxy shows 127.0.0.1:<port> (for unpin, wait for the proxy-live line in the run output), then exercise the app — tap through it or fire your receiver. New connections pick up the proxy; launch-time pings may race ahead of it. The single most common "flows: 0 but pinning says bypassed" report is traffic triggered too early. This applies equally to HTTP/1.x and HTTP/2.

Expected output

Decrypted flows land in the bundle (a flows.jsonl plus headers/bodies). The closing summary prints flows recorded: N (and, for the pinning routes, ssl.trust-manager bypass=Bypassed). Inspect or export it offline:

rutken capture info ./cap
rutken capture export ./cap --format har --include-bodies

In the GUI Network view, selecting a flow shows the decrypted request line — e.g. GET /v1/profile HTTP/1.1 — with every header, including the real x-api-key. An HTTP/1.0 flow shows HTTP/1.0 on that line; an HTTP/1.1 flow shows HTTP/1.1. That request line is itself the clearest tell of which minor version the app used.

HTTP/2 (h2)

HTTP/2 is captured by the same commands as HTTP/1.x — you do not opt in to it. What makes it work is that both TLS legs advertise h2 over ALPN and Rutken runs hyper's HTTP/2 server/client whenever h2 is the protocol that negotiated. The capture is transparent: an h2 request and a http/1.1 request produce the identical flow record.

1. The ALPN negotiation (what actually happens on the wire)

  • The app's TLS stack sends a ClientHello whose ALPN offer typically lists h2 and http/1.1. The proxy peeks it, confirms it can MITM it (the offer contains h2 and/or http/1.1), and terminates TLS with a forged leaf whose server config advertises h2 then http/1.1.
  • rustls settles on h2; the proxy reads that back and switches the client-facing connection to hyper::server::conn::http2.
  • Independently, the proxy-to-origin leg re-negotiates ALPN with the real server and uses hyper::client::conn::http2 if the origin also chose h2 — otherwise HTTP/1.1. The two legs need not match.

2. End-to-end capture

Identical to the HTTP/1.x commands — there is no version switch. For an unpinned/provisioned device:

rutken <app>.apk capture record \
  --out ./cap --device --serial <serial> \
  --ca-store user --bodies --duration 120 \
  --i-am-authorized "I am authorized to intercept traffic for this target"

For a pinned app, the same unpin / capture --embed-trust routes as above: the pinning bypass is orthogonal to the HTTP version. Observe the trigger-after-proxy-live rule — it bites HTTP/2 the same way.

3. How h2 framing shows up in the captured flows

The decrypted .rcap does not store raw HTTP/2 frames — hyper reassembles the HEADERS/DATA frames and HPACK-decoded headers into the same normalized request/response the HTTP/1.1 path produces. So in the flow and the GUI you see a clean method, path, headers, and body, not binary frames. What that means in practice:

  • Covered. Standard request/response exchanges (GET/POST/PUT/…), headers, and bodies up to the proxy's max_body_bytes cap — with the same truncation / data-loss accounting as HTTP/1.1.
  • Deferred (not silently mishandled — an h2 connection simply never exercises these): server push (the proxy never advertises enable_push, and hyper's h2 server defaults to push off), HTTP/2 trailers (dropped, not forwarded — there is no flow field to carry them), and any hand-tuned flow-control/window sizing (both legs run hyper's stock HTTP/2 defaults). None of these affect the HTTP/1.1 path.

4. The native tap sees the h2 plaintext frames

If you want the raw HTTP/2 wire — the connection preface and the actual HEADERS/DATA frames — read it off the native in-process tap (Way 2), which captures what the app hands to SSL_write before encryption and what it reads from SSL_read after decryption. That buffer is the literal h2 byte stream, no proxy involved:

rutken <app>.apk run --bespoke \
  --bespoke-attach-agent <arm64 libagent.so> \
  --bespoke-hooks native-tls.json \     # SSL_read / SSL_write / keylog symbols
  --bespoke-raw-bytes \                 # surface the actual bytes, not a {len, sha256} digest
  --bespoke-authorize <sha256> \
  --i-am-authorized-bespoke "I am authorized to observe this target via the bespoke agent" \
  --i-am-authorized-bespoke-active "I am authorized to MODIFY this target's behaviour via the bespoke agent"

With --bespoke-raw-bytes the first thing an SSL_write buffer shows on an h2 connection is the HTTP/2 connection preface PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n, followed by binary SETTINGS and HPACK-compressed HEADERS frames — which is also the surest way to confirm the app really spoke h2 rather than HTTP/1.1 (see below). Under run --bespoke the return-override gate is not auto-armed; if a hook refuses with ReturnOverrideDisabled, run adb shell setprop debug.rutken.return_overrides 1 first (only the literal "1" enables it).

HTTP/3 / QUIC

HTTP/3 is fundamentally different from HTTP/1.x and HTTP/2, and no amount of proxy configuration changes that:

  • It is UDP, not TCP. QUIC runs over UDP and carries its own transport (streams, flow control, multiplexing) inside encrypted UDP datagrams.
  • It has its own crypto, not the TCP TLS record layer. QUIC uses TLS 1.3 for the handshake but encrypts packets with QUIC's own packet-protection key schedule — it does not flow through the SSL_read/SSL_write record-layer calls that carry HTTP/1.x and HTTP/2.
  • It is not system-proxy routable. The Android http_proxy setting and Rutken's CONNECT-style TCP proxy only ever see TCP. A QUIC flow on UDP/443 never arrives at the proxy to be MITM'd.

What does NOT work

  • System-proxy MITM (Way 1) will not catch QUIC. This is a structural limit: capture / unpin decrypt TCP HTTP only. A pure HTTP/3 flow is invisible to the proxy — it is neither decrypted nor even relayed opaquely, because it is UDP and the TCP listener is never on its path.
  • The SSL_read/SSL_write native tap (Way 2) does not see QUIC payload. Those symbols are the TCP TLS record layer; QUIC's application bytes go through BoringSSL's QUIC crypto interface instead, so the standard native-TLS hook set reads nothing for a QUIC connection.
  • A classic CLIENT_RANDOM keylog (Way 4) does not decrypt QUIC. QUIC derives packet protection from a different key schedule than TCP TLS, so a CLIENT_RANDOM keyfile — which decrypts TCP TLS in Wireshark — does not decrypt captured QUIC datagrams.

What still works

  • Native tap on the QUIC library's own crypto, if reachable. The in-process approach is still the right layer — but it must hook the QUIC implementation's plaintext boundary (the point where the app's HTTP/3 library hands application data to, or receives it from, QUIC packet protection) rather than SSL_read/SSL_write. This is a different, library-specific hook set from the TCP-TLS one; it is not part of the standard native-tls.json symbol list above.
  • QUIC keylog for offline decryption. If you can make the app (or its QUIC library) emit QUIC key-update secrets to a keylog, Wireshark can decrypt a captured UDP/QUIC trace offline — the QUIC analogue of the CLIENT_RANDOM workflow, not the same keyfile.
  • Force HTTP/3 off, fall back to the covered path. The most reliable practical route today is to deny the app its QUIC path so it falls back to HTTP/2 or HTTP/1.1 over TCP — which capture then decrypts normally. Many clients do exactly this when UDP/443 is blocked or when Alt-Svc advertisement is unavailable, and the trigger-after-proxy-live flow then applies unchanged.

Bottom line: HTTP/1.0, HTTP/1.1, and HTTP/2 are fully covered by the system-proxy MITM and by the native tap. HTTP/3 over QUIC is not system-proxy MITM'able and is not read by the TCP SSL_read/SSL_write tap; it requires a QUIC-specific native hook or keylog, or forcing the app back onto a TCP transport.

Telling which version actually negotiated

Because the flow model is deliberately version-agnostic (an h2 request and an http/1.1 request produce the same .rcap record), you often cannot read the negotiated version off the flow body alone. Four reliable tells:

  • The request line (HTTP/1.0 vs 1.1). In the decrypted flow / GUI Request pane, the request line ends in HTTP/1.0 or HTTP/1.1 — that is the exact minor version the app sent.
  • The native-tap raw bytes (HTTP/1.x vs h2). With --bespoke-raw-bytes, an HTTP/1.x SSL_write buffer begins with an ASCII request line (GET / HTTP/1.1\r\n…); an HTTP/2 one begins with the preface PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n followed by binary frames. This is the unambiguous h2-vs-1.1 signal.
  • The opaque scheme (not-MITM'able TCP). A flow recorded with the opaque scheme means the client's ALPN offer contained neither http/1.1 nor h2 (e.g. h2c, spdy/*, a bespoke label) — so it was relayed byte-for-byte, never decrypted.
  • No TCP flow at all (likely QUIC). If the app clearly made a request but nothing — not even an opaque flow — appears for it, suspect HTTP/3 over UDP: confirm with a UDP/443 capture and treat it per the HTTP/3 section.

Raising proxy verbosity (-vv) also traces the per-connection ALPN protocol rustls negotiated, if you want the version named at the handshake layer rather than inferred from the payload.

Per-version summary

HTTP/1.0
Fully covered. Proxy MITM (Way 1) decrypts it via the HTTP/1.1 server path; native tap (Way 2), app-layer hook (Way 3), and keylog (Way 4) all apply. Tell: request line ends HTTP/1.0.
HTTP/1.1
Fully covered, identical commands to HTTP/1.0. All four plaintext ways apply. Tell: request line ends HTTP/1.1.
HTTP/2 (h2)
Fully covered, same commands — negotiated via ALPN (h2 then http/1.1 on both TLS legs) and proxied through hyper's HTTP/2 server/client. Server push and trailers are deferred (never exercised, not mishandled). All four ways apply; the native tap shows the raw preface + frames. Tell: PRI * HTTP/2.0… preface in the Way-2 raw bytes.
HTTP/3 / QUIC
Different transport. UDP + QUIC's own crypto: not system-proxy routable (Way 1 misses it) and not read by the TCP SSL_read/SSL_write tap (Way 2's standard symbols miss it). What works: a QUIC-specific native hook on the QUIC library's plaintext boundary, a QUIC keylog for offline decryption, or forcing the app to fall back to HTTP/2 / HTTP/1.1 over TCP (which is then fully covered).

See also: Dynamic capture (the routes, delivery, and teardown in full) and pinning-bypass coverage (the per-stack matrix). The pinning bypass is orthogonal to the HTTP version — it decides whether a connection can be decrypted at all, not which HTTP version it carries.