diff --git a/README.md b/README.md index 6e490ac..12a1d2f 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/docs/dpop.md b/docs/dpop.md new file mode 100644 index 0000000..e280203 --- /dev/null +++ b/docs/dpop.md @@ -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 diff --git a/docs/pinning.md b/docs/pinning.md new file mode 100644 index 0000000..6a6ed50 --- /dev/null +++ b/docs/pinning.md @@ -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 `. + +## 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 +``` + +`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 diff --git a/mimic/cli.py b/mimic/cli.py index aab1e19..7387c40 100644 --- a/mimic/cli.py +++ b/mimic/cli.py @@ -4,6 +4,7 @@ mimic hosts list captured hosts (pick your API host here) mimic learn show the endpoints mimic saw for a host mimic gen AI-write a Python client for a host + mimic unpin 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) diff --git a/mimic/unpin.py b/mimic/unpin.py new file mode 100644 index 0000000..9e26e54 --- /dev/null +++ b/mimic/unpin.py @@ -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 ` prints the ready `frida -U …` command. + - stock device (Frida gadget): inject the gadget into a decrypted IPA, re-sign, + sideload. `mimic unpin ` 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 "" + 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 .") + 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 ") + 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 ") + 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.")