mitmcloak
Intercept the traffic. Keep the fingerprint.
Your browser behind mitmproxy runs the site's JavaScript and produces genuine telemetry.
That half was never broken. The telemetry then leaves over a Python TLS handshake,
and the origin sees a browser that handshakes like a Python script.
Results
Headless Chromium fetching the same page, three ways. One line of difference in how mitmproxy was started.
JA4
Chromium, direct t13d1516h2_8daaf6152771_806a8c22fdea
- through mitmproxy t13d2812h2_a01be8c064b6_0d46a1bf4a7c
+ through mitmcloak t13d1516h2_8daaf6152771_806a8c22fdea
Akamai HTTP/2
Chromium, direct
1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
- through mitmproxy
- 1:4096;2:0;4:2147483647;5:131072;8:0;3:100;6:65536|2147418112|0|m,s,p,a
+ through mitmcloak
+ 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
A static match is the easy half. Chromium also reshuffles its TLS extensions on every connection, so its JA3 moves while its JA4 holds still:
Four connections, one browser
Chromium, direct 4 distinct JA3 · 1 JA4
+ through mitmcloak 4 distinct JA3 · 1 JA4
It reproduces the behaviour, not one sample of it.
mitmcloak does not defeat challenges. It stops the proxy corrupting a session that was already legitimate.
How
client ──▶ mitmproxy + mitmcloak ──▶ origin
1. read the client's ClientHello and HTTP/2 preface
2. mint an httpcloak preset from them, at runtime
3. serve that same request through it
No capture phase and no guessing: the client's real handshake is read on the way in and replayed on the way out, in the same request cycle.
Install
pip install mitmcloak
python -m mitmcloak install # writes ~/.mitmproxy/config.yaml, then gets out of the way
mitmdump # plain mitmdump, no -s flag
mitmdump, mitmweb and the TUI keep every one of their own flags. python -m mitmcloak uninstall puts it back. Prefer explicit? mitmdump -s "$(python -m mitmcloak path)".
What survives the proxy
| mirrored | |
|---|---|
| TLS | raw ClientHello byte for byte · GREASE in position · X25519MLKEM768 key shares · extension order redrawn per handshake · resumption-shaped hello · extensions httpcloak has no model for |
| HTTP/2 | SETTINGS values and their order · connection WINDOW_UPDATE · pseudo-header order · stream priority |
| Headers | order, casing and repeated names verbatim · per-request order · response trailers |
Measured against six clients, each compared direct and through the proxy. exact means
byte-identical, not "close enough".
| client | JA4 | JA3N | Akamai H2 |
|---|---|---|---|
| curl | exact | exact | exact |
| Chrome | exact | exact | exact |
| Firefox | exact | exact | exact |
HTTP/3
A proxy setting only redirects TCP, so a client speaking QUIC never reaches the proxy at
all. That is why an app can keep working normally while mitmproxy shows nothing from it.
wireguard and transparent carry UDP, so QUIC from a device arrives; reverse:http3://
works for a fixed origin.
mitmdump --mode wireguard --set mitmcloak_http_version=h3
mitmdump --mode reverse:http3://example.com --set mitmcloak_http_version=h3
The QUIC hello is captured, a preset is minted from it, and the upstream leg goes out
over HTTP/3. Measured against tls3.peet.ws with curl as the client:
curl, direct q13d313_55b375c5d22e_19cb63ff0383 13 extensions
through mitmcloak q13d313_55b375c5d22e_19cb63ff0383 13 extensions
Byte-identical, over four connections, bridged=4 mirrored=4. This needs httpcloak
1.7.2: before it the QUIC transport resolved only a named ClientHelloID, so a captured
hello never reached the wire and the h3 leg went out with seven extensions instead of
thirteen.
Use http3://, not quic://. They are different mitmproxy schemes. quic:// is a
raw QUIC tunnel with no HTTP parsing, so no request hook fires and nothing is bridged at
all: captured_hellos=1 and no bridged, where http3:// gives bridged=1 mirrored=1.
Any client, any language
It is a proxy, so what sits in front of it is unconstrained. A Go or Rust service gets Chrome's TLS and HTTP/2 fingerprints by setting one environment variable and integrating nothing. That is wider reach than httpcloak has on its own, which is Python, Node, .NET and the C library.
Go http.Transport{Proxy: …} Rust reqwest .proxy()
Java -Dhttp.proxyHost Node HTTPS_PROXY=…
curl · PHP · Ruby · C# · a browser · a phone · an app
Two limits are the proxy's rather than mitmcloak's: a client speaking QUIC/HTTP3 bypasses an HTTP proxy entirely, and a client that pins certificates will not trust the CA.
Mirror mode, and keeping what it sees
By default mitmcloak reads the fingerprint of whatever client connected and reproduces it upstream. Point it at your phone, and the origin sees your phone, not a preset someone guessed.
It reads the client's raw ClientHello from mitmproxy's tls_clienthello hook and its HTTP/2
preface from next_layer, builds an httpcloak preset on the fly, and serves that same
request through it. One request cycle, no capture phase.
Measured against six clients, each compared direct and through the proxy:
| client | JA4 | JA3N | Akamai H2 |
|---|---|---|---|
| curl | exact | exact | exact |
| Chrome | exact | exact | exact |
| Firefox | exact | exact | exact |
Cost: about 2.6 ms once per distinct fingerprint, because preset names are content-addressed. It does not show up in the latency budget.
Catalogue everything that goes past
Mirroring only builds presets for clients we actually serve. Most of what a proxy sees is not that: a pinned app that refused the certificate, a background agent that made one request and left, a client that hung up. Those fingerprints are worth keeping too.
mitmdump --set mitmcloak_catalogue_dir=./seen
Every distinct TLS fingerprint observed lands there, used or not, with what was seen alongside it:
{
"version": 1,
"preset": { "name": "observed-aa12047efcd3", "based_on": "chrome-151-ios", "tls": {...} },
"_observed": {
"hosts": ["gateway.icloud.com", "support.apple.com"],
"connections": 22, "requests": 0,
"rejected_our_certificate": 22,
"connections_without_a_request": 22,
"tls_only": true,
"grease": true, "alpn": ["h2", "http/1.1"]
}
}
Those files are valid preset documents, so anything the proxy watched can become a preset with no conversion:
httpcloak.load_preset("seen/observed-aa12047efcd3.json")
The _observed block rides alongside and is ignored on load. tls_only: true means only
the TLS half was captured, because the connection never got far enough to show its HTTP/2
preface, so that layer comes from based_on.
mitmcloak.catalogue lists what has been seen and mitmcloak.catalogue.save <dir> writes
it out on demand.
Keep a fingerprint without keeping the device
mitmdump --set mitmcloak_export_dir=./presets
Every mirrored client is written out as a portable httpcloak preset. Load it anywhere, with no proxy and no device:
import httpcloak
name = httpcloak.load_preset("presets/mc-5dd29ec437e7.json")
httpcloak.Session(preset=name).get("https://example.com/")
Set the phone up once, export, then run from anywhere.
Configuration and the four tiers of control
Every option works from the command line and from mitmweb's option editor, where the ones with a fixed set of values render as dropdowns.
mitmdump --set mitmcloak_preset=chrome-151-windows \
--set mitmcloak_mode=auto \
--set mitmcloak_headers=merge
| option | default | what it does |
|---|---|---|
mitmcloak_preset |
chrome-151-windows |
preset used when not mirroring |
mitmcloak_mode |
auto |
auto mirrors when it can and falls back; static never mirrors; mirror refuses to fall back |
mitmcloak_headers |
merge |
merge keeps the client's values inside the preset's block; replace keeps only semantic headers; exact sends the client's block verbatim and nothing else |
mitmcloak_rule |
per-flow preset, repeatable, first match wins | |
mitmcloak_bypass |
flow filter for requests that should not be bridged | |
mitmcloak_preset_file |
JSON preset to load at startup, repeatable | |
mitmcloak_session_scope |
origin |
origin, client or connection, see below |
mitmcloak_export_dir |
write every mirrored preset here | |
mitmcloak_http_version |
auto |
auto, h1, h2, h3 |
mitmcloak_max_sessions |
96 |
upstream session pool cap, and the main memory dial (see below) |
mitmcloak_max_body |
50 MB |
larger requests are handed back to mitmproxy |
mitmcloak_proxy |
upstream proxy for the httpcloak leg | |
mitmcloak_ja3 / mitmcloak_akamai |
one-off fingerprint overrides | |
mitmcloak_tcp_ttl / _mss / _window_size |
TCP/IP knobs | |
mitmcloak_catalogue_dir |
record every fingerprint seen, used or not | |
mitmcloak_identify_by_tls |
true |
pick a mirrored client's base from its TLS stack, falling back to the User-Agent |
mitmcloak_timeout |
30 |
upstream request timeout, seconds |
mitmcloak_verify |
true |
verify upstream certificates |
mitmcloak_max_idle |
300 |
seconds before an idle upstream session is swept |
mitmcloak_disable_ech |
false |
disable Encrypted Client Hello upstream |
mitmcloak_tls_only |
false |
keep the preset's TLS but not its headers |
Header modes
merge and replace hand httpcloak a dict, and a dict cannot hold two Cookie headers
or remember that the client wrote X-FOO. exact hands over ordered pairs instead:
order, casing and repeated names all survive, and httpcloak adds nothing of its own.
Measured through the proxy, the same curl request with Cookie: a=1, X-ODD-CASE: 1,
Cookie: b=2:
merge Host, Connection, sec-ch-ua-platform, User-Agent, sec-ch-ua, sec-ch-ua-mobile,
Accept, Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, Accept-Encoding,
Accept-Language, Cookie, X-Odd-Case
exact Host, Cookie, X-ODD-CASE, Cookie, User-Agent, Accept
Host is protocol framing that httpcloak writes itself. Everything after it in the
second line is exactly what the client sent, both Cookie headers included, in their
original interleaved positions.
The trade is that exact discards the preset's header block entirely, so it is right
when the client in front of the proxy is the client you are impersonating, and wrong
when it is not. Mirror mode is the case it was built for. Under a static preset, merge
stays the better answer.
In merge and replace the client's header order is sent alongside the dict, but only
when the preset was mirrored from that same client, for the same reason.
Per-flow rules, in mitmproxy's own filter language
Not a syntax invented here, so ~d, ~u, ~m, ~src and the boolean operators all work
and the separator can be any character:
--set mitmcloak_rule="/~src 192\.168\.1\.50/chrome-151-ios" # the iPhone
--set mitmcloak_rule="/~src 192\.168\.1\.51/chrome-151-android" # the Android
--set mitmcloak_rule="/~d .*\.example\.com/firefox-148-linux"
--set mitmcloak_bypass="~d internal\.corp"
Driving it from Python
One line, and every httpcloak.Session keyword is forwarded untouched, so a new httpcloak
release is usable without waiting for a mitmcloak release:
from mitmcloak import Bridge
addons = [Bridge("chrome-151-windows", headers="replace", disable_ech=True)]
Full ownership, when you want to build the session yourself:
import httpcloak
from mitmcloak import Bridge
def session_for(flow):
if flow.request.pretty_host.endswith("example.com"):
return httpcloak.Session(preset="chrome-151-ios", ja3=my_ja3, proxy="http://...")
return None # fall through to normal resolution
addons = [Bridge(session=session_for)]
The session is used as given. It is not wrapped, inspected or limited, except for three flags that are correctness rather than preference: the cookie jar, the conditional cache and redirect following are all turned off, because the real client owns those.
Custom JSON presets work here for free through httpcloak.load_preset_from_json, which is
worth knowing: httpcloak lets you describe a fingerprint down to the TLS extension, and
mitmcloak does nothing to hide that.
Commands
Available in the mitmproxy TUI and mitmweb, with tab completion:
mitmcloak.presets every preset, built in and mirrored
mitmcloak.preset <name> switch the static preset
mitmcloak.preset.load <path> load a JSON preset file
mitmcloak.preset.describe <name> fully resolved JSON for a preset
mitmcloak.mirror.list fingerprints mirrored this session
mitmcloak.mirror.export <dir> write them out for reuse
mitmcloak.stats counters, including what was not bridged
Bridged flows carry a mitmcloak entry in their metadata (preset, route, upstream
protocol, timing), so ~meta filters on it and mitmweb shows it.
Proxy modes, and which are verified
| mode | status |
|---|---|
regular (default) |
works, with mirroring |
socks5 |
works, with mirroring |
upstream:... |
works; the upstream proxy is handed to httpcloak, see below |
reverse:... |
works, but the client speaks plain HTTP to the local port, so there is no ClientHello to mirror and the static preset governs |
transparent |
works, with mirroring; verified against an origin that reports the ClientHello it saw |
wireguard |
works, with mirroring; same verification, through the tunnel |
local |
needs real root and is untested here, see below |
On transparent and wireguard. Both were verified in an isolated network namespace against a local TLS origin that parses the ClientHello it receives and reports its identity, so the check is that the client's own fingerprint arrives, not merely that a 200 comes back:
origin saw
curl straight to the origin stable_id=c48b918794d3 30 ciphers
curl through plain mitmproxy stable_id=da93061cb016 28 ciphers
curl through mitmcloak stable_id=c48b918794d3 30 ciphers
Transparent mode was tested the way it is deployed, as a gateway for a second namespace
with the redirect in PREROUTING. That detail matters: a -t nat -A OUTPUT redirect on
the same host also catches httpcloak's own upstream dial and loops it back into the
proxy. WireGuard mode was tested with a real kernel wg0 client completing a handshake
against mitmproxy's server. Both reported bridged=1 mirrored=1, so nothing looped.
On local mode. It shells out to sudo -n to run mitmproxy-linux-redirector, which
loads an eBPF program, and root inside a user namespace is not enough because sudo
itself rejects the mapped uid. Untested here for that reason. Worth knowing how it fails
if you do not have passwordless sudo, because it fails in the good direction:
[..] Failed to elevate privileges
Error logged during startup, exiting...
It exits at startup rather than starting and quietly forwarding traffic unintercepted, so there is no state where you believe you are bridging and are not.
On upstream mode. mitmproxy would normally forward everything to the proxy you named,
but the short circuit means it never gets that far. Left alone, your proxy would be
silently bypassed and requests would leave from the real address while you believed
otherwise. mitmcloak reads --mode upstream: and hands that proxy to httpcloak instead.
An explicit --set mitmcloak_proxy= wins if you set both.
Limitations, and what is not covered
Flows mitmproxy does not decrypt are not bridged. --ignore-hosts, TLS passthrough
when a client rejects your CA, and raw CONNECT tunnels reach the origin with the client's
own fingerprint. mitmcloak counts these and logs the host once so the gap is visible rather
than silent. The apps most likely to be excluded are the pinned ones, which are often the
apps you most wanted covered.
The consolation: fingerprint capture and interception are separable. An app that refuses your CA still sends its ClientHello in plaintext first, and mitmcloak keeps it. Those fingerprints are written out like any other, with the H2 block absent because the connection never got that far. You can fingerprint what you cannot decrypt.
Worth knowing why an app "stops working", because there are three unrelated causes and none of them is detection:
- Certificate pinning. The app carries its own trust store and refuses any CA it does
not already know. Logged as
tls alert certificate unknown. - QUIC. Instagram and Facebook default to HTTP/3 over UDP, and an OS proxy setting only redirects TCP, so that traffic never reaches the proxy at all.
- Apps that ignore the proxy setting. WhatsApp runs its own transport on port 443 rather than standard TLS.
A few clients offer cipher suites httpcloak could not complete. The offer itself is always copied exactly, so the fingerprint is unaffected; the question is only what happens if the origin selects one of them, in which case the handshake fails after a correct hello. Four families are missing, all dropped from the Go stack deliberately: DHE, CCM, ARIA/Camellia and the CBC-SHA384 variants. Every browser offers none of them, which is why a mirrored browser cannot hit this. OpenSSL, GnuTLS and JSSE clients offer between six and thirty-two, always behind the AES-GCM, ChaCha20 and TLS 1.3 suites in the same hello, so an origin would have to prefer one of them over TLS 1.3 to trip it. Registration names them when it happens:
WARNING mitmcloak: mc-c14fc534d4ff offers 15 cipher(s) httpcloak cannot complete if the
server selects one: 0x009f 0xccaa 0x00a3 ... They are still offered, so the fingerprint
matches; only an origin that prefers one of them will fail.
Nothing is planned here. If a real origin is ever found that selects one, the fix is on httpcloak's side and this is the message that will point at it.
All three fail at or before the handshake, which is earlier than mitmcloak acts. Plain mitmproxy behaves identically.
serverconnect and serverconnected hooks do not fire on bridged flows, because there
is no upstream connection to announce. If you have an existing addon stack that depends on
them, that is a real behavioural change.
WebSockets are passed through, not bridged, until httpcloak speaks them. Only that connection's fingerprint is mitmproxy's. The upgrade normally happens after the document and API requests have already gone through.
Bodies are buffered, matching stock mitmproxy, which sets stream_large_bodies to
None by default. Requests over mitmcloak_max_body are handed back unbridged and counted.
Response trailers reach an HTTP/2 client and not an HTTP/1.1 one. HTTP/1.1 can carry
a trailer block only on a chunked response, and the body arrives from httpcloak already
decoded, so the response goes out with a Content-Length. Handing mitmproxy trailers in
that state raises in its HTTP/1 layer rather than degrading, so they are dropped and
counted as trailers_dropped_h1. gRPC is HTTP/2-only, so the case that needs them is the
case that gets them.
Memory tracks live sessions, not traffic. An httpcloak session costs roughly 190 kB,
and closing one does not return that memory to the operating system. Peak usage
therefore follows the high-water mark of concurrently live sessions, so
mitmcloak_max_sessions is a memory ceiling as much as a reuse setting:
peak ~= baseline + 128 kB x distinct hosts (mitmproxy's own cost)
+ 190 kB x mitmcloak_max_sessions
Measured, not estimated. It is bounded: serving more requests does not raise it once the pool is full. Raise the cap for better reuse on many-origin workloads, lower it if memory matters more. The idle sweep frees sockets and file descriptors but recovers no memory.
If you see the session pool has a 0% hit rate in the log, more origins are in play than
the cap allows and every request is building a fresh upstream session.
The TCP/IP layer is still the host's. A mirrored iPhone has a Linux stack underneath
it. mitmcloak_tcp_* exposes what httpcloak can set; window scale is a kernel limit.
Extension permutation is not reproduced. Real Chrome reorders TLS extensions on every connection, so its raw JA3 changes while its JA4 does not. A mirrored preset replays one captured order, so its raw JA3 is stable. JA4 and JA3N are unaffected, since both sort.
connection_strategy is forced to lazy. The default, eager, opens the upstream TLS
connection before the request hook fires, which leaks a Python ClientHello to the origin
even though the request itself goes out through httpcloak. That is a louder signal than not
bridging at all, so mitmcloak corrects it and says so in the log.
Alpine and musl will not work. Go's c-shared archive uses initial-exec TLS, which musl
cannot dlopen (golang/go#54805). Use a glibc
image such as python:3.12-slim. mitmcloak raises a clear error at import rather than
letting you find out later.
Load order
mitmcloak's request hook runs in addon registration order, so load it last if you have
other scripts that modify requests, otherwise their changes will not be included.
Development
pip install -e ".[dev]"
pytest
The unit tests need neither the network nor the cgo library: the wire parsing in
mitmcloak/capture.py has no httpcloak import and is tested against captured ClientHello
fixtures from Chrome, Firefox and curl.
Licence
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mitmcloak-0.1.0.tar.gz.
File metadata
- Download URL: mitmcloak-0.1.0.tar.gz
- Upload date:
- Size: 68.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e684082d6e32e218dcac08f571d70e674dfa4c67f66473fdf5f4057fb2ca5fa1
|
|
| MD5 |
77da5d944d635017d3813a8a661fd675
|
|
| BLAKE2b-256 |
c0211fcb8b8398955224e4c9af3917c396597e28d4cd967e30b492de671683da
|
Provenance
The following attestation bundles were made for mitmcloak-0.1.0.tar.gz:
Publisher:
publish.yaml on sardanioss/mitmcloak
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mitmcloak-0.1.0.tar.gz -
Subject digest:
e684082d6e32e218dcac08f571d70e674dfa4c67f66473fdf5f4057fb2ca5fa1 - Sigstore transparency entry: 2692258437
- Sigstore integration time:
-
Permalink:
sardanioss/mitmcloak@06e3068b9bd532b49aa4899acc4f2b552df6e62f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sardanioss
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yaml@06e3068b9bd532b49aa4899acc4f2b552df6e62f -
Trigger Event:
push
-
Statement type:
File details
Details for the file mitmcloak-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mitmcloak-0.1.0-py3-none-any.whl
- Upload date:
- Size: 64.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
904803f0edb741d0316cfd0d4cb6465a885b3e992149b44125bc9302322b8437
|
|
| MD5 |
6671c549fa28e9cd7e7762357a56c233
|
|
| BLAKE2b-256 |
47733b35358634c2ffb238334dfcfb7e21e0fa62626ba75a65a3787e838b3a13
|
Provenance
The following attestation bundles were made for mitmcloak-0.1.0-py3-none-any.whl:
Publisher:
publish.yaml on sardanioss/mitmcloak
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mitmcloak-0.1.0-py3-none-any.whl -
Subject digest:
904803f0edb741d0316cfd0d4cb6465a885b3e992149b44125bc9302322b8437 - Sigstore transparency entry: 2692258597
- Sigstore integration time:
-
Permalink:
sardanioss/mitmcloak@06e3068b9bd532b49aa4899acc4f2b552df6e62f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sardanioss
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yaml@06e3068b9bd532b49aa4899acc4f2b552df6e62f -
Trigger Event:
push
-
Statement type: