Phantasm is a system-wide network-fingerprint evasion toolkit for Linux. It sits in the kernel's outbound path through NFQUEUE and rewrites the wire so that every process on the box — not just one specially rebuilt binary — presents a chosen browser's TCP, TLS, QUIC and HTTP/2 fingerprint. It ships two front-ends: a desktop GUI, and the one I want to talk about here, the terminal client.

This is a log entry about the terminal version specifically — what it is, how it talks to the daemon, and where it fits.

Dev-only, authorized-use scope. Every phantasm binary binds 127.0.0.1 and rejects non-loopback at startup. The packet daemon needs CAP_NET_ADMIN; the optional proxy needs a locally-trusted CA. Don't point it at anything you don't own or haven't been authorized to test. This is fingerprint-evasion research tooling for your own machines.

Why a terminal version at all

Phantasm's actual work happens inside phantasm-daemon: a long-running process that holds CAP_NET_ADMIN, sits on an NFQUEUE hook, and runs every outbound packet through a pipeline of rewrite modules. The daemon has no UI of its own, by design. It exposes one control socket and nothing else.

That leaves a gap — something has to drive the daemon: toggle modules, read counters, follow logs, retune the active profile. There are two clients for the job. One is phantasm-web, a localhost HTTPS dashboard. The other is the terminal client, phantasm, and on a box you reach over SSH (which is most boxes worth hardening) it's the one you actually use. No browser, no X server, no port to forward. It's also the control plane you can drop straight into a shell script.

The design constraint that falls out of this: the CLI has to be thin. The daemon is the privileged, complicated half. The client should be a small, dependency-free thing that opens a socket, sends a request, prints a response, and gets out of the way. It is — the whole client is a few hundred lines of C++ with no third-party libraries.

What it's driving

Before the client makes sense, here's the shape of what's on the other end of the socket. Phantasm is three components that build and run separately, plus an optional fourth:

apps ──▶ Linux kernel (NFQUEUE) ──▶ phantasm-daemon
                                     ├─ tcp_fingerprint → tcp_timestamp
                                     ├─ tls_mimic → quic_rotation
                                     ├─ ech_observer → webrtc_block
                                     └─ IPC over /run/phantasm/phantasm.sock
                                          ▲                  ▲
                              phantasm (CLI)        phantasm-web (HTTPS)

The daemon loads a stack of modules — TCP SYN-option rewriting, TCP timestamp masking, TLS ClientHello mimicry, QUIC Initial rewriting, hostname / DHCP anonymizing, a WebRTC STUN block, a passive ECH observer — and runs each outbound packet through them. A separate phantasm-proxy can be slotted in for HTTP/2 SETTINGS and pseudo-header rewriting when you need that extra layer of fidelity. The CLI and the web dashboard are simply two clients hanging off the same IPC socket. The CLI never touches packets, capabilities, or modules directly; it asks the daemon to.

The command surface

The entire client is one small verb-and-noun command set:

phantasm status              Show daemon & module status
phantasm enable <module>      Enable a module
phantasm disable <module>     Disable a module
phantasm stats <module>       Show module statistics
phantasm monitor             Live dashboard with auto-refresh
phantasm config get <key>     Get a config value
phantasm config set <key> <value>
phantasm log [-f]            Show or follow the live log
phantasm shutdown            Stop the daemon

Because nobody wants to type disable forty times during a session, there's an alias table resolved before the command is even parsed: on/off, up/down, and start/stop all map onto enable/disable; ls is status, st is stats, mon is monitor, cfg is config, shut/halt is shutdown. So in practice you type:

phantasm on tls_mimic        # enable, via alias
phantasm ls                  # status
phantasm st tcp_fingerprint  # per-module counters
phantasm mon                 # live dashboard

How it talks to the daemon

The client and daemon speak a deliberately boring protocol over a Unix domain socket at /run/phantasm/phantasm.sock (a named pipe on Windows). Every message is a 4-byte big-endian length prefix followed by a JSON payload. That's the whole framing:

// 4-byte big-endian length prefix + JSON payload
std::vector<uint8_t> frame_message(const std::string& payload) {
    uint32_t len = static_cast<uint32_t>(payload.size());
    std::vector<uint8_t> frame(4 + len);
    frame[0] = (len >> 24) & 0xFF;
    frame[1] = (len >> 16) & 0xFF;
    frame[2] = (len >>  8) & 0xFF;
    frame[3] = (len >>  0) & 0xFF;
    std::memcpy(frame.data() + 4, payload.data(), len);
    return frame;
}

