Transparent, per-packet UDP payload obfuscator for Linux. Captures locally- originated UDP, XORs the payload against a passphrase-derived ChaCha20 keystream, and forwards it over the wire. The receiver decodes symmetrically and re-injects the cleartext UDP into the local stack, so applications see a normal UDP socket on both ends — no per-app changes, no userspace proxy, no MTU loss.
Intended for environments where a UDP tunnel (VXLAN, WireGuard behind a tunnel, QUIC, plain application UDP, …) should look like opaque UDP to on-path observers.
- Transparent: applications keep their sockets; no proxy in the hot path.
- MTU-preserving: the payload length is unchanged.
- Works with both user apps and kernel UDP tunnels (tested with VXLAN).
- IPv4 and IPv6.
- Three send modes: raw socket (
raw), TUN write-back (tun_writeback), or UDP-socket pool (udp_socket); auto-detected when omitted. - Optional dst-port rewriting so the wire port differs from the app port.
- Rule engine matches by AF, src/dst IP, src port range, dst port.
- Clean RAII setup: all iptables / ip-rule / sysctl changes are rolled back on exit.
┌─ local UDP ─┐ ┌─ local UDP ─┐
app ───▶│ iif lo │──▶ udpak0 (TUN) ──▶ encode ─│ raw send │──▶ wire
└─────────────┘ XOR └─────────────┘
│
┌─── TUN ────┐ ┌── TPROXY mangle ──┐ │
app ◀──│ write back │◀── decode XOR ◀──│ dport match │◀─────┘
└─────────────┘ └───────────────────┘
Sender side:
- An
ip rule iif lo dport X fwmark 0/0x3 lookup <table>redirects locally-originated UDP matchinglocal_trafficinto a routing table whose only route isdefault dev udpak0. - The obfus reads the packet from the TUN, parses the UDP header, XORs the payload with the keystream, optionally rewrites the dst port, recomputes the UDP checksum, and emits the packet.
- In
rawmode the packet is re-sent via a raw socket (IP_HDRINCL / v6 HDRINCL) withSO_MARK = mark_emit. The mark bit prevents the capture rule from re-matching, so no loop. - In
tun_writebackmode the packet is written back into the TUN; the kernel then forwards it via the normal output path. This mode needsip_forward=1(saved and restored by the setup guard).
Receiver side:
- An iptables
mangle PREROUTINGrule matches UDP on the wire port andTPROXY-redirects it to the obfus listener, which runs withIP_TRANSPARENT+IP_RECVORIGDSTADDR. - The obfus recovers the original 5-tuple from the
ORIGDSTADDRcmsg, decodes the XOR, optionally rewrites the dst port back, and writes the cleartext packet into the TUN. - A secondary routing table (
table + 1) containinglocal 0.0.0.0/0 dev lomakes the kernel treat any dst IP as local for traffic coming from the TUN, so the decoded packet is delivered to the app (or to the kernel UDP-tunnel socket, e.g. VXLAN).
An ! -i <tun> match on the TPROXY rule keeps decoded packets from being
re-captured when dstport_rewrite is not set.
Build:
cargo build --release
sudo setcap cap_net_admin,cap_net_raw=eip target/release/transparent-udp-obfus
# or run as rootMinimal config (obfus.toml):
tun = "udpak0"
table = 100
tproxy_port = 9000
mark_reinject = 0x3
mark_emit = 0x2
mark_tproxy = 0x1
passphrase = "correct horse battery staple"
num_threads = 2
sender_mode = "raw" # or "tun_writeback"
# Capture locally-originated UDP destined to port 4789 and ship it out
# as UDP/9887 on the wire.
[[local_traffic]]
af = "ip4"
dstport = 4789
dstport_rewrite = 9887
# On the remote side, receive UDP/9887, decode, deliver as UDP/4789.
[[remote_traffic]]
af = "ip4"
dstport = 9887
dstport_rewrite = 4789Run (must be root for raw sockets / iptables / ip rule):
sudo ./transparent-udp-obfus --config obfus.tomlBoth ends must share the same passphrase. All Linux state (TUN, ip rule,
route tables, iptables TPROXY rule, rp_filter/ip_forward sysctls) is added
at startup and rolled back on SIGINT / SIGTERM.
| Key | Type | Notes |
|---|---|---|
tun |
string | Interface name (≤15 chars). Created multi-queue. |
table |
u32 | Primary routing table for capture. table+1 is used for the tproxy local route. |
tproxy_port |
u16 | Local port for the transparent listener. |
mark_reinject |
u32 (mask) | fwmark mask that must be zero on the capture rule (fwmark 0/mask). |
mark_emit |
u32 | SO_MARK applied to raw-socket sends and tun_writeback sends; should overlap with mark_reinject so re-emitted packets skip the capture rule. |
mark_tproxy |
u32 | Mark the TPROXY rule sets; used to route to table+1 (local 0/0 dev lo). Default 0x1. |
passphrase |
string | Hashed with BLAKE3 to seed the ChaCha20 keystream. |
num_threads |
usize | Tokio worker threads and TUN queues. Defaults to available parallelism. |
sender_mode |
enum | raw, tun_writeback, or udp_socket. Omit to auto-detect: udp_socket if every local rule has srcport_rewrite with a range disjoint from srcport, else raw. |
socket_idle_secs |
u64 | Idle TTL for entries in the udp_socket pool. Default 10. |
iptables_comment |
string | Tag added to every iptables TPROXY rule (-m comment --comment X). Used by pre/post-clean to wipe leftovers from a crashed prior run. Default udp-obfus-<passphrase with whitespace stripped>. |
cipher_mode |
enum | chained (default) or plain. See below. |
local_traffic |
array[rule] | Match on locally-originated UDP to capture. |
remote_traffic |
array[rule] | Match on remote UDP to TPROXY-intercept. |
Each rule:
| Key | Type | Notes |
|---|---|---|
af |
"ip4" | "ip6" |
Required. |
srcip |
CIDR string | Optional. |
dstip |
CIDR string | Optional. |
srcport |
u16 or [lo, hi] |
Optional. |
dstport |
u16 | Optional. |
dstport_rewrite |
u16 | Optional. If set, overwrite dst port after encoding / before decoding. |
srcport_rewrite |
u16 or [lo, hi] |
Optional. Requires srcport. Either a range of the same size as srcport (1:1 offset mapping) or a single port (N:1 flatten — every matched src port is squashed onto it). Sender maps app→wire; receiver maps back. Flatten mode is intended for symmetric NAT on the path: the NAT randomises the wire src port, so the receiver sets srcport_rewrite to a single port to pin everything back to a fixed app-side port. |
chained(default): tail→head chained XOR,C[i] = P[i] ^ K[i] ^ C[i+1]. Any byte change propagates through the head of the packet, so identical plaintext segments produce different ciphertext. Not SIMD-vectorizable because of the loop-carried dependency, so it runs scalar at ~1 byte/cycle.plain: pure statelessbuf[i] ^= K[i]. The compiler auto-vectorizes it to AVX2/NEON (10–30× faster in the inner loop), but identical plaintext at the same offset produces identical ciphertext. Useful only when XOR is the bottleneck — typically 10 Gbps+ or jumbo frames; for 1 Gbps-class VXLAN overlays the two modes are within noise of each other because syscalls and the UDP checksum dominate per-packet cost.
Both peers must use the same cipher_mode.
- Must run as root, or with
CAP_NET_ADMIN+CAP_NET_RAW. rp_filteris set to 0 on bothalland<tun>for the lifetime of the program (effective rp_filter ismax(all, iface)). Previous values are restored on exit.tun_writebackmode additionally enablesip_forwardandaccept_localon the TUN.accept_localis required because without itfib_validate_sourcerejects our own local src as a martian, independent ofrp_filter.- Multi-queue TUN: the writer fd reuses
sender_fds[0]. A dedicated writer fd would cause the kernel to hash some outgoing packets into a queue that nothing reads, silently dropping whole flows. - Matcher is first-match-wins. Put more specific rules before broader ones.
A netns-based integration test lives under tests/netns/. It builds a
topology alice ↔ home_a ↔ isp ↔ home_b ↔ bob (two double-NATed peers with
an ISP in between), runs obfus on both ends, and verifies:
- cleartext roundtrip with both a user app (socat) and a kernel VXLAN tunnel,
- that the ISP-side capture contains no cleartext marker,
- all combinations of
{ip4, ip6} × {raw, tun_writeback} × {rewrite on/off} × {userapp, vxlan}— 16 cases.
Run:
sudo bash tests/netns/run.shSee LICENSE if present.