mirror of
https://github.com/littledivy/mimic.git
synced 2026-10-26 19:15:10 +00:00
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:
1 parent
f231086755
commit
37ff46811d
5 files changed
+386
-6
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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.")
|
||||
Reference in new issue
Block a user