Contents

Header obfuscation

Wrap raw streams and UDP packets in fake HTTP or protocol headers so first-byte DPI mislabels the flow.

Header obfuscation prepends a fake protocol header to your traffic so a censor’s classifier reads the opening bytes and files the flow under something boring — a web request, a video call, a torrent. It adds no encryption; it only changes what the first bytes look like. Xray ships two flavours: an HTTP costume for TCP streams and a set of fixed byte templates for mKCP’s UDP packets.

The HTTP header is a costume worn once, not a tunnel

network: tcp with header.type: http writes one fake HTTP request at the very start of the connection, then gets out of the way. The client’s Authenticator.Client wraps the socket in a Conn whose writer fires exactly once — after the header goes out, oneTimeWriter is set to nil and every later Write is the raw proxy stream, untouched.

transport/internet/headers/http/http.go
func (c *Conn) Write(b []byte) (int, error) {
	if c.oneTimeWriter != nil {
		err := c.oneTimeWriter.Write(c.Conn) // GET / HTTP/1.1\r\n...\r\n\r\n
		c.oneTimeWriter = nil
	}
	return c.Conn.Write(b) // everything after: unmodified
}

So the bytes on the wire are a plausible request header followed immediately by your encrypted tunnel masquerading as the HTTP body:

GET / HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0)

<...raw VMess/VLESS bytes look like a request body...>
header=http: the request line fools first-byte matching, but the 'body' is random ciphertext no HTTP parser would accept

Every value is picked fresh per connection

The request template is a menu, not a fixed string — Xray rolls a die across each field’s options every time it opens a connection. pickString chooses a random entry when a header or URI lists several, so no two connections carry an identical fingerprint.

transport/internet/headers/http/config.go
{
	"network": "tcp",
	"tcpSettings": {
		"header": {
			"type": "http",
			"request": {
				"path": ["/", "/assets", "/api/v2"],
				"headers": {
					"Host": ["www.bing.com", "www.cloudflare.com"],
					"User-Agent": ["Mozilla/5.0 (iPhone; ...)", "curl/8.4.0"]
				}
			}
		}
	}
}

On the receiving side the server does the mirror job: HeaderReader reads until the blank-line \r\n\r\n, then checks the request path against the configured uri list. A mismatch returns ErrHeaderMisMatch; a header past maxHeaderLength (8192 bytes) returns ErrHeaderToLong.

The server answers a wrong header like a real web server

A probe that opens the port but sends the wrong path gets a canned 400/404, not a dropped connection — so active scanning sees a bored HTTP server. When the header never validated and the connection closes, Conn.Close writes back the matching error response.

transport/internet/headers/http/http.go transport/internet/headers/http/resp.go
switch c.errReason {
case ErrHeaderMisMatch:
	c.errorMismatchWriter.Write(c.Conn) // HTTP/1.1 404 Not Found
case ErrHeaderToLong:
	c.errorTooLongWriter.Write(c.Conn) // HTTP/1.1 400 Bad Request
default:
	c.errorWriter.Write(c.Conn) // HTTP/1.1 400 Bad Request
}

mKCP headers fake the first bytes of a familiar UDP protocol

Each mKCP packet header is a tiny fixed template stamped onto the front of every datagram so stateless DPI classifies the flow as SRTP, DTLS, WeChat, uTP, or WireGuard. In this fork they are finalmask UDP masks: each exposes a Size() int and a Serialize([]byte) that stamps the disguise bytes, and finalmask’s headerManagerConn.WriteTo reserves that many bytes at the packet head and prepends them before mKCP’s payload. The classic kcpSettings.header knob is gone — KCPConfig.Build rejects it with PrintRemovedFeatureError — so you configure these under streamSettings.finalmask.udp with type ids like header-srtp.

transport/internet/finalmask/finalmask.go transport/internet/finalmask/header/srtp/conn.go
{
	"streamSettings": {
		"network": "kcp",
		"finalmask": {
			"udp": [{ "type": "header-srtp" }]
		}
	}
}
finalmask.udp typeBytesDisguised asLeading bytes
(omit the mask)0nothing — raw mKCP
header-srtp4WebRTC media (SRTP)B5 E8 + 16-bit counter
header-utp4BitTorrent uTP16-bit conn ID + 01 00
header-wechat13WeChat video callA1 08 + 32-bit seq + 00 10 11 18 30 22 30
header-dtls13DTLS 1.0 record17 FE FD + epoch + sequence + length
header-wireguard4WireGuard04 00 00 00

The DTLS template is the most elaborate: byte 0x17 is the application_data record type, FE FD is the wire version for DTLS 1.0, and the length field is deliberately jittered each packet — it grows by 17 and wraps down by 50 once it passes 100 — so the fake record sizes look organic.

transport/internet/finalmask/header/dtls/conn.go
func (h *dtls) Serialize(b []byte) {
	b[0] = 23             // application_data
	b[1] = 254; b[2] = 253 // DTLS 1.0
	// ...epoch, sequence...
	h.length += 17
	if h.length > 100 {
		h.length -= 50
	}
}

Reach for real TLS when you need to actually hide

Header obfuscation defeats cheap first-byte classifiers, but a stateful DPI that follows the flow sees plaintext framing wrapping non-conforming data. Use it as a lightweight nudge, not as your security boundary.

Don't

Rely on header=http to hide from a modern censor

The request line is sent in clear text and the body is obviously not HTTP. A stateful middlebox that reassembles the stream flags the mismatch — and there is no encryption underneath, so the tunnel protocol is exposed if the obfuscation is stripped.

Do

Layer headers under real transport security

Run VLESS with TLS/REALITY (or put mKCP header masking behind its own encryption). Real TLS gives you a genuine, validating handshake and confidentiality; a fake protocol header only buys you a friendlier first impression against dumb pipes.