A command like phantasm enable tls_mimic becomes {"cmd":"module.enable","module":"tls_mimic"}, gets length-prefixed, and is written to the socket; the daemon replies with {"ok":true,"data":{...}} and the client prints it. The JSON is handled by a hand-rolled extractor on both ends — at the IPC layer there is no JSON library dependency at all, which keeps the client tiny and the daemon's parsing surface small.

That "small surface" isn't a throwaway line. The control socket is a trust boundary, and the daemon treats it like one. Three things matter:

  • A command allowlist. The server honors only a fixed set of verbs — module.enable, module.disable, status, module.stats, config.get/set, log.subscribe, rotate, shutdown. Anything else is rejected before it reaches a handler.
  • A per-connection rate limiter. A token bucket caps requests per second, so a misbehaving or runaway client can't spin the daemon.
  • Output escaping. Responses and log events carry strings that originate from the network — a hostname, an SNI seen by the JA3 rotator, a module name from a rejected request. All of it runs through a json_escape before being interpolated into the response JSON, so an attacker-influenced " or \ can't break out of the string and inject sibling keys into whatever parses the daemon's output downstream.

None of this is exotic, and that's the point: the control plane is the kind of code you want to be able to read in one sitting and be confident about.

The monitor: a TUI with no TUI library

The one command that isn't fire-and-forget is phantasm monitor. It's a live dashboard that polls the daemon and redraws, and it's written against raw terminal primitives — no ncurses, no dependency:

$ phantasm monitor
Phantasm Monitor                                  d=toggle, q=quit

Uptime: 2h 34m  Modules: 4/7  Pkt: 12,845

MODULE              STATUS    PKT IN    PKT OUT    DROP
─────────────────────────────────────────────────────────
tcp_fingerprint      ON        8,234      7,891      343
hostname_dhcp        ON        3,102      3,102        0
tcp_timestamp        OFF          0          0        0
webrtc_block         ON        1,509      1,509        0
ja3_filter           OFF          0          0        0

