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.
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/unpinproxy) 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 plushyper'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_writeinside 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 throughSSL_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
h2thenhttp/1.1over ALPN. Whatever the app's TLS stack picks, the proxy reads the protocolrustlsactually negotiated back — it never assumes.h2runshyper'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.1preference and, independently, reads back whichever the origin chose. Anh2origin getshyper'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_RANDOMkeyfile decrypts recorded TCP TLS (HTTP/1.x, HTTP/2) offline. QUIC uses a different, QUIC-specific key schedule, so a classicCLIENT_RANDOMkeylog does not decrypt it (see HTTP/3).
Prerequisites (all versions)
- Device connected:
adb devicesshows it asdevice(rutken deviceslists 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
ClientHellowhose ALPN offer typically listsh2andhttp/1.1. The proxy peeks it, confirms it can MITM it (the offer containsh2and/orhttp/1.1), and terminates TLS with a forged leaf whose server config advertisesh2thenhttp/1.1. -
rustlssettles onh2; the proxy reads that back and switches the client-facing connection tohyper::server::conn::http2. -
Independently, the proxy-to-origin leg re-negotiates ALPN
with the real server and uses
hyper::client::conn::http2if the origin also choseh2— 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_bytescap — 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, andhyper'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 runhyper'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_writerecord-layer calls that carry HTTP/1.x and HTTP/2. -
It is not system-proxy routable. The
Android
http_proxysetting 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/unpindecrypt 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_writenative 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_RANDOMkeylog (Way 4) does not decrypt QUIC. QUIC derives packet protection from a different key schedule than TCP TLS, so aCLIENT_RANDOMkeyfile — 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 standardnative-tls.jsonsymbol 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_RANDOMworkflow, 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
capturethen 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.0orHTTP/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.xSSL_writebuffer begins with an ASCII request line (GET / HTTP/1.1\r\n…); an HTTP/2 one begins with the prefacePRI * HTTP/2.0\r\n\r\nSM\r\n\r\nfollowed 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.1norh2(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
(
h2thenhttp/1.1on both TLS legs) and proxied throughhyper'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_writetap (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.