The art of hiding in plain sight: swap the protocol, break the balance, blend into the noise.
Ark is a layer-4 protocol-swapping VPN built for adversarial networks: environments where a deep-packet-inspection adversary monitors traffic patterns at every node and blocks or disrupts connections it classifies as VPN tunnels.
Instead of encrypting traffic and hoping nobody notices, Ark changes what protocol the traffic looks like at the transport layer. Your TCP stream is wrapped inside legitimate-looking DNS UDP packets and silently unwrapped at the exit node — transparent to applications, invisible to the traffic classifier.
- The Problem: Traffic Pattern Detection
- The Solution: Layer 4 Protocol Swapping
- Building
- Usage
- How It Works
- Full Packet Flow
- Connection Handshake
- Architecture
- Protocol Reference
- Repository Layout
- Limitations
In an adversarial network, a monitoring agent identifies VPN gateway nodes by checking one property: a relay node forwards every byte it receives, so its incoming traffic equals its outgoing traffic per protocol. That perfect per-protocol balance is the fingerprint.
A standard VPN gateway relays TCP in both directions. Counting all flows at the gateway:
Standard VPN — flagged because in ≈ out:
CLIENT VPN GATEWAY DESTINATION
│ │ │
│─ TCP 200B ───────>│─ TCP 200B ────────>│ (request)
│<─ TCP 2GB ────────│<─ TCP 2GB ─────────│ (response)
│ │ │
╔══════════════════════╗
║ TCP in ≈ 200B+2GB ║
║ TCP out ≈ 200B+2GB ║ <── perfectly balanced
╚══════════════════════╝ adversary flags this node
Every byte that enters the gateway on TCP exits on TCP. The adversary doesn't need to decrypt anything — the balance alone is enough to classify the node as a VPN.
Ark wraps TCP packets inside DNS/EDNS UDP datagrams before they leave the client. The server unwraps them and forwards the original TCP to the destination. The gateway still relays the same data, but the per-protocol accounting is now lopsided in both directions:
Ark VPN — per-protocol imbalance defeats the detector:
CLIENT ARK GATEWAY DESTINATION
│ │ │
│─ UDP 200B ───────>│─ TCP 200B ────────>│ (request)
│<─ UDP 2GB ───────│<─ TCP 2GB ────────│ (response)
│ │ │
╔═══════════════════════╗
║ UDP in ≈ 200B ║ imbalanced
║ UDP out ≈ 2GB ║ (small queries, big answers)
╠═══════════════════════╣
║ TCP in ≈ 2GB ║ imbalanced
║ TCP out ≈ 200B ║ (big fetch, small request)
╚═══════════════════════╝
looks like a DNS resolver — not a VPN
The gateway's traffic profile now matches a DNS caching resolver: it receives small queries over UDP and returns large payloads, while fetching content over TCP in the background. DNS is vital infrastructure that an adversary cannot block without breaking the network for everyone, making it the ideal camouflage carrier.
cargo build --releaseRequires Rust 1.70+ and Linux. The binary needs CAP_NET_ADMIN to create TUN devices — the provided scripts handle this automatically via setcap.
./run-server.sh --local 0.0.0.0:9091 --mode server --subnet 172.16.0.1/24The script also enables IP forwarding and installs a NAT/masquerade rule so that VPN clients can reach the internet through the server:
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 172.16.0.0/24 ! -d 172.16.0.0/24 -j MASQUERADE./run-client.sh --local 0.0.0.0:7070 --remote <server-ip>:9091 --mode clientThe script sets the default route through ark-0 so all traffic is tunneled:
ip route add default dev ark-0| Flag | Short | Description | Required by |
|---|---|---|---|
--local |
-l |
Local UDP bind address (ip:port) |
Both |
--mode |
-m |
client or server |
Both |
--remote |
-r |
Server address (ip:port) |
Client |
--subnet |
-s |
VPN subnet CIDR (e.g. 172.16.0.1/24) |
Server |
Set RUST_LOG=debug for per-packet logging.
Raw TCP packets are stuffed into DNS EDNS (Extension Mechanisms for DNS) OPT records, using custom option code 65001. The DNS message is disguised as a legitimate A-record query — an ordinary thing for any host to send.
┌─────────────────────────────────────────────────────────┐
│ UDP datagram (on the wire) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ DNS Message │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ Header ID=1, QR=Query, RD=1 │ │ │
│ │ ├─────────────────────────────────────────────┤ │ │
│ │ │ Question "leader.ir." A IN │ │ │
│ │ ├─────────────────────────────────────────────┤ │ │
│ │ │ EDNS OPT RR │ │ │
│ │ │ ┌───────────────────────────────────────┐ │ │ │
│ │ │ │ Option Code : 65001 (ARK) │ │ │ │
│ │ │ │ Option Data : <raw TCP packet bytes> │ │ │ │
│ │ │ └───────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
EDNS is fully backward-compatible with all DNS infrastructure, so these packets traverse firewalls, NAT boxes, and DNS proxies without modification.
TCP negotiates its Maximum Segment Size during the three-way handshake. Ark intercepts SYN packets and reduces the MSS to 1300 bytes, reserving ~200 bytes of headroom for the DNS/EDNS wrapper so every packet stays within the 1500-byte MTU.
USER APP ARK CLIENT ARK SERVER DESTINATION
│ │ │ │
│ SYN MSS=1500 │ │ │
│────────────────>│ │ │
│ │ SYN MSS=1300 │ │
│ │ (clamped) │ │
│ │────────────────> │─ SYN MSS=1300 ──>│
│ │ │<─ SYN,ACK ───────│
│<── SYN,ACK ─────│<─────────────────│ │
│─── ACK ────────>│─────────────────>│── ACK ──────────>│
Ark creates a virtual network interface (ark-0) via the Linux TUN/TAP API. Setting ark-0 as the default route causes the kernel to hand every outbound packet to Ark's process before it reaches the real NIC — no iptables rules or application changes required.
┌──────────────────────────────────────────────────────┐
│ Linux Kernel │
│ │
│ Application ──> TCP/IP Stack ──> ark-0 (TUN) ─────> │
│ │ │
└────────────────────────────────────────│─────────────┘
│ fd read/write
┌──────▼──────┐
│ ark process │
│ (userspace)│
└──────┬──────┘
│ UDP socket
┌──────▼──────────┐
│ Real NIC │
│ (eth0, wlan0…) │
└─────────────────┘
CLIENT SIDE SERVER SIDE
───────────────────────────────────────────────────────────────────────
Application (curl, browser, …)
│ TCP segment
▼
Kernel routes via ark-0 TUN
│ raw IPv4 frame
▼
read_from_nic()
├─ parse IPv4 + TCP headers
├─ clamp MSS if SYN packet
└─ build TcpPacketSlice { src_ip, dst_ip, tcp_bytes }
│
▼
Relay (crossbeam select loop)
└─ to_edns_packet(tcp_bytes)
wraps TCP in DNS EDNS option 65001
│ (edns_bytes, dst_ip)
▼
Client::run_udp_pipe()
└─ sendto(server_addr, edns_bytes)
│
│═══════════════ UDP over Internet ════════════════╗
║
Server::run_udp_tunnel()
├─ recvfrom(buf)
├─ from_edns_packet(buf)
│ extracts TCP bytes
└─ push to udp_input_pipe
│
▼
Relay (crossbeam select loop)
└─ forward tcp_output_pipe
│
▼
write_to_nic()
├─ reconstruct IPv4 header
└─ write to TUN ark-0
│
▼
kernel ──> real NIC ──> destination
The return path is symmetric: the server reads the TCP response from TUN, wraps it in EDNS, sends it back to the client over UDP.
Before tunneling begins, the client obtains a VPN-internal IP from the server via a lightweight plaintext handshake:
CLIENT SERVER
│ │
│──── "client hello" (raw UDP) ───────────────>│
│ │ allocate next IP
│ │ e.g. 172.16.0.2
│ │ record IP → UDP addr
│<─── "server accept" + [172, 16, 0, 2] ────────│
│ │
│ configure ark-0: 172.16.0.2/24 │ configure ark-0: 172.16.0.1/24
│ │
│════════ EDNS-wrapped TCP tunnel active ════════│
The server maintains a map of VPN IP → client UDP address to route responses back to the correct client. Up to 254 clients can connect simultaneously (.2 through .255).
┌───────────────────────────────────┐
│ Relay │
│ │
TUN read ────────► │ tcp_input_pipe │
│ │ │
│ ▼ (select loop) │
│ to_edns_packet() │
│ │ │
│ ▼ │
│ udp_output_tx ────────────────────┼──► UDP socket
│ │
UDP recv ────────► │ udp_input_pipe │
│ │ │
│ ▼ │
│ tcp_output_pipe ──────────────────┼──► TUN write
└───────────────────────────────────┘
Server extras:
┌────────────────────────────────────────────────────┐
│ addr_map : HashMap<Ipv4Addr, SocketAddr> │
│ base : Ipv4Addr (incremented per new client) │
└────────────────────────────────────────────────────┘
Five scoped threads keep everything non-blocking:
| Thread | Responsibility |
|---|---|
read_from_nic |
TUN fd → relay tcp_input_pipe |
write_to_nic |
relay tcp_output_pipe → TUN fd |
relay.run |
TCP↔EDNS conversion, crossbeam select! loop |
run_udp_tunnel |
UDP recvfrom → relay udp_input_pipe |
run_udp_pipe |
relay udp_output_tx → UDP sendto |
| Property | Value |
|---|---|
| Wire transport | UDP |
| Encapsulation | DNS/EDNS OPT record (RFC 6891) |
| ARK option code | 65001 (0xFDE9) |
| DNS query name | leader.ir. |
| DNS query type | A |
| EDNS max payload | 4096 bytes |
| TCP MSS clamp | 1300 bytes |
| Default VPN subnet | 172.16.0.0/24 |
| Max concurrent clients | 254 |
| Packet buffer | 1500 bytes (stack-allocated) |
ark/
├── src/
│ ├── main.rs CLI, TUN setup, thread spawning
│ ├── lib.rs TcpPacketSlice — core packet wrapper type
│ ├── relay.rs Relay, Server, Client, Tunnel trait
│ └── edns.rs TCP ↔ DNS/EDNS encode/decode
├── run-server.sh Server startup + NAT rules
├── run-client.sh Client startup + default route
└── Cargo.toml
- No encryption — Ark conceals traffic patterns, not payload content. Layer it with TLS or a separate encryption tool for confidentiality.
- No authentication — Any client that reaches the server can connect. A pre-shared key or certificate check is not yet implemented.
- IPv4 only — IPv6 is not currently supported.
- Hardcoded query name — The DNS query name
leader.ir.is fixed; a rotating or configurable name would improve stealth. - UDP-over-TCP overhead — When tunneling UDP user traffic through the TCP tunnel, retransmission semantics are inherited from the outer TCP layer, which can cause head-of-line blocking.