Under the hood it does four things on a loop. It puts the terminal into raw mode — termios with ICANON and ECHO cleared and VMIN=0 so reads never block. It clears the screen with the \033[2J\033[H ANSI escape. It polls stdin with a zero-timeout select() to catch q (quit) and d (toggle compact vs. detailed) without ever blocking the redraw. And on each refresh it opens a fresh connection to the socket, asks for status plus a module.stats per module, and renders the table.

Two small decisions are worth calling out. First, the monitor reconnects every refresh rather than holding the socket open — each poll is a clean, stateless request/response, so a daemon restart mid-session just shows up as one dropped frame and then reconnects, no special-casing required. Second, the terminal state is restored through an RAII guard: the original termios is captured in a constructor and put back in the destructor, so however you leave — q, Ctrl+C, or the daemon vanishing — your shell is never left stuck in raw mode.

Where the terminal client fits

Phantasm as a whole occupies a specific niche, and the CLI is how you operate inside it. Anti-fingerprinting tooling in 2026 comes in three flavors: language libraries like uTLS or tls-client that are byte-exact but only cover apps written against them; patched binaries like curl-impersonate, byte-exact but only that one rebuilt program; and tunnel obfuscators like v2ray or AmneziaWG that hide the tunnel, not the traffic inside it. Phantasm trades a little of curl-impersonate's byte-exact fidelity for breadth: install once, route your outbound traffic through it, and every process on the host inherits the profile.

From the terminal, that translates into a handful of real workflows:

  • Red-team / authorized assessment. Match a target environment's baseline browser, enable just the modules you need (phantasm on tls_mimic), and leave the H2 proxy off unless you're up against a WAF that scores on SETTINGS. The CLI is scriptable, so the whole setup folds into your engagement tooling.
  • Privacy crowd-blending. A uniform Firefox-ESR mimic, plus the WebRTC STUN block, plus the passive ECH observer — configured and checked in a couple of commands.
  • Live triage. phantasm monitor in one pane and phantasm log -f in another, watching which modules are actually touching packets and whether anything is dropping.

And, just as importantly, where it doesn't fit: phantasm is not a VPN-tunnel obfuscator and doesn't pretend to be. If you need the tunnel itself to look like nothing, that's AmneziaWG or Cloak's job. Phantasm changes the fingerprint of the traffic, not the shape of the pipe.

Putting traffic through it: curl, Playwright, headless browsers

Here's the part that makes phantasm different from a per-app impersonation library: you don't integrate it into your client at all. You pick a profile from the terminal, and every process on the box inherits it. There are two layers to route through, and the CLI is how you arm both.

The daemon, on its NFQUEUE hook, rewrites the wire for any process transparently — TCP SYN options, TCP timestamps, the TLS ClientHello (extension order and cipher / groups / sig_algs, length-preserving, so approximate rather than byte-exact), and the QUIC Initial. Nothing in the client needs to know. The optional phantasm-proxy adds the HTTP/2 layer — SETTINGS values and pseudo-header order — for clients you point at it. You set the active profile and flip the modules on the same way you'd do anything else from the terminal:

phantasm config set tls_mimic.profile chrome
phantasm on tls_mimic        # TLS ClientHello mimicry (daemon)
phantasm on quic_rotation    # QUIC Initial rewriting (daemon)
phantasm ls                  # confirm what's actually active

curl

With just the daemon running, curl needs no flags at all — its handshake is rewritten on the way out:

# the daemon is already rewriting the wire; curl is oblivious
curl https://example.com

To also rewrite the HTTP/2 fingerprint — and to read back exactly what the far end saw — send it through the proxy and trust the local CA:

curl -p -x http://127.0.0.1:8080 \
     --cacert ~/.config/phantasm/proxy-ca.pem \
     https://tls.peet.ws/api/all | jq '.tls.ja3'

tls.peet.ws echoes back the JA3 / JA4 and the HTTP/2 Akamai fingerprint it observed; diff it against a bare curl run to confirm the rewrite actually landed. It's the fastest way to know phantasm is doing what you think.

Playwright

Playwright drives a real Chromium or Firefox, so the daemon rewrites its TCP / TLS / QUIC transparently, exactly like curl. For the H2 layer, point the browser at the proxy and accept the local CA:

const browser = await chromium.launch({
  proxy: { server: 'http://127.0.0.1:8080' },   // phantasm-proxy
});
const ctx = await browser.newContext({
  ignoreHTTPSErrors: true,   // or install proxy-ca.pem into the trust store
});

You usually don't want to rewrite the whole box's traffic for one automation run. Because the daemon hooks NFQUEUE through iptables, you can scope it to a single UID and run Playwright as that user — only its packets enter the pipeline:

sudo iptables -I OUTPUT -m owner --uid-owner $(id -u automation) \
     -j NFQUEUE --queue-num 0 --queue-bypass

Headless and terminal browsers

Headless Chrome is the same story — --headless --proxy-server=http://127.0.0.1:8080 for the H2 layer, the daemon for everything beneath it. Terminal browsers that drive a real engine (carbonyl, browsh) get the wire rewritten the same way; even text-only clients like w3m and lynx get the TCP and TLS treatment, because it all happens below the application. If your harness renders pages, turn on the WebRTC block so it can't leak your real address through a STUN candidate:

phantasm on webrtc_block

One gotcha worth knowing: the MITM proxy breaks certificate-pinned apps by design, so it ships a default bypass list (Apple, Google, Mozilla, Microsoft) and, in transparent mode, peeks the SNI to route pinned hosts around the interceptor untouched.

What this buys you — and what it doesn't

Be clear-eyed about the boundary. Phantasm is a network tool: it makes the wire — TCP, TLS, QUIC, HTTP/2 — look like the browser you chose, coherently, across every process on the host. It does not touch the application layer. A headless Chromium routed through phantasm still reports navigator.webdriver, still sends a HeadlessChrome user-agent, and still carries the canvas and WebGL tells a DOM-level fingerprinter reads. Those live in a browser extension — phantasm's own phantasm-ext, or a hardened browser — not in the daemon. Route your automation through phantasm for a coherent network identity; don't expect it to make a headless browser look human all on its own.

Project scope

Phantasm is built as a stack of 17 PRs against master, each one independently reviewable and revertable: daemon core and the Linux packet-abstraction layer first, then the TLS / QUIC fingerprint modules, then the web dashboard, then the proxy, then the docs. The terminal client rides on the same IPC protocol the entire way through.

It's explicitly dev-scoped, and the security posture reflects that. Every binary is loopback-only and refuses to start otherwise. The daemon drops to CAP_NET_ADMIN and nothing else, sets PR_SET_NO_NEW_PRIVS, and installs a seccomp blacklist of around twenty syscalls (ptrace, kernel-module loading, mount, reboot, clock manipulation). The proxy's MITM CA key is written 0600 with an atomic create-and-rename. There's a 33-test suite, including network-namespace integration tests gated behind a root tier, and every test that mutates host state is sandboxed with env-var path overrides so the suite is safe to run as root. Linux is the primary target; there's Windows scaffolding — the CLI already speaks the named-pipe variant of the same protocol — but the packet engine is Linux-first.

The terminal client is the smallest piece of all of this, and that's the nicest thing about it. The daemon can be doing genuinely hairy work — parsing ClientHellos, rewriting QUIC Initials, juggling capabilities and seccomp filters — and the thing you actually touch to drive it is a few hundred lines that open a socket and print what comes back. A boring control plane for a complicated system is exactly the trade you want.

— logged from the deck.