Reverse-engineering a mobile app's API: certificate pinning, TLS, and the protobuf wall
A mobile app talks to a backend you did not write, over a channel you cannot read, in a format with no field names. You point a proxy at the phone, install the proxy’s CA, open the app, and the request list stays empty. The app refuses to connect. Somewhere in its binary is a hardcoded fingerprint of the real server’s key, and your proxy does not match it. That is the wall most people hit on their first attempt, and most of them stop there.
This post is about what that wall is made of and why it works. It is a defensive, educational reference, not a recipe. I will explain how TLS interception works, why certificate pinning defeats a naive proxy, what runtime instrumentation does at a conceptual level to get past pinning, and what you find on the other side when the payloads turn out to be protobuf with the schema stripped out. The goal is to understand the system well enough to reason about it, whether you are testing your own app, auditing a vendor’s, or just trying to grasp why the mobile-RE workflow looks the way it does.
The shape of the problem
A modern mobile app is, from the network’s point of view, an HTTP client with opinions. It opens TLS connections to one or more backend hosts, sends requests, and parses responses. If you want to understand its API you want to read those requests and responses. The complication is that everything after the TCP handshake is encrypted, and the app has been built specifically to keep you out of it.
There are three layers to get through, and they fail you in order. First, TLS encrypts the traffic, so a passive sniffer sees nothing but ciphertext. You solve that with an intercepting proxy. Second, the app refuses to trust your proxy’s certificate because it pins the real server’s key. You solve that, if you can, by changing the app’s behaviour at runtime. Third, even with cleartext in hand, the payloads may be a binary format with no self-describing field names, so reading them is its own exercise.
Each layer is independent. A web API you discover through a browser’s network tab often clears all three for free: no pinning, JSON payloads, cleartext after the browser does the TLS for you. A mobile app makes you earn each one. If you have done the XHR-replay route on a website, the mobile version is the same idea with three extra fences in the way.
TLS interception, and why a proxy is allowed to read your traffic at all
Start with the layer everyone clears. TLS exists to stop a network observer from reading or tampering with traffic. The defence is anchored in certificates: the server presents one, signed by a Certificate Authority the client already trusts, and the client checks the signature chain back to a root in its trust store. If the chain validates and the hostname matches, the connection proceeds. If not, it aborts.
An intercepting proxy works by inserting itself as a deliberate man-in-the-middle, with your cooperation. mitmproxy, the most common open-source tool for this, ships a full CA implementation that generates interception certificates on the fly. The trick is that you install mitmproxy’s CA certificate into the device’s trust store yourself. Once that root is trusted, the proxy can mint a valid-looking certificate for any hostname the app connects to, sign it with its own CA, and the client’s chain validation passes. The proxy decrypts, inspects, re-encrypts, and forwards.
mitmproxy’s documentation lays out the explicit-HTTPS flow as a sequence. The client sends an HTTP CONNECT request naming the target host. The proxy answers 200 Connection Established. The client begins its TLS handshake, and critically it includes the target hostname in the Server Name Indication (SNI) extension of the ClientHello. The proxy reads that SNI value, opens its own connection upstream to the real server, completes that handshake, and copies the Common Name and Subject Alternative Names off the real certificate into the dummy certificate it generates for the client. SNI is what lets the proxy know which certificate to forge before it has talked to the server. When a client connects by IP with no SNI, mitmproxy falls back to upstream certificate sniffing: it pauses the client, connects upstream first, and reads the CN off the real cert to build its forgery.
This is the part people misread as a flaw in TLS. It is not. The proxy can only impersonate the server because you, the operator of the device, chose to trust its CA. Without that step the forged certificate fails chain validation like any other unknown cert. TLS is doing exactly what it promises. You have simply added a new trusted authority to your own device on purpose.
Getting the phone to send its traffic through the proxy at all is its own small ritual. The usual route is to set an HTTP proxy in the device’s Wi-Fi settings pointing at the machine running mitmproxy, then visit a magic hostname the proxy serves to download and install its CA. That covers apps that honour the system proxy. Plenty of apps do not, either because they use a networking stack that ignores the proxy setting or because they connect over protocols the proxy does not handle. HTTP/3 over QUIC is the common blind spot: it runs on UDP and most interception setups either cannot read it or simply block it to force the app back to HTTP/2, which is one reason HTTP Toolkit’s connection hook explicitly blocks HTTP/3. The fingerprintable shape of those HTTP/2 connections is a story in itself, covered in TLS and HTTP/2 fingerprinting; for interception, the practical effect is that you sometimes have to nudge the app off QUIC before you can see anything.
The Android trust-store change that started the arms race
Until 2016 the proxy story above was the whole story on Android. Install the CA as a user certificate, set the Wi-Fi proxy, read the traffic. Then Android 7.0 (API level 24) changed the default trust configuration, and the mobile-RE workflow has been more complicated ever since.
The change is small in the XML and large in effect. Android apps can declare a Network Security Configuration, an XML file that controls which CAs the app trusts. On Android 6.0 (API 23) and lower, the default base config trusted both <certificates src="system" /> and <certificates src="user" />. From Android 7.0 (API 24) onward, the default dropped the user entry. Apps trust only the system CA store unless they opt back into user certificates explicitly. A CA you install yourself goes into the user store, so by default a modern app ignores it.
That single default is why “install the cert and proxy” stopped working for app traffic. The mitmproxy CA sits in the user store, the app only consults the system store, and your forged certificates fail validation. Google’s documented escape hatch for developers is the <debug-overrides> element, which adds extra trust anchors but only when the app is built with android:debuggable="true". For an app you did not build, that does not help. The remaining paths are to move the CA into the system store, which on modern Android needs root or other elevated access, or to alter the app’s certificate handling at runtime.
And that is only the default. An app that wants to keep you out goes further and pins.
What pinning actually checks
Certificate pinning narrows trust from “any CA in the store” to “specifically this key, and nothing else.” The app ships with a fingerprint of the expected server identity baked in, and at connection time it compares the server’s presented certificate against that fingerprint. A forged certificate signed by your CA validates as a chain, but it does not match the pin, so the app rejects it. Installing your CA into the system store no longer saves you, because the pin check happens after chain validation and is stricter than it.
The detail that matters is what gets pinned. Modern practice pins the public key, not the whole certificate, using a digest of the certificate’s SubjectPublicKeyInfo (SPKI) field. Android’s Network Security Configuration expresses this directly. A <pin-set> holds one or more <pin digest="SHA-256"> elements, each a base64-encoded SHA-256 hash of the SPKI, plus an optional expiration date after which pinning is disabled as a safety valve. SHA-256 is the only digest algorithm the platform supports here. Pinning the SPKI rather than the full certificate lets the server rotate to a renewed certificate with the same key without breaking every installed app, which is why public-key pinning won over certificate pinning.
There are several places an app can put that check, and they differ in how hard they are to reach. The Network Security Configuration is the declarative, platform-level option, available since API 24. An app can also implement a custom TrustManager and do the comparison in its own Java or Kotlin code. Many Android apps lean on OkHttp’s CertificatePinner class, which takes pins in the form sha256/<base64> and runs its check method after a successful TLS handshake but before the connection is used. All three of these live in the managed runtime, which matters for the next section. The fourth option, native code, does not.
Why instrumentation is the standard answer, conceptually
If the pin lives in the app’s own logic, the way past it is to change that logic at runtime. This is where dynamic instrumentation comes in, and Frida is the tool the field has standardised on. Frida injects a JavaScript engine into a running process and lets you hook functions: intercept a call, read its arguments, and replace its return value or skip its body entirely. Nothing on disk changes. The patch lives in memory for the life of the process.
Applied to pinning, the concept is simple to state. The pin check is a function that returns “trusted” or throws. If you can reach that function and make it always return “trusted,” the app stops rejecting your forged certificate, and the proxy reads everything. HTTP Toolkit’s widely used frida-interception-and-unpinning toolkit is organised exactly around this idea. It carries a script that patches known pinning techniques, a fallback script that auto-detects and patches unknown ones after a connection fails, and separate scripts for the layers below.
I am deliberately not reproducing hook code here. The point of this section is the mechanism, not a working bypass. For a defender, the useful takeaway is the reverse: if every pin check in your app sits in a single hookable method that returns a boolean, one hook neutralises all of it. The structure of the check is part of its strength.
The managed-versus-native distinction is the whole game at this layer. A pin check written in Java or Kotlin, including OkHttp’s CertificatePinner and the Network Security Configuration enforcement, runs in the Android runtime, where method boundaries are well-defined and easy to hook by name. Security-sensitive apps know this. Facebook, Instagram, and high-security banking apps move the check into native C or C++ code, frequently by linking their own copy of BoringSSL or OpenSSL and validating inside it. Because that happens below the JNI boundary, there is no neat Java method to hook. Reaching it means hooking native functions by memory address, and that is why HTTP Toolkit’s native-tls-hook.js targets BoringSSL’s TLS validation specifically. Flutter apps are their own headache: Flutter does its TLS in its statically linked BoringSSL and ignores both the system proxy and the Network Security Configuration, which is why the toolkit ships a dedicated Flutter script and notes those apps as the hard case.
The same wall on iOS
Everything above is framed around Android because that is where most public tooling lives, but iOS plays the same game with different parts. There is no Network Security Configuration on iOS. The platform’s baseline is App Transport Security, which enforces TLS but does not pin by itself. Pinning is something the app adds on top, and the common building blocks are well documented.
The standard library is TrustKit, an open-source framework from Data Theorem that deploys public-key pinning on iOS 12 and up, plus macOS, tvOS, and watchOS. Like Android’s NSC and OkHttp, TrustKit pins the SubjectPublicKeyInfo, not the whole certificate. Its configuration takes an array of base64 SPKI hashes under the key kTSKPublicKeyHashes, scoped to domains under kTSKPinnedDomains, and it is explicitly modelled on the HTTP Public Key Pinning specification, RFC 7469. TrustKit can either swizzle the NSURLSession and NSURLConnection delegates to pin automatically, or expose a TSKPinningValidator you call from your own delegate. Apps that do not use TrustKit usually implement pinning by hand in the URLSession authentication-challenge delegate, calling SecTrustEvaluate to run standard chain validation and then comparing the server key against a hardcoded value.
The conceptual picture is identical to Android. The pin check is a function in the app’s process, and the question is whether it lives somewhere a hooking framework can reach. On iOS the runtime is Objective-C or Swift rather than the Android runtime, but Frida hooks Objective-C methods by name just as readily, which is why a delegate-based pin written in URLSession code is reachable in the same way an OkHttp CertificatePinner is on Android. And the same escalation applies: move the check into a statically linked native TLS library and the easy hook disappears.
The other side: when the app sees you back
The arms race does not stop at the pin. Instrumentation is observable, and an app that cares can look for it. This is the domain of runtime application self-protection, and the detection techniques are well documented because the same write-ups that explain them also explain how brittle they are.
Frida leaves traces. When frida-server runs on a device, the injected agent communicates with the server, and an app can scan /proc/self/maps for memory-mapped regions belonging to frida-agent.so. It can iterate the file descriptors under /proc/<pid>/fd looking for the named pipes Frida uses to talk between its components. It can check for Frida’s default control port, historically 27042. It can look for the threads Frida spawns to run its JavaScript engine. None of these is decisive on its own, and each has a known counter, which is why researchers describe runtime protection as something instrumentation quietly wins more often than not. The detections raise the cost; they rarely close the door.
For anyone studying anti-bot systems this should feel familiar. It is the same logic the JavaScript-runtime fingerprinting systems use in the browser: enumerate the artifacts your adversary’s tooling leaves behind, and treat their presence as a signal. The mobile version watches for Frida and root the way the web version watches for CDP and headless Chrome. Detection and evasion trade places with each release, the same stealth-patch lifecycle that plays out in browser automation.
The protobuf wall
Say you have cleared all of that. The proxy is up, the pin is neutralised, the app is talking, and the request list fills. You open a request and find a body that is not JSON, not form-encoded, not anything you can read. A short run of bytes, some printable, most not. This is the third wall, and for many apps it is protocol buffers.
Protobuf is Google’s binary serialization format, and it is built for size and speed, not for being read by a stranger. The encoded message carries no field names, no type labels beyond a coarse wire type, and no separators. The schema that gives those bytes meaning lives in a .proto file that the client and server share at build time and that you do not have. What you get on the wire is a flat sequence of fields identified only by number.
The encoding is worth understanding because it is what makes both the obstacle and the workaround possible. Each field begins with a tag, itself a varint, computed as (field_number << 3) | wire_type. The low three bits are the wire type; the rest is the field number. There are a handful of wire types: 0 is VARINT (used for the integer family, bools, and enums), 1 is I64 (fixed 64-bit values and doubles), 2 is LEN (length-delimited: strings, bytes, and, importantly, embedded messages), and 5 is I32 (fixed 32-bit values and floats). Wire types 3 and 4 were start-group and end-group, now deprecated. A varint encodes an integer in groups of seven bits, little-endian, with the most significant bit of each byte set as a continuation flag indicating whether another byte follows.
That structure is exactly why a schema-less payload is still partly readable. A parser does not need the .proto to walk the message, because the wire type tells it how long each field is. The protobuf compiler exposes this directly: protoc --decode_raw reads a raw protobuf message on standard input and prints the fields it finds, identified by number, with no names, recursing into wire-type-2 fields that look like nested messages. Piping a captured hex body through xxd -r -p into protoc --decode_raw turns an opaque blob into a labelled tree of numbered fields. You will see something like 1: 28, 2: "You", 5 { 1: "abc123" }. The numbers are real field numbers; the meanings are yours to infer.
Inference is the work. A field that is always a short ASCII string in the position where you expect a username probably is one. A field that increments by one each request is a sequence counter. A LEN field whose contents themselves decode as a valid sub-message is an embedded message, and you recurse. This is patient correlation between what you do in the app and what changes on the wire, and it is the same discipline as reverse-engineering a website’s private XHR API, only without the courtesy of field names.
A couple of encoding quirks bite during this phase and are worth knowing in advance. The decoder cannot always tell a length-delimited string from an embedded message, because both are wire type 2, so protoc --decode_raw guesses based on whether the bytes parse as a valid sub-message. Sometimes it guesses wrong and shows you a “message” that is really a string, or a string that is really a packed array of numbers. Negative integers are another trap. Protobuf has two ways to store signed values: the plain int32 and int64 types encode negatives as very large varints, while the sint32 and sint64 types use zigzag encoding, where positive p becomes 2p and negative n becomes 2|n|-1, so small magnitudes stay small regardless of sign. A field that decodes to an enormous number is often a negative int, and a field whose values alternate odd and even as you flip something in the app is probably a zigzag-encoded sint. None of this is recoverable from the bytes alone with certainty, which is exactly why correlating wire behaviour with app behaviour matters more than staring at the hex.
There is a discipline question hiding in this work, too. Once you understand the API well enough to call it directly, you are issuing requests that no real client would, at a rate no human would, and a backend watching for that will notice. The same rate-limiting and backoff hygiene that keeps a web scraper from getting blocked applies to a replayed mobile API, and a reconstructed protobuf client with no throttling is a fast way to get an account flagged.
Recovering the actual schema
Guessing field meanings by hand has a ceiling. The better outcome is to recover the real .proto, and for a meaningful fraction of apps that is possible, because the schema, or enough of it, ships inside the binary.
The reason is that some protobuf bindings embed descriptor metadata so the runtime can do reflection. IOActive’s write-up on reverse-engineering gRPC binaries makes the structural point: the binary must include schema information in some form to deserialize messages correctly, and where reflection metadata is embedded, which is typical of C++ and present in some other bindings, you can in most cases reconstruct the original .proto. The toolkit pbtk (protobuf toolkit) automates exactly this for Android: feed it an APK and it hunts for embedded descriptors and rebuilds .proto definitions from them. When that works, you go from numbered fields to named ones in a single step, and the rest of the API reads almost like JSON.
When it does not work, because the app uses a binding that strips descriptors or deliberately obfuscates them, you are back to inference, with the binary itself as your reference. Strings pulled from the decompiled code, enum values, and endpoint names all help you attach meaning to field numbers that protoc --decode_raw can only count. The schema is somewhere in the system, by necessity, because both ends have to agree on it. Your job is to find where.
What this means if you are on the defending side
Flip the whole post around and it reads as a maturity ladder for app security. Plain TLS with no pinning stops a passive sniffer and nothing else, since anyone can install a CA and proxy. Pinning in the Network Security Configuration or OkHttp raises the bar to someone willing to instrument the runtime, which is most serious testers but not casual ones. Moving the pin check into native BoringSSL raises it again, to someone comfortable hooking by memory address. Adding Frida and root detection raises the cost of instrumentation without ever fully removing it. And choosing a binary payload format buys obscurity, not secrecy: protobuf without a schema is slower to read, but protoc --decode_raw reads it anyway, and a binding that embeds descriptors hands the schema back wholesale.
The honest summary is that none of these layers is a wall in the sense of being impassable. Each is a cost multiplier. They are useful precisely because they convert “trivial” into “expensive,” and expensive is often enough to redirect an attacker toward a softer target. The exact internals of any given app’s pinning and payload handling are not public, and what I have described is the documented machinery plus the well-known structure of these formats, not the specifics of any one app. If you are auditing your own, the question worth asking is not “is this bypassable” but “how many of these layers do I have, and does the cost of getting through all of them exceed what my data is worth to the person trying.” For most apps that number is small. For the ones that move the pin into native code and pin the SPKI and watch for instrumentation, it climbs fast enough that the protobuf at the end is the easy part.
Sources & further reading
- OWASP MASTG (2024), Certificate Pinning (MASTG-KNOW-0015) and Android Network Security Configuration (MASTG-KNOW-0014) — the catalogue of Android pinning methods (NSC, custom TrustManager, third-party libraries, native code) and how trust anchors and pin-sets are governed.
- Android Developers (2026), Network security configuration — Google’s reference for the trust-anchors, debug-overrides, and pin-set XML, including the API 24 default that dropped user-CA trust.
- mitmproxy docs (2026), How mitmproxy works — the explicit-HTTPS interception flow, on-the-fly certificate generation, SNI handling, and upstream certificate sniffing.
- HTTP Toolkit (2024), Defeating Android certificate pinning with Frida — conceptual explanation of why pinning blocks a proxy and how runtime hooks neutralise the check.
- HTTP Toolkit (2026), frida-interception-and-unpinning — the toolkit’s script inventory, including the native BoringSSL and Flutter cases, and notes on which apps are hard.
- Square (2025), OkHttp CertificatePinner API — the
sha256/<base64>pin format and whencheckruns relative to the TLS handshake. - Data Theorem (2026), TrustKit — the de facto iOS pinning library: SPKI hashes under
kTSKPublicKeyHashes, delegate swizzling, and its RFC 7469 lineage. - Protocol Buffers docs (2026), Encoding — the wire format: tag computation, the wire-type table, varint continuation bits, and zigzag encoding.
- IOActive (2021), Breaking Protocol (Buffers): reverse engineering gRPC binaries — why deserialization forces schema info into the binary and when embedded reflection metadata lets you reconstruct the
.proto. - David Vassallo (2018), Pentesting gRPC / Protobuf: decoding first steps — the
xxd -r -p | protoc --decode_rawworkflow and what numbered, name-less output looks like. - marin-m (2024), pbtk: a toolset for reverse engineering Protobuf apps — extracts embedded descriptors from APKs to rebuild
.protodefinitions. - crackinglandia (2015), Anti-instrumentation techniques: I know you’re there, Frida! — named-pipe and handle-enumeration Frida detection at the source level, the basis for the detection signals described here.
Further reading
Reverse-engineering a binary protocol: protobuf, MessagePack, and the wire format
A primary-source walk through decoding undocumented binary protocols: the protobuf wire format byte by byte, why a schema-less payload is still half-readable, MessagePack's self-describing prefixes, gRPC framing, and the tooling that reconstructs structure.
·27 min readInside the DataDome JS tag: what ddjskey and the client payload carry
A reference on DataDome's client-side JavaScript tag: the ddjskey site identifier, the signals the browser collector gathers and posts to api-js.datadome.co, and how the challenge and interstitial flow is wired.
·21 min readAkamai Bot Manager's _abck cookie: structure, sensor data, and the validation handshake
Traces what the _abck cookie carries, how it relates to the sensor_data POST, and the handshake that flips it from a challenge state to a validated one, with notes on what is documented versus inferred.
·21 min read