Skip to content

👻 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:

TypeTCPUDPWhat 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.


  • On vless and trojan proxies 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 (including download-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 finalmask block 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.


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 of length/delay is not included, as in Xray.
Xray (JSON)Prizrak-Core (YAML)
randRangerand-range
maxSplitmax-split
customTable, customTablescustom-table, custom-tables
paddingMin, paddingMaxpadding-min, padding-max
texturesValue, texturesSignaturetextures-value, textures-signature
remoteIPs, remotePortsremote-ips, remote-ports
lenLimit, labelLimit, extraPolllen-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.

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"]

Cuts the first writes of the connection. The implementation is shared with freedom, so the behavior is the same.

ParameterValuesWhat it does
packetstlshello, "N-M" or emptytlshello 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", bytesSize of every piece
delay"N" or "N-M", msPause after every piece. With tlshello and 0 all records go out in one write
lengthslist of rangesSize of piece i; the last entry serves every later piece
delayslist of rangesPause 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

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: str

Every item of a sequence is exactly one of:

FieldWhat it does
packet + typeFixed bytes. type: str (as is), hex, base64, or a list of numbers 0–255 without type
rand + rand-rangerand random bytes within rand-range (0-255 by default)
reuseA value captured earlier with capture
transformComputed 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: ...}.

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.

ParameterWhat it does
passwordSeeds the permutation table; the same on both sides
asciiprefer_entropy (default): bytes look random; prefer_ascii: printable ASCII
custom-tableAn 8-letter bit layout: exactly 2 x, 2 p and 4 v, for example xpxvvpvv (entropy mode only)
custom-tablesSeveral layouts, rotated
padding-min, padding-maxChance (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.


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.

The same items as on TCP, in two modes:

  • mode: prefix (default): the client header goes in front of every client datagram, server in 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: txid

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.

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 every interval seconds;
  • intervalRemote: a new server address/port from remote-ips / remote-ports every interval seconds;
  • 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–3

The 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.

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 domain

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.

ParameterDefaultWhat it does
mtu1350Largest packet, bytes. Keep it small with xdns, see below
tti50Send interval, ms
uplink-capacity5Upload limit, MB/s
downlink-capacity20Download limit, MB/s
congestionfalseCongestion control
write-buffer, read-buffer2097152Buffers, bytes (Xray writes them in MB)

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.


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>

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.

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.


  • xicmp on the Prizrak-Core side is covered by unit tests only: the server needs raw sockets.
  • udphop in the intervalRemote / perConnRemote modes needs a server that accepts every port of the list.
  • xdns without edns0 cannot 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 seed or header (before v26.1.31) are not supported: the mkcp-original and mkcp-aes128gcm masks are not ported.
  • sudoku and xmc cost 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.