23 KiB
Protocol reverse engineering
Load this when the app's traffic is not readable by the usual means: the body is protobuf with no schema, the transport is gRPC, the connection is QUIC rather than TCP, or the client refuses to trust your proxy because it validates the certificate inside a native library.
Everything in this file assumes you have already decided the traffic matters and that a proxy is
allowed to see it. If a feature-scoped TLS failure has appeared while the rest of the app works,
that is a different problem with its own file: read tls-and-cert.md first — it owns the trust-chain
attribution and the permissive-defaults template, and this file deliberately does not repeat them.
Claim strength. measured = an exact command and output exist in
references/evidence-summary.md §The capability matrix. inferred = documented behaviour or a step that
follows directly from a measured one. unverified = reported by others, not reproduced here. The
wire-format section is measured on a self-built fixture; every tool named below is unverified
unless stated otherwise.
1. Protobuf on the wire (measured)
Protobuf has no field names on the wire and no header. A payload is a flat sequence of
tag, value pairs, and a nested message is just a value of type "bytes whose content is another
sequence". That is the whole format; the schema supplies meaning and nothing else.
| Element | Encoding |
|---|---|
| Varint | base-128, least-significant group first, high bit set on every byte but the last |
| Tag | (field_number << 3) | wire_type, itself a varint |
| Wire type 0 | varint value (int32/int64/uint/bool/enum) |
| Wire type 1 | fixed 8 bytes (fixed64/sfixed64/double) |
| Wire type 2 | length-delimited: length varint, then that many bytes (string, bytes, nested message, packed repeated) |
| Wire type 5 | fixed 4 bytes (fixed32/sfixed32/float) |
| Wire types 3/4 | group start/end, legacy; treat as unsupported when walking a modern payload |
A host-side check of the rules, including the canonical 150 -> 96 01, was run independently of the
shipped decoder — references/evidence-summary.md §The capability matrix records that cross-check against
the official runtime. Measured output on the fixture built for it:
message (29 B): 089601120774657374696e671a0508011201782206038e029ea7052800
field 1 varint 150
field 2 len-delimited(7) 'testing'
field 3 len-delimited(5) nested message:
field 1 varint 1
field 2 len-delimited(1) 'x'
field 4 len-delimited(6) hex '038e029ea705'
field 5 varint 0
rebuild from the walked rows equals original: True
Three consequences that decide how you interpret any decoded body:
- Field numbers are local to their message. In the walk above, field 3 of the outer message is a nested message whose own field 1 exists independently. Nothing on the wire says which message a field number belongs to; only a schema does.
- A packed repeated field is invisible without a schema. Field 4 above is three varints
(
3, 270, 86942) packed into one length-delimited value. A schema-free decode can only report it as opaque bytes — the length is known, the element boundaries are not. - A decoded
0and a field that was never set are two different questions. An implicit (no-presence) proto3 field whose value is the default writes nothing at all, sofield = 0and "never touched" produce identical bytes; a field with presence (proto2 optional,proto3 optional) set to0does put its tag and a zero byte on the wire. Both halves were measured against the official runtime; a schema-free decoder can only flag the case, never resolve it. So a decoded0was emitted on purpose by some writer, and a field missing from a decode is not evidence that 0 was the value in use.
Decoding without a schema
protoc --decode_raw < body.bin— the reference implementation's own raw decode; prints field numbers and values in exactly the shape above. Note it prints strings and nested messages without distinguishing them, which is why the fallback order matters: try a nested walk first, then printable UTF-8, then hex.protobuf-inspectorandblackboxprotobufare the pip-installable equivalents, and both add heuristics: guessing whether a length-delimited value is a string or a submessage, and (forblackboxprotobuf) inferring a candidate type set per field across many samples. Useful when you have traffic but noprotoc; still unverified here.- Decode many samples of the same message, not one. The number of distinct field numbers in a large sample set is what tells you which fields are optional, which repeat, and which are enums whose value set you can then enumerate.
- Anything the app stores locally is a sample source that needs no proxy at all: an OkHttp disk cache
(the
journalfile plus its entries), a DataStore file, or a Room blob column.runtime-data.mdcovers reading these, anddatastore_inject.pycan re-encode an edited DataStore value once you know the message.
The decoder that ships here (measured)
scripts/protobuf_decode_raw.py walks a payload with no schema and prints a tree. Input is a hex
string, a binary file, or stdin (hex text and raw bytes are told apart automatically); 0x prefixes,
spaces, commas, newlines and \xNN escapes are all accepted, and | splits independent frames.
--json emits the same tree as data, --max-depth bounds the nesting, and --reencode writes a
decoded (possibly hand-edited) tree back to bytes and can diff the result against the original.
$ python skills/apkseep/scripts/protobuf_decode_raw.py --hex "08 96 01 12 07 74657374696e67"
frame 0 bytes 0..12 (12 B) status=end_of_buffer
f1 varint 150
f2 len-delimited(7) view=utf8_string
candidate utf8_string 0.50 valid UTF-8 with no control characters
candidate packed_varint 0.50 the payload is exactly a sequence of 7 varint(s) ...
candidate bytes 0.20 opaque bytes: always consistent with the payload ...
tie: packed_varint and utf8_string are equally consistent with these bytes ...
string: 'testing'
The point of the tool is the candidate list. Fed a nested message that google.protobuf 6.33.6
itself produced (inner payload 0801120178), it answers:
nested_message 0.50 | packed_varint 0.50 | utf8_string 0.35 | bytes 0.20
tie: nested_message and packed_varint are equally consistent ... the view is a display default
Both readings really are legal protobuf for those six bytes, and no heuristic closes the gap. The
confidence numbers are a documented ordering, not probabilities, and the view exists only so the
tree can be expanded and re-encoded -- it is not a claim about what the field is.
Round trip, which is the strongest evidence available without a schema. Decode, re-encode, and
compare bytes: the message serialized by the official runtime re-encodes byte-identically to
SerializeToString(); a real AndroidX DataStore file (81 B, written by scripts/datastore_inject.py)
round-trips byte-identically; and editing one value in the JSON tree and re-encoding produced a file
that the other tool read back as the new value. Two of the built-in fixture families are exceptions
by design and are reported as such rather than hidden: a non-canonical varint re-encodes to the
minimal form, and a --split varint-length run compares frame bodies only, since the length prefixes
are framing and not message data.
What a schema-free decode cannot conclude -- write none of these down:
- that a length-delimited field is a string, a submessage or a packed array, rather than that it could be any of them;
- the element boundaries of a packed array, or its element type;
- that a field's value was 0 because it is absent from the decode, or that a sender "set" a field because a 0 appears (see the two traps above);
- which message a field number belongs to, or that the same field number in two payloads means the same thing;
- that a payload which parses cleanly is a real message at all -- §6 has that failure mode, and the fixes are a second sample and a round trip, not a longer look at the first one.
The measured record for this tool -- every command, both input forms, the round trip, and the
framing mistake it does not protect you from -- is in
references/evidence-summary.md §The capability matrix.
2. Recovering the schema from the APK
What generated protobuf code looks like
| Marker in the decompiled output | What it tells you |
|---|---|
extends GeneratedMessageLite / GeneratedMessageV3 |
protobuf class; Lite means the reflection/schema runtime, not the descriptor-based one |
static final int FIELD_NUMBER = 3; (or int <name>_ = 3) |
the field number, usually right next to the field's getter |
switch (tag >>> 3) with case constants |
the parse loop's field dispatch — each case is a field number, in declaration order |
input.readInt32(), readInt64(), readBool(), readStringRequireUtf8(), readBytes(), readMessage(...), readEnum() |
the field's wire type and semantic type, read after the tag |
input.readTag() returning 0 as the loop exit |
the standard parsePartialFrom skeleton; the loop body is where all field knowledge lives |
writeTo(CodedOutputStream) / getSerializedSize() / dynamicMethod(...) |
the class really is a generated message, not a hand-written DTO |
newMessageInfo(instance, "<escaped string>", ...) |
protobuf-lite's compact schema blob (see below) |
The generator keeps those framework calls even after R8, so an obfuscated app usually still carries readable protobuf structure: class and field names are gone, field numbers and wire types are not.
Two routes to a .proto
Route A — read the field table out of the bytecode (inferred). Decompile the class, walk
parsePartialFrom, and record case <n> → the read* call that follows. Field name unknown? Name
it field_<n>; a valid .proto does not need the original identifiers, and the app's own getter
names often survive anyway. Then verify immediately against captured bytes with
protoc --decode=<message> <schema.proto>: a decode that produces a coherent structure for every
sample is the proof that your field table is right, and it costs seconds. A field that decodes into
nonsense means a wrong wire type, not a wrong field number — check the read* call before moving on.
Route B — read protobuf-lite's own schema blob (inferred). protobuf-java-lite 3.x embeds a
compact schema string (rawMessageInfo) per message class, consumed by MessageSchema at runtime.
Recovering it means locating that blob and feeding it to a matching runtime build. It is a strictly
larger win when it works — it carries field numbers, types, and labels in one place — and it is
version-sensitive, so treat Route A as the default and Route B as the acceleration. unverified
here: no target in this repository was analysed this way.
Tooling that automates this (unverified): pbtk/protodump-style scanners walk dex/JAR inputs
looking for the generated-code shape above and emit .proto files, and a protobuf-aware decompiler
plugin can do the same from a loaded APK. Use them to produce a first draft, then verify it with
protoc --decode against real captured bytes — that verification is the step that turns a guess into
a schema, and it is the step worth spending time on.
3. gRPC
gRPC is HTTP/2 with a message framing convention, so the first decision is whether you can see HTTP/2 at all.
- Recognise it. A request carries
:method: POST,content-type: application/grpc[+proto](also+json,+thrift), and:path: /<package>.<Service>/<Method>— that path shape is the reliable fingerprint; the package in it usually matches a package name visible in the APK, which is how you connect traffic to a client class. - Message framing. Each message is: one compression-flag byte, then a 4-byte big-endian
length, then that many bytes of (possibly compressed) protobuf. Several messages can share one
DATA frame and one message can span frames, so reassemble by length, not by frame. The call's
outcome is in trailers (
grpc-status,grpc-message), which many proxies display separately from the body — a200with no body and a nonzerogrpc-statusis a normal gRPC error. - Read it after capture. Extract the reassembled message bytes and hand them to
protoc --decode_rawor your recovered schema.grpcurl -plaintext -protoset file.protosetreplays against an endpoint once you have a schema, which is also the fastest way to confirm that a recovered.protohas the right shape. - Capture it. A transparent TCP proxy sees HTTP/2 fine; a tool that only understands HTTP/1.1 may
show the tunnel without the frames, which looks like "the app is not making requests". Interception
tooling for this comes in two shapes: a proxy add-on that understands the gRPC framing and prints
messages (
grpc-dumpand similar mitmproxy add-ons, or a gRPC-Web decoder when the client is a browser-style stack), and a command-line client (grpcurl) for replay against a live endpoint. If the app uses TLS (gRPC-Java does by default), the certificate question comes first — seetls-and-cert.md, and the native-material trust chain below.
4. QUIC / HTTP/3
The failure to expect: a working HTTP proxy, a working CA, and an empty capture.
- Why. An HTTP proxy is a TCP endpoint, and a client reaches it with
CONNECT. QUIC is a UDP-based transport with TLS 1.3 built into the handshake; there is no TCP connection for aCONNECTto carry and no separate TLS layer for a proxy to terminate. - Recognise it before debugging anything else.
Alt-Svc: h3=":443"in an earlier HTTP response announced the upgrade; on the wire you see UDP traffic to port 443 whose first packets are QUIC Initials (long header, version field, and a TLS ClientHello inside), and the client's ALPN ish3. Check for UDP activity when a TCP proxy stays idle while the app plainly works. - Options, in order of how little they change:
- Force a fallback to TCP. Block or blackhole UDP 443 for the device. A client that cannot reach an origin over QUIC falls back to TCP/HTTP2 if the server also offers it, and then your existing proxy works unchanged. This is the cheapest option and the one to try first, but it is a network-condition change: state it in the report, because "the app failed to load over QUIC" and "the app failed" look identical in a screenshot.
- Decrypt with keys, not with a proxy. TLS 1.3 key logging (
SSLKEYLOGFILE-style) works for QUIC exactly as it does for TLS-over-TCP, and a packet capture plus the key log can be decrypted in Wireshark. It requires the client to support a key-log callback — Chromium-based stacks and BoringSSL generally do, Java/OkHttp-based stacks generally do not — so this is a per-client property, not a general solution. - Turn HTTP/3 off in the client by hooking whatever enables it (a library version check, a remote-config flag, or a build-time constant). This is a client-side patch and therefore belongs to the repack decision, not to the capture step.
- Everything in this section is
inferred/unverifiedhere: no HTTP/3 target was exercised in this repository. Treat the ordering as a plan whose first step (block UDP 443) needs no tool support to test.
5. SSL pinning that lives inside a native library
Three different mechanisms are routinely called "pinning", and only the last one survives a system certificate module.
| Mechanism | Where it runs | What defeats it |
|---|---|---|
OkHttp CertificatePinner (or a custom TrustManager) in Java/Kotlin |
app's own client | Client-level, not the system path: tls-and-cert.md §Step 4 and §"If OkHttp is the failing path" own this case — do not apply the permissive-defaults template to it |
System trust decisions via HttpsURLConnection / platform stack |
platform | System store policy — see tls-and-cert.md §Step 5 |
Verification performed by a library the app ships, e.g. a Flutter app's own BoringSSL, or a bundled libcurl/mbedtls |
libflutter.so, libapp.so, another .so |
Only an in-memory change to that library's verification path (below), because the library does not consult the platform trust manager you can influence from Java |
Flutter, specifically. A Flutter app's dart:io HTTP client runs on the engine's BoringSSL
inside libflutter.so; the app's own Dart code can additionally supply a custom trusted-certificate
context or a bad-certificate callback. Two consequences that decide which direction to work in:
- If the app is not pinning and simply fails to trust your CA, getting your CA into the system
store is the normal fix.
MoveCertificate-style Magisk modules exist precisely because Android 7+ ignores user-store CAs for app traffic; moving the CA into the system store is a device change, not an artifact, and it must be labelled as a fallback rather than the deliverable. Its boundary is exactly this: it changes what the platform trust store contains, so it can only help a client that reads that store. - If the app is pinning, or supplies its own verification context, the system store is irrelevant and the change has to land on the library's verification function.
Locating the verifier inside libflutter.so (unverified — reported method). The published
approach in
universal-flutter-ssl-pinning
(github.com/vichhka-git/universal-flutter-ssl-pinning) automates the anchor search with PyGhidra:
- load
libflutter.soheadlessly in Ghidra and scan its defined strings forssl_client; - resolve cross-references from that string to the containing functions;
- decompile each candidate to count its parameters;
- take the 3-parameter function — that is BoringSSL's
ssl_crypto_x509_session_verify_cert_chain, the certificate-chain verification entry point; - emit the discovered RVA into two ready-to-run artifacts: a Frida script that attaches to that
address and replaces the return value with
1(success), and a Renef script that patches the function entry with the ARM64 sequenceMOV X0, #1 ; RET.
The repository reports it working on Google Flutter arm64-v8a and Shorebird-patched builds with
Frida 16.x/17.x, and notes the RVA is identical across build types for the same engine version. The
same project also ships an HTTP monitor that auto-locates BoringSSL's SSL_write/SSL_read in
libflutter.so and prints reassembled, de-chunked plaintext — which is the route to take when the
traffic cannot be proxied at all, because it never needs a certificate the app will accept.
Two things to verify before trusting that recipe on your target. First, the string anchor is a
build artifact: a stripped or repacked engine can lose ssl_client while keeping the function, in
which case fall back to the structural search — find the X509_verify_cert-shaped call sequence in
the handshake code, or identify the function called with the handshake structure during
certificate_verify. Second, the failure mode of a wrong anchor is a silent success: replacing a
return value in a function that merely looks like the verifier makes the certificate check pass
in a process that then fails somewhere else, which is easy to misread as "the app detected my hook".
Always confirm the hook fires on a connection that would otherwise fail, and confirm it is
SSL_VERIFY-shaped semantics (the same value the library itself uses for success), rather than
assuming 1 is right.
When the string anchor is gone (the session_creator route). A second family of anchors does not
depend on the ssl_client literal at all: instead of finding the verifier through a string it
references, find it through the call chain that reaches it. BoringSSL's handshake creates the session
and then verifies the peer certificate chain, so the target is the function invoked with the
handshake/session structure on that path — this is the shape that Flutter-unpinning write-ups call the
session-creator route. It survives string and symbol stripping, because the call graph survives, and
it costs more: you must identify the handshake structure first, which is why the string anchor is the
fast path and this is the fallback when an engine build has dropped the string. unverified here — no
engine binary was analysed in this repository.
When the system store is genuinely enough. If the client is Java-side and non-pinning, moving the
CA into the system store is the least invasive change and needs no per-library work. The decision is
therefore: identify the client first (tls-and-cert.md §Step 4 attribution), then choose the
mechanism. Patching a library that was never consulting the store is work with no effect; installing
a system CA when a native verifier ignores it produces the same empty capture you started with.
6. Failure modes
A decode that looks valid but is not. protobuf is self-synchronising enough that random bytes often decode into a plausible-looking field list. Guard against it by (a) decoding several samples of the same message and expecting the same field set, (b) checking that nested lengths exactly consume their parents, and (c) when you have a schema, re-encoding the decoded structure and comparing bytes with the input — the same round-trip check used in, which caught a decoder bug in this repository's own fixture.
A framing choice that is wrong without being refused. --split varint-length means protobuf's own
writeDelimitedTo framing: a varint length, then the message. gRPC frames a message differently — one
compression-flag byte, then a four-byte big-endian length (). Handed a real gRPC frame, the
varint splitter reported five frames rather than failing, while the same bytes decoded with no
splitting reported stray_end_group. Strip the 5-byte gRPC prefix yourself, and check the frame count
either way: a wrong split still produces a decode, which is what makes it expensive.
Assuming the proxy sees everything. The proxy sees what the client sends through it. An app that
uses QUIC (), a raw socket, a native client bypassing Java's HTTP stack, or a certificate-pinned
connection shows up as silence. Silence is evidence about the transport, not about the app: check
UDP activity, check whether a native library is doing the I/O (lib_map.py, dynamic-frida.md),
and only then conclude that a feature is client-side.
Patching the wrong verifier. Covered in §5: the failure is silent, and the resulting state is worse than a clean failure because it looks like progress. Verify the hook against a connection known to fail.
Treating a recovered schema as ground truth for the server contract. A schema recovered from the
client tells you what the client expects to send and parse — it does not prove the server honours
those fields, and it says nothing about authorization. server-api.md owns that question. The
recurring expensive mistake is to assume that because a field exists and is settable client-side, the
server will act on it.