👻 finalmask: traffic masks from Xray-core
finalmask is the mask layer of Xray-core, as implemented in the Jolymmiles/Xray-core fork. A mask sits between the socket and TLS and changes how the bytes look on the wire: it cuts the first packets, puts a handshake of your own in front of the data, recodes the whole stream as sudoku or as a game protocol, hides UDP in DNS queries or ICMP. To an Xray server with the same masks in its inbound’s finalmask, Prizrak-Core is an ordinary client.
Ported masks:
| Type | TCP | UDP | What it does |
|---|---|---|---|
fragment | ✅ | — | Cuts the first writes of the connection (the ClientHello) into pieces |
header-custom | ✅ | ✅ | A scripted exchange of bytes at the start of a TCP stream; a header in front of every UDP datagram, or a separate exchange before the first one |
sudoku | ✅ | ✅ | Recodes the stream into sudoku hint bytes |
xmc | ✅ | — | The stream looks like a Minecraft client logging in and playing |
noise | — | ✅ | Sends a few noise packets before the first packet to a peer |
udphop | — | ✅ | Changes the local socket and/or the server address and port at intervals |
xdns | — | ✅ | Datagrams travel as DNS queries and answers through resolvers |
xicmp | — | ✅ | Datagrams travel as ICMP echoes (ping) |
Not ported: mkcp-legacy, salamander (including gecko) and realm. A configuration that names them fails with a clear error.
Where it works
Section titled “Where it works”- On
vlessandtrojanproxies and on the listeners of the same types only. - TCP masks wrap the connection under TLS, REALITY, ShadowTLS, Restls and JLS and work with every transport:
tcp,ws,grpc,h2,http,xhttp(includingdownload-settings). - UDP masks wrap the socket of VLESS over mKCP (
network: mkcp, see below) and the QUIC socket of xhttp over HTTP/3 (network: xhttp,alpn: [h3]). Other transports do not use them, and such a configuration is rejected with an error. - Listeners take TCP masks only: they have no HTTP/3 or mKCP socket.
- Without a
finalmaskblock the proxy works as before, with no wrapper at all.
The order is Xray’s: the first mask in the list is nearest to TLS, the last one is nearest to the network and is what the wire shows. Masks that open the network themselves (udphop, xdns, xicmp) must be the last entry of the udp list.
The masks must match on both sides. A server with a mask the client lacks, or one parameter apart (the sudoku password, say), means no connection.
Configuration format
Section titled “Configuration format”The finalmask block of a proxy holds the tcp and udp lists. Every entry is a type with the mask’s options beside it:
proxies: - name: vless-finalmask type: vless server: example.com port: 443 uuid: <uuid> tls: true servername: example.com client-fingerprint: chrome finalmask: tcp: - type: fragment packets: tlshello lengths: ["1-1"] delays: ["0"] - type: sudoku password: <password>- Keys are written mihomo-style, with dashes:
rand-range,max-split,custom-tables,padding-min. - Xray’s spelling with a nested
settings:is accepted too, so a block can be copied from an Xray client configuration after renaming the keys (table below). - Ranges are written as in Xray:
"10-20"or a plain number. The upper bound oflength/delayis not included, as in Xray.
Key names in Xray and here
Section titled “Key names in Xray and here”| Xray (JSON) | Prizrak-Core (YAML) |
|---|---|
randRange | rand-range |
maxSplit | max-split |
customTable, customTables | custom-table, custom-tables |
paddingMin, paddingMax | padding-min, padding-max |
texturesValue, texturesSignature | textures-value, textures-signature |
remoteIPs, remotePorts | remote-ips, remote-ports |
lenLimit, labelLimit, extraPoll | len-limit, label-limit, extra-poll |
The other keys (packets, length, delay, lengths, delays, clients, servers, errors, client, server, mode, capture, reuse, transform, password, ascii, hostname, profiles, reset, noise, interval, domains, resolvers, names, types, edns0, addrs, dgram, ips) are the same.
An Xray example and its equivalent
Section titled “An Xray example and its equivalent”In Xray the finalmask block of an outbound looks like this:
{ "tcp": [ { "type": "fragment", "settings": { "packets": "tlshello", "lengths": ["1-1"], "delays": ["0"] } } ]}In Prizrak-Core it is:
finalmask: tcp: - type: fragment packets: tlshello lengths: ["1-1"] delays: ["0"]The spelling with settings: works as well:
finalmask: tcp: - type: fragment settings: packets: tlshello lengths: ["1-1"] delays: ["0"]TCP masks
Section titled “TCP masks”fragment
Section titled “fragment”Cuts the first writes of the connection. The implementation is shared with freedom, so the behavior is the same.
| Parameter | Values | What it does |
|---|---|---|
packets | tlshello, "N-M" or empty | tlshello rewrites the first ClientHello into several TLS records. "1-3" cuts the first three writes into TCP segments, empty cuts every write |
length | "N" or "N-M", bytes | Size of every piece |
delay | "N" or "N-M", ms | Pause after every piece. With tlshello and 0 all records go out in one write |
lengths | list of ranges | Size of piece i; the last entry serves every later piece |
delays | list of ranges | Pause after piece i; the last entry serves every later piece |
max-split | "N" or "N-M" | At most this many pieces, the last one takes the rest. 0 means no limit |
header-custom
Section titled “header-custom”A scripted exchange at the start of the connection: the client sends clients[0], the server answers with servers[0], then clients[1], servers[1] and so on. TLS follows only after that. When the client’s sequence does not match, the server answers with errors[i] (server side only).
- type: header-custom clients: - - packet: "GET /api HTTP/1.1\r\nHost: cdn.example.com\r\nX-Nonce: " type: str - rand: 8 rand-range: "97-122" # letters a-z capture: nonce - packet: "\r\n\r\n" type: str servers: - - packet: "HTTP/1.1 101 Switching Protocols\r\nX-Nonce: " type: str - reuse: nonce - packet: "\r\n\r\n" type: strEvery item of a sequence is exactly one of:
| Field | What it does |
|---|---|
packet + type | Fixed bytes. type: str (as is), hex, base64, or a list of numbers 0–255 without type |
rand + rand-range | rand random bytes within rand-range (0-255 by default) |
reuse | A value captured earlier with capture |
transform | Computed bytes: {op: ..., args: [...]} |
Also: capture: name keeps the item’s received or sent bytes under a name; delay: "a-b" (TCP only) pauses that many ms before the item.
transform operations: concat, slice, be16, be32, le16, le32, le64, xor16, xor32, pad, truncate, add, sub, and, or, shl, shr. Arguments: {bytes: ..., type: hex}, {u64: 42}, {reuse: name}, {metadata: local_port} (local_port, remote_port, local_ip4_u32, remote_ip4_u32, src_port_u16, dst_port_u16, src_ip4_u32, dst_ip4_u32), a nested {transform: ...}.
sudoku
Section titled “sudoku”Recodes the whole stream into the hint bytes of 4×4 sudoku grids. Over TCP the client writes the classic encoding (4 hints per byte) and the server the packed one (6 bits per byte). There is no handshake; the mask is a pure change of appearance.
| Parameter | What it does |
|---|---|
password | Seeds the permutation table; the same on both sides |
ascii | prefer_entropy (default): bytes look random; prefer_ascii: printable ASCII |
custom-table | An 8-letter bit layout: exactly 2 x, 2 p and 4 v, for example xpxvvpvv (entropy mode only) |
custom-tables | Several layouts, rotated |
padding-min, padding-max | Chance (in percent) of inserting a padding byte |
Traffic grows 4–9× over TCP from the client and about 1.4× from the server.
The stream looks like a Minecraft 26.1.2 client logging in: handshake, Login Start, encryption with an RSA key derived from the password, Login Success with a signed profile, then a “world load” of the right length and the data itself in Custom Payload packets with keep-alives.
- type: xmc password: <password> # the same on both sides hostname: mc.example.com # the server address of the handshake packet profiles: - username: Steve uuid: 069a79f4-44e9-4726-a5be-fca90e38aaf5 textures-value: <value> textures-signature: <signature>uuid comes from https://api.mojang.com/users/profiles/minecraft/{username}, textures-value and textures-signature from https://sessionserver.mojang.com/session/minecraft/profile/{uuid}?unsigned=false. The profiles must match on the client and the server.
UDP masks
Section titled “UDP masks”They work for VLESS over mKCP (network: mkcp) and for xhttp over HTTP/3 (alpn: [h3]).
Sends a few noise packets before the first datagram to a peer and again after reset seconds.
- type: noise reset: "30-60" # seconds; 0 sends the noise once noise: - type: exp packet: "<b 0d0a0d0a> <t> <r 24>" delay: "10-20" # pause after the packet, ms - rand: "40-60" rand-range: "0-255"A noise packet is one of: rand (that many random bytes within rand-range), packet + type (str, hex, base64, a list of bytes) or type: exp with an expression of segments: <b hex> fixed bytes, <t> a 4-byte timestamp, <c> a 4-byte counter, <n> an 8-byte nonce, <r a-b> random bytes, <rc a-b> random letters, <rd a-b> random digits.
header-custom (UDP)
Section titled “header-custom (UDP)”The same items as on TCP, in two modes:
mode: prefix(default): theclientheader goes in front of every client datagram,serverin front of every server one;mode: standalone: the headers are exchanged once as datagrams of their own before the first packet (a STUN-like request and answer, for example).
- type: header-custom mode: standalone client: - packet: "000100002112a442" type: hex - rand: 12 capture: txid server: - packet: "010100002112a442" type: hex - reuse: txidsudoku (UDP)
Section titled “sudoku (UDP)”The same options as on TCP; every datagram is encoded on its own with the classic encoding. A datagram grows 4–9×: a 1200-byte QUIC packet becomes 5–10 KiB. Mind the UDP limits of your stack.
udphop
Section titled “udphop”The client changes its socket and/or the server address at intervals. The Xray server needs nothing for the local socket to change; for the port to change, the server must accept every port of the list (usually through a DNAT rule onto one socket, as Hysteria does).
- type: udphop mode: intervalLocal # intervalLocal, intervalRemote, perConnRemote, comma-separated interval: "30-60" # seconds, at least 5 # remote-ips: ["203.0.113.0/24"] # remote-ports: ["20000-30000"]intervalLocal: a new local socket everyintervalseconds;intervalRemote: a new server address/port fromremote-ips/remote-portseveryintervalseconds;perConnRemote: the address/port is picked once per connection.
Must be the last entry of the udp list.
Datagrams travel as DNS queries and answers for a zone the server is authoritative for, through the given resolvers. The wire carries plain DNS, so the mask must be the last entry of the udp list.
- type: xdns domains: - names: ["t.example.com"] # the server's zones edns0: 4096 # required for QUIC: a 512-byte answer does not hold a packet types: [16] # 1 A, 5 CNAME, 16 TXT (default), 28 AAAA # len-limit: 255 # label-limit: 63 resolvers: - addrs: ["udp://1.1.1.1", "tcp://8.8.8.8:53"] # udp without a scheme, port 53 without a port # extra-poll: 0 # extra polls after every write, 0–3The answer’s capacity depends on the record type: with edns0: 4096 TXT holds about 3800 bytes, AAAA and CNAME about 2000, A no more than 764. Use TXT for QUIC.
The first xdns: compact spelling
Section titled “The first xdns: compact spelling”Xray-core from v26.1.31 to v26.9.9 wrote a resolver as one string: the zone, the record type and the resolver.
- type: xdns resolvers: - "t.example.com:aaaa+udp://1.1.1.1:53" # record type: txt (default), a, aaaa - "t.example.com:aaaa+udp://8.8.8.8:53"That xdns has its own wire format, which the current xdns does not read, and panels and servers of that time still use it. A mask written in this spelling gets the client of that time and works with such servers. The two spellings do not mix in one mask. Only the client side is ported.
A packet of the first xdns carries at most 223 bytes and has to fit a query name: about 120 bytes with a short zone. It is meant for mKCP with a small MTU.
Datagrams travel as ICMP echoes. It needs raw sockets or, with dgram: true, unprivileged ICMP sockets (Linux with net.ipv4.ping_group_range, macOS, iOS). On Android without root it is usually unavailable.
- type: xicmp dgram: true ips: ["203.0.113.1"] # the server's addresses; required when server is a domainVLESS over mKCP
Section titled “VLESS over mKCP”network: mkcp (or kcp) carries a VLESS proxy over mKCP, the way an Xray-core kcp inbound expects it. The framing is that of Xray-core since finalmask (v26.1.31): mKCP adds no header or authentication of its own, because its old seed and header became finalmask masks. So mkcp-opts takes no seed or header for VLESS. The udp list of finalmask goes under mKCP. Without it, mKCP talks to the server directly.
| Parameter | Default | What it does |
|---|---|---|
mtu | 1350 | Largest packet, bytes. Keep it small with xdns, see below |
tti | 50 | Send interval, ms |
uplink-capacity | 5 | Upload limit, MB/s |
downlink-capacity | 20 | Download limit, MB/s |
congestion | false | Congestion control |
write-buffer, read-buffer | 2097152 | Buffers, bytes (Xray writes them in MB) |
Example: a DNS tunnel
Section titled “Example: a DNS tunnel”mKCP packets travel as DNS queries for the server’s zone through public resolvers. server and port are written as in the Xray config: the tunnel reaches the server through the resolvers only.
proxies: - name: dns-tunnel type: vless server: 77.88.8.8 port: 53 uuid: <uuid> udp: true packet-encoding: xudp network: mkcp mkcp-opts: mtu: 77 tti: 100 congestion: false finalmask: udp: - type: xdns resolvers: - "t.example.com:aaaa+udp://77.88.8.8:53" - "t.example.com:aaaa+udp://8.8.8.8:53"A packet must fit one DNS query, so the MTU stays small: Remnawave panels set 77. Such a tunnel gives tens of KB/s, enough for messengers in text mode. The resolvers here are written in the compact spelling of the first xdns, as such panels give them.
Prizrak-Core converts Xray JSON subscriptions (the format Happ gets) and vless:// links with type=kcp, mtu, tti and fm into such a proxy by itself. VMess over mKCP keeps the mKCP before finalmask, with seed and header in mkcp-opts.
Server: vless and trojan listeners
Section titled “Server: vless and trojan listeners”The vless and trojan listeners take the same finalmask block (tcp only). The masks wrap accepted connections under TLS, REALITY, ShadowTLS, Restls or JLS with every transport. A connection that fails a mask is closed and the others go on. This way Prizrak-Core can be the server of an Xray client with finalmask, or of another Prizrak-Core.
listeners: - name: vless-in type: vless port: 443 listen: 0.0.0.0 users: - uuid: <uuid> certificate: ./server.crt private-key: ./server.key finalmask: tcp: - type: header-custom clients: [[{ packet: "GET / HTTP/1.1\r\n", type: str }, { rand: 4, capture: n }]] servers: [[{ packet: "HTTP/1.1 101\r\n", type: str }, { reuse: n }]] errors: [[{ packet: "HTTP/1.1 404 Not Found\r\n\r\n", type: str }]] - type: sudoku password: <password>Through the Remnawave host mapper
Section titled “Through the Remnawave host mapper”To deliver finalmask to Remnawave subscription users, add it to the host’s proxy with the host mapper, in the mihomo list. The fragment example from the top of the page:
{ "xrayJson": [], "mihomo": [ { "op": "set", "value": { "tcp": [ { "type": "fragment", "packets": "tlshello", "lengths": ["1-1"], "delays": ["0"] } ] }, "to": "['finalmask']" } ], "base64": []}In the Mihomo subscription the proxy gets a finalmask block with this fragment. The masks must match the finalmask of the inbound on the host’s Xray server. Clients on regular Mihomo ignore the finalmask key and connect without masks, so they cannot reach a masked server: enable finalmask only on hosts used by Prizrak-Core clients.
A kcp host (VLESS over mKCP)
Section titled “A kcp host (VLESS over mKCP)”For a host on a kcp inbound, such as an xdns DNS tunnel, the mapper builds the whole proxy from the host’s own settings: the network, mkcp-opts and finalmask.
{ "xrayJson": [], "mihomo": [ { "op": "set", "to": "network", "value": "mkcp" }, { "op": "copy", "from": "$host.transportOptions.clientMtu", "to": "mkcp-opts.mtu" }, { "op": "copy", "from": "$host.transportOptions.clientTti", "to": "mkcp-opts.tti" }, { "op": "copy", "from": "$host.transportOptions.congestion", "to": "mkcp-opts.congestion" }, { "op": "copy", "from": "$host.streamOverrides.finalMask", "to": "finalmask" }, { "op": "unset", "to": "client-fingerprint" } ], "base64": []}The proxy comes out as in the DNS tunnel example.
Limitations
Section titled “Limitations”xicmpon the Prizrak-Core side is covered by unit tests only: the server needs raw sockets.udphopin theintervalRemote/perConnRemotemodes needs a server that accepts every port of the list.xdnswithoutedns0cannot carry QUIC; type A is limited to 764 bytes per answer.- The first xdns (compact spelling) is ported for the client only; its packet carries at most 223 bytes.
- VLESS over mKCP speaks the framing of Xray-core since finalmask. Servers with mKCP
seedorheader(before v26.1.31) are not supported: themkcp-originalandmkcp-aes128gcmmasks are not ported. sudokuandxmccost noticeable CPU and traffic.- A key a mask does not know is ignored (as everywhere in mihomo); a typo in a key raises no error, the mask works without it.