add cert-pinning bypass (mimic unpin) + pinning/dpop docs

- mimic unpin <ipa|bundle-id>: orchestrates httptoolkit's Frida unpinning
  scripts, bakes the mitmproxy CA + proxy addr into config.js, emits the
  frida command; runs objection patchipa for the no-jailbreak gadget path
  and hands off signing/install
- doctor: optional git/frida/objection checks for the unpin path
- docs/pinning.md, docs/dpop.md: why pinning blocks capture (beatable) vs
  DPoP defeats replay (by design); README Limitations links both
This commit is contained in:
Divy Srivastava committed 2026-07-13 17:45:10 +05:30
1 parent f231086755
commit 37ff46811d
5 files changed
+386 -6

No files matched your search

+8 -6
View File
@@ -93,15 +93,17 @@ rotates, a `401`/`403` triggers one re-pull from mitmweb and a retry.
## Limitations
Two auth schemes defeat this by design:
Two auth schemes get in the way, for different reasons:
- **Certificate pinning** (banking, Instagram). The app rejects the mitmproxy
cert, so the proxy never sees the traffic and nothing shows up in
`mimic hosts`. Getting past it needs runtime patching (Frida-class), which is
out of scope here.
cert, so the proxy sees no traffic and nothing shows up in `mimic hosts`. This
blocks *capture*, not replay — get past the pin and the rest works normally.
`mimic unpin <ipa|bundle-id>` sets up a Frida-based bypass; see
[docs/pinning.md](docs/pinning.md).
- **DPoP / sender-constrained tokens.** Each request carries a fresh proof
signed by a private key that never leaves the device. Captured proofs are
single-use and bound to one method + URL, so replaying them fails.
signed by a private key that never leaves the device, so captured requests
don't replay. This defeats the core model, not just capture; there's no clean
workaround. See [docs/dpop.md](docs/dpop.md).
If `mimic hosts` shows the app's API host, you're good.
+65
View File
@@ -0,0 +1,65 @@
# 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.
## 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.
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*:
| 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 |
| `iat` | issue time | server accepts only a few-second window |
| `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 |
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 only path: 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:
| 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 |
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.
## 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.
## Bottom line for mimic
- **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.
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.
[rfc]: https://www.rfc-editor.org/rfc/rfc9449
+118
View File
@@ -0,0 +1,118 @@
# 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.
## What pinning actually 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.
Two things follow:
- **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).
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.
## Prerequisites
```bash
mimic doctor # 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.
## Path A — jailbroken device (simplest)
The phone runs `frida-server`; 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
```
`unpin` fetches the scripts, bakes in your CA + this Mac's LAN IP:8080, and
prints:
```bash
frida -U \
-l mimic-unpin/frida-scripts/config.js \
-l mimic-unpin/frida-scripts/ios/ios-connect-hook.js \
-l mimic-unpin/frida-scripts/ios/ios-disable-detection.js \
-l mimic-unpin/frida-scripts/native-tls-hook.js \
-l mimic-unpin/frida-scripts/native-connect-hook.js \
-f com.example.app
```
Run it, use the app, then the normal flow: `mimic hosts` → `mimic gen <host>`.
## Path B — stock device (Frida gadget)
No jailbreak. Inject the Frida gadget into a **decrypted** IPA, re-sign, sideload.
```bash
mimic unpin ./MyApp.ipa --codesign <TEAM_ID>
```
`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:
- **easiest** — drag the patched IPA into [Sideloadly](https://sideloadly.io)
- **scripted** — `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.
### 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.
## 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.
## 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.
[ht]: https://github.com/httptoolkit/frida-interception-and-unpinning
+25
View File
@@ -4,6 +4,7 @@
mimic hosts list captured hosts (pick your API host here)
mimic learn <host> show the endpoints mimic saw for a host
mimic gen <host> AI-write a Python client for a host
mimic unpin <ipa|id> defeat cert pinning (Frida) so capture works
mimic doctor check your setup
"""
import argparse
@@ -14,6 +15,7 @@ import subprocess
import sys
from . import codegen
from . import unpin
from .sources import mitm
@@ -101,6 +103,21 @@ def cmd_doctor(args):
pass
check("mitmweb running + reachable", reachable,
"run `mimic record` in another terminal")
def opt(name, present, fix):
# Optional — only needed for `mimic unpin`; never fails the check.
print(f" [{'ok ' if present else ' -'}] {name}")
if not present:
print(f" → {fix}")
print("\noptional — only for `mimic unpin` (pinned apps):")
opt("git (fetch unpinning scripts)", shutil.which("git") is not None,
"install git (Xcode CLT: xcode-select --install)")
opt("frida (run the hooks)", shutil.which("frida") is not None,
"pipx install frida-tools (or: uv tool install frida-tools)")
opt("objection (gadget inject, no-JB path)", shutil.which("objection") is not None,
"pipx install objection (or: uv tool install objection)")
print(f"\nLAN IP for the iPhone proxy: {_lan_ip()}:8080")
sys.exit(0 if ok else 1)
@@ -178,6 +195,14 @@ def main(argv=None):
gp.add_argument("--prompt-only", action="store_true", help="print the prompt instead of calling claude")
gp.set_defaults(func=cmd_gen)
up = sub.add_parser("unpin", help="defeat cert pinning via Frida so capture works")
up.add_argument("target", help="a decrypted .ipa (gadget path) or app bundle-id (jailbroken path)")
up.add_argument("--ca", help="mitmproxy CA cert (default: ~/.mitmproxy/mitmproxy-ca-cert.pem)")
up.add_argument("--proxy-host", help="proxy host to bake in (default: this Mac's LAN IP)")
up.add_argument("--workdir", help="where to put scripts + patched IPA (default: mimic-unpin/)")
up.add_argument("--codesign", help="signing identity for `objection patchipa`")
up.set_defaults(func=unpin.cmd_unpin)
args = p.parse_args(argv)
args.func(args)
+170
View File
@@ -0,0 +1,170 @@
"""Defeat certificate pinning so mimic can capture a pinned iOS app.
Pinning doesn't stop replay — it stops *capture*. A pinned app rejects
mitmproxy's cert, so the proxy never sees the plaintext and there's nothing to
grab. The fix is app-side: make the app stop checking the pin, using Frida to
hook its TLS verification at runtime.
mimic doesn't reimplement the hooks (that's a moving target across iOS versions
and TLS stacks). It orchestrates the maintained upstream —
httptoolkit/frida-interception-and-unpinning — and owns the one part that is
genuinely mimic's job: baking *your* mitmproxy CA and proxy address into the
scripts, and emitting the exact command to run.
Two device paths:
- jailbroken (frida-server on the phone): attach over USB, no repackaging.
`mimic unpin <bundle-id>` prints the ready `frida -U …` command.
- stock device (Frida gadget): inject the gadget into a decrypted IPA, re-sign,
sideload. `mimic unpin <app.ipa>` runs `objection patchipa` for the inject,
then hands the signing+install step to Sideloadly / pymobiledevice3.
"""
import os
import re
import shutil
import subprocess
import sys
SCRIPTS_REPO = "https://github.com/httptoolkit/frida-interception-and-unpinning"
# Load order matters: config.js first (it defines CERT_PEM/PROXY_* the others
# read), then the iOS hooks, then the shared native hooks. Mirrors the upstream
# README's iOS invocation.
IOS_SCRIPTS = [
"config.js",
"ios/ios-connect-hook.js",
"ios/ios-disable-detection.js",
"native-tls-hook.js",
"native-connect-hook.js",
]
DEFAULT_CA = os.path.expanduser("~/.mitmproxy/mitmproxy-ca-cert.pem")
PROXY_PORT = 8080 # mitmweb's proxy listener — same port `mimic record` prints
def _clone_or_update(dst):
"""Fetch (or refresh) the upstream Frida scripts into `dst`."""
if not shutil.which("git"):
return False
if os.path.isdir(os.path.join(dst, ".git")):
subprocess.run(["git", "-C", dst, "pull", "--quiet", "--ff-only"],
check=False)
return True
r = subprocess.run(["git", "clone", "--depth", "1", "--quiet",
SCRIPTS_REPO, dst], check=False)
return r.returncode == 0
def _patch_config(scripts_dir, ca_pem, proxy_host):
"""Bake the CA + proxy into a copy of upstream config.js, in place.
We patch upstream's file rather than hand-write one so all its helper code
(the base64/DER machinery the hooks depend on) stays intact and current.
"""
path = os.path.join(scripts_dir, "config.js")
with open(path) as f:
src = f.read()
ca = ca_pem.strip()
src, n_cert = re.subn(r"const CERT_PEM = `.*?`;",
f"const CERT_PEM = `{ca}`;", src, count=1, flags=re.S)
src, n_host = re.subn(r"const PROXY_HOST = '[^']*';",
f"const PROXY_HOST = '{proxy_host}';", src, count=1)
src, n_port = re.subn(r"const PROXY_PORT = \d+;",
f"const PROXY_PORT = {PROXY_PORT};", src, count=1)
if not (n_cert and n_host and n_port):
sys.exit("config.js from upstream changed shape — mimic couldn't patch "
f"it (cert={n_cert} host={n_host} port={n_port}). Open an issue.")
with open(path, "w") as f:
f.write(src)
def _lan_ip():
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "<this-machine-ip>"
finally:
s.close()
def _frida_command(scripts_dir, target, gadget):
flag = "-n" if gadget else "-f" # attach to running gadget vs spawn by id
lines = ["frida -U \\"] if not gadget else ["frida -U \\"]
for s in IOS_SCRIPTS:
lines.append(f" -l {os.path.join(scripts_dir, s)} \\")
lines.append(f" {flag} {target}")
return "\n".join(lines)
def cmd_unpin(args):
target = args.target
is_ipa = target.lower().endswith(".ipa")
ca_path = args.ca or DEFAULT_CA
if not os.path.exists(ca_path):
sys.exit(f"no mitmproxy CA at {ca_path} — run `mimic record` once so "
"mitmproxy generates it, or pass --ca <path>.")
with open(ca_path) as f:
ca_pem = f.read()
workdir = args.workdir or "mimic-unpin"
scripts_dir = os.path.join(workdir, "frida-scripts")
os.makedirs(workdir, exist_ok=True)
print(f"fetching unpinning scripts → {scripts_dir}", file=sys.stderr)
if not _clone_or_update(scripts_dir):
sys.exit(
"couldn't fetch the Frida scripts (need git + network). Clone them "
f"yourself:\n git clone {SCRIPTS_REPO} {scripts_dir}\n"
"then re-run `mimic unpin`.")
host = args.proxy_host or _lan_ip()
_patch_config(scripts_dir, ca_pem, host)
print(f"baked your CA + proxy {host}:{PROXY_PORT} into config.js\n")
if not is_ipa:
# Jailbroken path: bundle id → attach over USB to frida-server.
print("Jailbroken device (frida-server) path:\n")
print(" 1. phone + Mac on the same network; `mimic record` running here")
print(" 2. run this to launch the app with pinning disabled:\n")
print(_frida_command(scripts_dir, target, gadget=False))
print("\n 3. use the app, then: mimic hosts → mimic gen <api-host>")
return
# Stock device path: inject the Frida gadget into the IPA via objection.
print("Stock device (Frida gadget) path:\n")
if not shutil.which("objection"):
sys.exit(
"objection not found — it does the gadget injection.\n"
" pipx install objection (or: uv tool install objection)\n"
"then re-run `mimic unpin`.")
out_ipa = os.path.join(workdir, os.path.basename(target)[:-4] + "-patched.ipa")
cmd = ["objection", "patchipa", "--source", target, "--output", out_ipa]
if args.codesign:
cmd += ["--codesign-signature", args.codesign]
print("injecting Frida gadget:\n " + " ".join(cmd) + "\n")
r = subprocess.run(cmd, check=False)
if r.returncode != 0:
sys.exit("objection patchipa failed (needs a *decrypted* IPA; App Store "
"binaries are encrypted). See the note below.")
print(f"\npatched IPA → {out_ipa}\n")
print("Next — sign + install (mimic hands this off; Apple auth lives here):")
print(" • easiest: drag the patched IPA into Sideloadly and install")
print(" • scripted: pymobiledevice3 apps install " + out_ipa)
print("\nThen attach the unpinning scripts to the gadget:\n")
print(_frida_command(scripts_dir, "Gadget", gadget=True))
print("\nThen use the app and: mimic hosts → mimic gen <api-host>")
print("\nNotes:")
print(" - Needs a DECRYPTED IPA. Your own app build works; an App Store")
print(" app must be decrypted first (a jailbroken device, once).")
print(" - Free Apple certs expire in 7 days. TrollStore (iOS ≤17.0.x)")
print(" avoids the re-sign churn.")
print(" - Flutter apps ignore the proxy + use private BoringSSL — this")
print(" path won't capture them without reFlutter.")