From 4889872b20a25bc2d3d33f3b56b4da640cb72b70 Mon Sep 17 00:00:00 2001 From: Divy Srivastava Date: Mon, 13 Jul 2026 17:58:58 +0530 Subject: [PATCH] de-slop pinning/dpop docs --- docs/dpop.md | 83 +++++++++++++++--------------- docs/pinning.md | 132 ++++++++++++++++++++++++------------------------ 2 files changed, 107 insertions(+), 108 deletions(-) diff --git a/docs/dpop.md b/docs/dpop.md index e280203..cc775a4 100644 --- a/docs/dpop.md +++ b/docs/dpop.md @@ -1,65 +1,66 @@ # DPoP / sender-constrained tokens -Unlike [cert pinning](pinning.md), this one defeats mimic's core model, not just -capture. If a target uses DPoP, static header replay does not work — by design. +[Cert pinning](pinning.md) blocks capture; you can get past it and mimic works +as normal. DPoP is different: it breaks the replay itself, which is the thing +mimic does. ## Why replay fails -A plain app authenticates every request with the same reusable bundle (a bearer -token, cookies, device ids). Capture it once, replay it forever — that's mimic. +A plain app authenticates every request with the same reusable bundle: a bearer +token, cookies, device ids. Capture it once, replay it, done. That's mimic. -DPoP ([RFC 9449][rfc]) breaks that. Every request carries a fresh `DPoP:` header: -a JWT signed by a private key the client holds, with claims that bind it to *that -one request*: +DPoP ([RFC 9449][rfc]) doesn't work that way. Every request carries a fresh +`DPoP:` header, a JWT signed by a private key the client holds, with claims that +tie it to that one request: -| Claim | Binds the proof to | Why a copy dies | -|-------|--------------------|-----------------| -| `htm` | HTTP method | reuse on another verb rejected | -| `htu` | request URL | reuse on another endpoint rejected | +| Claim | Ties the proof to | Why a copy fails | +|-------|-------------------|------------------| +| `htm` | HTTP method | reuse on another verb is rejected | +| `htu` | request URL | reuse on another endpoint is rejected | | `iat` | issue time | server accepts only a few-second window | -| `jti` | unique id | server caches it; single-use | +| `jti` | unique id | server caches it; single use | | `nonce` | server-issued value | unpredictable, short-lived (§8) | -| `ath` | the access token | proof tied to that token | +| `ath` | the access token | proof is tied to that token | -The access token itself is bound to the key's thumbprint (`cnf.jkt`), so a stolen -token is useless without signing a matching proof *per request*. Copying the -header replays nothing. +The access token is bound to the key's thumbprint (`cnf.jkt`), so a stolen token +does nothing without a matching signature on every request. Copying the header +replays nothing. -## The only path: a signing oracle +## The only option: a signing oracle -You can't extract the key in the common case, but you might *borrow the app's -ability to use it*. Keep the app running on a device you control, hook its -DPoP-signing routine with Frida, and ask it to mint a fresh proof for each -request you want to send. It never touches the key — it uses the app as an -oracle. Whether this is even possible comes down to one fact: +You usually can't extract the key, but you might borrow the app's ability to use +it. Keep the app running on a device you control, hook its DPoP-signing routine +with Frida, and have it mint a fresh proof for each request you want to send. The +oracle never touches the key; it uses the app to sign. Whether that's even +possible depends on where the key lives: | Key storage | Extractable? | Replay path | |-------------|--------------|-------------| -| **Secure Enclave** (P-256, `kSecAttrTokenIDSecureEnclave`) — recommended, common | **No.** A jailbreak gives userland root, not silicon access. | Live Frida oracle only — tethered to the running app | -| **Keychain software key** (lazier apps) | **Yes**, on a jailbroken device (keychain-dumper, or a Frida `SecItemCopyMatching` hook) | Dump the key, sign proofs offline in Python | +| Secure Enclave (P-256, `kSecAttrTokenIDSecureEnclave`) — recommended, common | No. A jailbreak gives userland root, not enclave access. | Live Frida oracle only, tethered to the running app | +| Keychain software key (lazier apps) | Yes, on a jailbroken device (keychain-dumper, or a Frida `SecItemCopyMatching` hook) | Dump the key, sign proofs offline in Python | -So: fingerprint the target first. Dump the keychain, check whether the DPoP key -is Secure-Enclave-backed or a plain software key. That single fact decides -everything. +Fingerprint the target first: dump the keychain and check whether the DPoP key is +Secure-Enclave-backed or a plain software key. That answer decides the rest. ## The nonce problem -Even with a working oracle, RFC 9449 §8 nonces make offline replay impossible: -they're server-issued, opaque, and short-lived. You can't pre-mint proofs. Each -request becomes a live dance — send, get `use_dpop_nonce` + a fresh nonce, sign -with it, resend — with the device online the whole time. Fine for poking an API -interactively; useless for detached or scaled replay. +A working oracle still can't do offline replay if the server uses nonces +(RFC 9449 §8). They're server-issued, opaque, and short-lived, so you can't +pre-mint proofs. Each request turns into a round trip: send, get a +`use_dpop_nonce` error with a fresh nonce, sign with it, resend, with the device +online the whole time. Workable for poking an API by hand, not for detached or +bulk replay. -## Bottom line for mimic +## Bottom line -- **Software key + jailbreak** → dump it, port the app's proof construction into - Python, sign offline. Clean win (subject to the nonce dance). -- **Secure Enclave** → no extraction, ever. Best case is a live Frida oracle on a - tethered jailbroken device. Not worth wiring into a general tool. -- **No device access** → infeasible. There is no network-only path. +- Software key plus a jailbreak: dump it, port the app's proof construction into + Python, sign offline. Works, minus the nonce round trip. +- Secure Enclave: no extraction. The best you get is a live Frida oracle on a + tethered jailbroken device, not worth wiring into a general tool. +- No device access: nothing works. There's no network-only path. -There's no published DPoP-specific tooling (no Frida script, no mitmproxy addon); -you'd build the oracle from generic Frida hooking. Static header replay — the -thing mimic *is* — does not carry over to DPoP targets. +No DPoP-specific tooling exists (no Frida script, no mitmproxy addon); you'd build +the oracle from generic Frida hooks. Static header replay, which is what mimic is, +doesn't carry over to DPoP targets. [rfc]: https://www.rfc-editor.org/rfc/rfc9449 diff --git a/docs/pinning.md b/docs/pinning.md index 6a6ed50..10fdd2d 100644 --- a/docs/pinning.md +++ b/docs/pinning.md @@ -1,57 +1,58 @@ # Certificate pinning -By default mimic captures traffic with a proxy (mitmproxy). A pinned app rejects -the proxy's cert, so the proxy sees nothing and there's nothing to capture. This -page is about getting past that. +mimic captures traffic through a proxy (mitmproxy). A pinned app rejects the +proxy's cert, so the proxy sees nothing and there's nothing to capture. This +page covers getting past that. -## What pinning actually does +## What pinning does -Worth being precise, because it changes the fix. Normal TLS authenticates the -*server* to the *client*: the app checks that the server's cert is signed by a -trusted CA. To MITM, you add your own CA to the device's trust store, and the -proxy presents a cert signed by it. Pinning breaks that by hardcoding the exact -cert (or public-key hash) the app will accept, so a proxy cert signed by *any* -CA — including one you control — is rejected. +Normal TLS authenticates the server to the client: the app checks that the +server's cert is signed by a trusted CA. To MITM it, you add your own CA to the +device trust store and the proxy presents a cert signed by that CA. Pinning +hardcodes the exact cert (or public-key hash) the app will accept, so a proxy +cert signed by any other CA, including one you control, gets rejected. -Two things follow: +So two things are true: -- **Pinning stops capture, not replay.** It only makes the traffic hard to - *observe*. Once you can see a request, the auth bundle in it replays exactly - as it would for an unpinned app. Pinning adds zero replay resistance — that's - what DPoP is for (see [dpop.md](dpop.md)). -- **You can't beat it by feeding the app a cert.** The pinned value is a - *public* cert; presenting it requires the matching private key, which lives on - the server. The fix is app-side: make the app stop checking, by hooking its - TLS verification at runtime with [Frida](https://frida.re). +Pinning stops capture, not replay. It only makes traffic hard to observe. Once +you can see a request, the auth bundle in it replays the same as it would for an +unpinned app. Pinning adds no replay resistance; that's what DPoP is for (see +[dpop.md](dpop.md)). -mimic doesn't reimplement those hooks (a moving target across iOS versions and -TLS stacks). `mimic unpin` orchestrates the maintained upstream, -[httptoolkit/frida-interception-and-unpinning][ht], and owns the part that's -genuinely mimic's job: baking *your* mitmproxy CA and proxy address into the -scripts and emitting the exact command to run. +You can't beat it by handing the app a cert. The pinned value is a public cert, +and presenting it in a handshake needs the matching private key, which sits on +the server. The fix is on the app side: stop the app from checking, by hooking +its TLS verification at runtime with [Frida](https://frida.re). + +mimic doesn't reimplement those hooks; they change across iOS versions and TLS +stacks. `mimic unpin` drives the upstream that maintains them, +[httptoolkit/frida-interception-and-unpinning][ht], and handles the mimic-specific +part: baking your mitmproxy CA and proxy address into the scripts and printing +the command to run. ## Prerequisites ```bash -mimic doctor # the "optional — only for mimic unpin" section +mimic doctor # see the "optional — only for mimic unpin" section ``` -- **git** — fetches the unpinning scripts. -- **frida** — runs the hooks (`pipx install frida-tools`). -- **objection** — only for the no-jailbreak path (`pipx install objection`). -- A **mitmproxy CA** at `~/.mitmproxy/mitmproxy-ca-cert.pem`. Running - `mimic record` once generates it. +- git, to fetch the unpinning scripts. +- frida, to run the hooks (`pipx install frida-tools`). +- objection, for the no-jailbreak path only (`pipx install objection`). +- A mitmproxy CA at `~/.mitmproxy/mitmproxy-ca-cert.pem`. Running `mimic record` + once generates it. -## Path A — jailbroken device (simplest) +## Path A: jailbroken device -The phone runs `frida-server`; you attach over USB. No repackaging, no signing. +The phone runs `frida-server` and you attach over USB. No repackaging, no +signing. ```bash -mimic record # in one terminal — starts the proxy -mimic unpin com.example.app # bundle id → prints a ready frida command +mimic record # one terminal — starts the proxy +mimic unpin com.example.app # bundle id → prints a frida command ``` -`unpin` fetches the scripts, bakes in your CA + this Mac's LAN IP:8080, and +`unpin` fetches the scripts, bakes in your CA and this Mac's LAN IP:8080, and prints: ```bash @@ -64,55 +65,52 @@ frida -U \ -f com.example.app ``` -Run it, use the app, then the normal flow: `mimic hosts` → `mimic gen `. +Run it, use the app, then `mimic hosts` and `mimic gen ` as usual. -## Path B — stock device (Frida gadget) +## Path B: stock device (Frida gadget) -No jailbreak. Inject the Frida gadget into a **decrypted** IPA, re-sign, sideload. +No jailbreak. You inject the Frida gadget into a decrypted IPA, re-sign, and +sideload. ```bash mimic unpin ./MyApp.ipa --codesign ``` -`unpin` bakes the config as above, then runs `objection patchipa` to inject the -gadget and writes `mimic-unpin/MyApp-patched.ipa`. mimic hands off the signing + -install step, because that's the Apple-auth swamp tools like Sideloadly exist to -handle: +`unpin` bakes the config, runs `objection patchipa` to inject the gadget, and +writes `mimic-unpin/MyApp-patched.ipa`. It stops there and prints the signing +and install step, since that's what tools like Sideloadly handle: -- **easiest** — drag the patched IPA into [Sideloadly](https://sideloadly.io) -- **scripted** — `pymobiledevice3 apps install mimic-unpin/MyApp-patched.ipa` +- drag the patched IPA into [Sideloadly](https://sideloadly.io), or +- `pymobiledevice3 apps install mimic-unpin/MyApp-patched.ipa` -Then attach the scripts to the gadget (`unpin` prints the exact `frida -U` -command) and capture as usual. +Then attach the scripts to the gadget (`unpin` prints the `frida -U` command) and +capture as usual. ### The catches -- **Decrypted IPA required.** Your own app build is fine. An App Store app is - FairPlay-encrypted; decrypting it needs a jailbroken device once (or a - pre-decrypted IPA). "No jailbreak" often quietly means "no jailbreak *if - someone already decrypted it*." -- **7-day cert churn.** Free Apple certs expire weekly. - [TrollStore](https://github.com/opa334/TrollStore) (iOS ≤ 17.0.x) installs - with real entitlements permanently and avoids the re-sign dance. -- **Anti-tamper.** Apps with binary-integrity checks detect the re-signed bundle - and refuse to run. +- You need a decrypted IPA. Your own app build is fine. An App Store app is + FairPlay-encrypted, and decrypting it takes a jailbroken device once (or a + pre-decrypted IPA). "No jailbreak" often means "no jailbreak, if someone + already decrypted it." +- Free Apple certs expire after 7 days. + [TrollStore](https://github.com/opa334/TrollStore) (iOS 17.0.x and below) + installs with real entitlements permanently and skips the weekly re-sign. +- Apps with binary-integrity checks detect the re-signed bundle and refuse to + run. ## Framework notes -- **React Native** — uses `NSURLSession` and honors the system proxy. The - standard hooks work. -- **Flutter** — ships its own BoringSSL and *ignores the system proxy*, so - `SecTrustEvaluate` hooks never fire and setting a device proxy captures - nothing. Needs [reFlutter](https://github.com/Impact-I/reFlutter) to patch the - engine. `mimic unpin` won't handle Flutter apps as-is. +- React Native uses `NSURLSession` and honors the system proxy. The standard + hooks work. +- Flutter ships its own BoringSSL and ignores the system proxy, so + `SecTrustEvaluate` hooks never fire and a device proxy captures nothing. It + needs [reFlutter](https://github.com/Impact-I/reFlutter) to patch the engine; + `mimic unpin` won't handle Flutter apps as they are. ## What still won't work -Pinning is beatable; some things past it aren't: - -- **DPoP / sender-constrained tokens** — the per-request signing key never - leaves the device, so captured requests don't replay. See [dpop.md](dpop.md). -- **Certificate transparency / attestation** backed by hardware - (App Attest / DeviceCheck) — out of scope. +- DPoP / sender-constrained tokens. The per-request signing key never leaves the + device, so captured requests don't replay. See [dpop.md](dpop.md). +- Hardware attestation (App Attest, DeviceCheck). Out of scope. [ht]: https://github.com/httptoolkit/frida-interception-and-unpinning