z2k/files/lua/z2k-modern-core.lua
Necronicle a6c607d75f game-udp: z2k_game_udp Lua handler actually applies repeats + fix profile layout
The built-in fake() in upstream zapret-antidpi.lua calls
rawsend_payload_segmented without passing options, so `repeats=N` is silently
dropped for UDP payloads. This made every nfqws1-era Roblox recipe
(fake + blob=unknown_udp + repeats=10 + ttl=4) a no-op under nfqws2 — which
is why the existing 6 game strategies in config_official.sh "never worked"
for Evgeniy or anyone else. On top of that, each strategy referenced
blob=quic_initial_www_google_com, but the registered alias is quic_google
(the filename is just the on-disk backing), so even a working fake() would
have errored out with "blob unavailable".

z2k_game_udp mirrors zapret-antidpi.lua:rst() — deepcopy, apply_fooling,
apply_ip_id, rawsend_dissect_ipfrag(dis, desync_opts(desync)) — so repeats,
ip_ttl/ip_autottl, ipfrag, and ip_id all flow through the rawsend options
exactly like classic nfqws1. Confirmed end-to-end on a live Roblox session:
10-14 quic_google fakes per real packet, autocircular rotating across 6
strategy/cutoff combinations.

The six strategies also needed to share a single profile with the circular
action — prior revision put each --lua-desync under its own --new, so
profile matching picked the circular profile first and the strategies were
never reached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:37:19 +03:00

1214 lines
40 KiB
Lua

-- z2k-modern-core.lua
-- Core-level desync extensions for z2k:
-- 1) custom 3-fragment IP fragmenters (with optional overlap)
-- 2) TLS ClientHello extension-order morphing (fingerprint drift)
-- Seed PRNG with better entropy when available
do
local seed = os.time() or 0
local f = io.open("/dev/urandom", "rb")
if f then
local bytes = f:read(4)
f:close()
if bytes and #bytes == 4 then
seed = seed + bytes:byte(1) + bytes:byte(2) * 256 +
bytes:byte(3) * 65536 + bytes:byte(4) * 16777216
end
end
math.randomseed(seed)
end
-- Fallback stubs for nfqws2 runtime globals (prevents crash if loaded standalone)
if type(DLOG) ~= "function" then DLOG = function() end end
if type(DLOG_ERR) ~= "function" then DLOG_ERR = function() end end
local function z2k_num(v, fallback)
local n = tonumber(v)
if n == nil then return fallback end
-- Clamp to safe range for bit operations and array indexing
if n > 2147483647 then n = 2147483647 end
if n < -2147483648 then n = -2147483648 end
return n
end
local function z2k_align8(v)
local n = math.floor(z2k_num(v, 0))
if n < 0 then n = 0 end
return bitand(n, NOT7)
end
local function z2k_frag_idx(exthdr)
if exthdr then
local first_destopts
for i = 1, #exthdr do
if exthdr[i].type == IPPROTO_DSTOPTS then
first_destopts = i
break
end
end
for i = #exthdr, 1, -1 do
if exthdr[i].type == IPPROTO_HOPOPTS or
exthdr[i].type == IPPROTO_ROUTING or
(exthdr[i].type == IPPROTO_DSTOPTS and i == first_destopts) then
return i + 1
end
end
end
return 1
end
local function z2k_ipfrag3_params(dis, ipfrag_options, totalfrag)
local pos1
if dis.tcp then
pos1 = z2k_num(ipfrag_options.ipfrag_pos_tcp, 32)
elseif dis.udp then
pos1 = z2k_num(ipfrag_options.ipfrag_pos_udp, 8)
elseif dis.icmp then
pos1 = z2k_num(ipfrag_options.ipfrag_pos_icmp, 8)
else
pos1 = z2k_num(ipfrag_options.ipfrag_pos, 32)
end
local span = z2k_num(ipfrag_options.ipfrag_span, 24)
local pos2 = z2k_num(ipfrag_options.ipfrag_pos2, pos1 + span)
local ov12 = z2k_num(ipfrag_options.ipfrag_overlap12, 0)
local ov23 = z2k_num(ipfrag_options.ipfrag_overlap23, 0)
pos1 = z2k_align8(pos1)
pos2 = z2k_align8(pos2)
ov12 = z2k_align8(ov12)
ov23 = z2k_align8(ov23)
if pos1 < 8 then pos1 = 8 end
if pos2 <= pos1 then pos2 = pos1 + 8 end
if pos2 >= totalfrag then pos2 = z2k_align8(totalfrag - 8) end
if pos2 <= pos1 then return nil end
if ov12 > (pos1 - 8) then ov12 = pos1 - 8 end
if ov23 > (pos2 - 8) then ov23 = pos2 - 8 end
local off2 = pos1 - ov12
local off3 = pos2 - ov23
if off2 < 0 then off2 = 0 end
if off3 <= off2 then off3 = off2 + 8 end
if off3 >= totalfrag then off3 = z2k_align8(totalfrag - 8) end
if off3 <= off2 or off3 >= totalfrag then return nil end
local len1 = pos1
local len2 = pos2 - off2
local len3 = totalfrag - off3
if len1 <= 0 or len2 <= 0 or len3 <= 0 then return nil end
return len1, off2, len2, off3, len3
end
-- option : ipfrag_pos_tcp / ipfrag_pos_udp / ipfrag_pos_icmp / ipfrag_pos
-- option : ipfrag_pos2 - second split position (bytes, multiple of 8)
-- option : ipfrag_span - used when ipfrag_pos2 is omitted (default 24)
-- option : ipfrag_overlap12 - overlap between fragment 1 and 2 (bytes, multiple of 8)
-- option : ipfrag_overlap23 - overlap between fragment 2 and 3 (bytes, multiple of 8)
-- option : ipfrag_next2 / ipfrag_next3 - IPv6 "next" field override for fragment #2/#3
function z2k_ipfrag3(dis, ipfrag_options)
DLOG("z2k_ipfrag3")
if not dis or not (dis.ip or dis.ip6) then
return nil
end
ipfrag_options = ipfrag_options or {}
local l3 = l3_len(dis)
local plen = l3 + l4_len(dis) + #dis.payload
local totalfrag = plen - l3
if totalfrag <= 24 then
DLOG("z2k_ipfrag3: packet too short for 3 fragments")
return nil
end
local len1, off2, len2, off3, len3 = z2k_ipfrag3_params(dis, ipfrag_options, totalfrag)
if not len1 then
DLOG("z2k_ipfrag3: invalid split params")
return nil
end
if dis.ip then
local ip_id = dis.ip.ip_id == 0 and math.random(1, 0xFFFF) or dis.ip.ip_id
local d1 = deepcopy(dis)
d1.ip.ip_len = l3 + len1
d1.ip.ip_off = IP_MF
d1.ip.ip_id = ip_id
local d2 = deepcopy(dis)
d2.ip.ip_len = l3 + len2
d2.ip.ip_off = bitor(bitrshift(off2, 3), IP_MF)
d2.ip.ip_id = ip_id
local d3 = deepcopy(dis)
d3.ip.ip_len = l3 + len3
d3.ip.ip_off = bitrshift(off3, 3)
d3.ip.ip_id = ip_id
return { d1, d2, d3 }
end
if dis.ip6 then
local idxfrag = z2k_frag_idx(dis.ip6.exthdr)
local l3extra_before_frag = l3_extra_len(dis, idxfrag - 1)
local l3_local = l3_base_len(dis) + l3extra_before_frag
local totalfrag6 = plen - l3_local
if totalfrag6 <= 24 then
DLOG("z2k_ipfrag3: ipv6 packet too short for 3 fragments")
return nil
end
local p1, p2, p3, p4, p5 = z2k_ipfrag3_params(dis, ipfrag_options, totalfrag6)
if not p1 then
DLOG("z2k_ipfrag3: invalid ipv6 split params")
return nil
end
len1, off2, len2, off3, len3 = p1, p2, p3, p4, p5
local l3extra_with_frag = l3extra_before_frag + 8
local ident = math.random(1, 0xFFFFFFFF)
local d1 = deepcopy(dis)
insert_ip6_exthdr(d1.ip6, idxfrag, IPPROTO_FRAGMENT, bu16(IP6F_MORE_FRAG) .. bu32(ident))
d1.ip6.ip6_plen = l3extra_with_frag + len1
local d2 = deepcopy(dis)
insert_ip6_exthdr(d2.ip6, idxfrag, IPPROTO_FRAGMENT, bu16(bitor(off2, IP6F_MORE_FRAG)) .. bu32(ident))
if ipfrag_options.ipfrag_next2 then
d2.ip6.exthdr[idxfrag].next = tonumber(ipfrag_options.ipfrag_next2)
end
d2.ip6.ip6_plen = l3extra_with_frag + len2
local d3 = deepcopy(dis)
insert_ip6_exthdr(d3.ip6, idxfrag, IPPROTO_FRAGMENT, bu16(off3) .. bu32(ident))
if ipfrag_options.ipfrag_next3 then
d3.ip6.exthdr[idxfrag].next = tonumber(ipfrag_options.ipfrag_next3)
end
d3.ip6.ip6_plen = l3extra_with_frag + len3
return { d1, d2, d3 }
end
return nil
end
-- Tiny overlap profile for z2k_ipfrag3.
function z2k_ipfrag3_tiny(dis, ipfrag_options)
local opts = deepcopy(ipfrag_options or {})
if opts.ipfrag_overlap12 == nil then opts.ipfrag_overlap12 = 8 end
if opts.ipfrag_overlap23 == nil then opts.ipfrag_overlap23 = 8 end
if opts.ipfrag_pos2 == nil then
local p1
if dis.tcp then
p1 = z2k_num(opts.ipfrag_pos_tcp, 32)
elseif dis.udp then
p1 = z2k_num(opts.ipfrag_pos_udp, 8)
elseif dis.icmp then
p1 = z2k_num(opts.ipfrag_pos_icmp, 8)
else
p1 = z2k_num(opts.ipfrag_pos, 32)
end
opts.ipfrag_pos2 = p1 + 24
end
return z2k_ipfrag3(dis, opts)
end
local function z2k_tls_ext_is_fixed(ext)
if not ext or ext.type == nil then return true end
if TLS_EXT_SERVER_NAME and ext.type == TLS_EXT_SERVER_NAME then return true end
if TLS_EXT_PRE_SHARED_KEY and ext.type == TLS_EXT_PRE_SHARED_KEY then return true end
return false
end
local function z2k_shuffle(tbl)
for i = #tbl, 2, -1 do
local j = math.random(i)
tbl[i], tbl[j] = tbl[j], tbl[i]
end
end
local function z2k_shuffle_range(tbl, i1, i2)
local a = tonumber(i1) or 1
local b = tonumber(i2) or #tbl
if a < 1 then a = 1 end
if b > #tbl then b = #tbl end
if a >= b then
return
end
for i = b, a + 1, -1 do
local j = math.random(a, i)
tbl[i], tbl[j] = tbl[j], tbl[i]
end
end
local function z2k_clamp(v, lo, hi, fallback)
local n = tonumber(v)
if n == nil then n = fallback end
if n < lo then n = lo end
if n > hi then n = hi end
return n
end
local function z2k_rand_between(a, b)
local x = tonumber(a) or 0
local y = tonumber(b) or x
if y < x then
x, y = y, x
end
return math.random(x, y)
end
local function z2k_payload_pad(payload, pad_min, pad_max)
local p = payload or ""
local n = z2k_rand_between(pad_min, pad_max)
if n <= 0 then
return p
end
return p .. string.rep("\0", n)
end
local z2k_unpack = table.unpack or unpack
local function z2k_quic_reserved_version_bytes()
-- RFC-reserved grease-like pattern: 0x?a?a?a?a
local b1 = bitor(bitlshift(math.random(0, 15), 4), 0x0A)
local b2 = bitor(bitlshift(math.random(0, 15), 4), 0x0A)
local b3 = bitor(bitlshift(math.random(0, 15), 4), 0x0A)
local b4 = bitor(bitlshift(math.random(0, 15), 4), 0x0A)
return b1, b2, b3, b4
end
local function z2k_qvarint_decode_bytes(bytes, pos, nbytes)
if type(bytes) ~= "table" then
return nil, nil
end
local b0 = bytes[pos]
if not b0 then
return nil, nil
end
local pref = bitrshift(b0, 6)
local len = 1
if pref == 1 then
len = 2
elseif pref == 2 then
len = 4
elseif pref == 3 then
len = 8
end
if (pos + len - 1) > (nbytes or #bytes) then
return nil, nil
end
local v = bitand(b0, 0x3F)
for i = 2, len do
v = (v * 256) + (bytes[pos + i - 1] or 0)
end
return v, len
end
local function z2k_qvarint_encode_bytes(value, force_len)
local v = tonumber(value) or 0
if v < 0 then v = 0 end
local len = tonumber(force_len)
if not len then
if v < 64 then
len = 1
elseif v < 16384 then
len = 2
elseif v < 1073741824 then
len = 4
else
len = 8
end
end
if len ~= 1 and len ~= 2 and len ~= 4 and len ~= 8 then
return nil, nil
end
local maxv = 63
if len == 2 then
maxv = 16383
elseif len == 4 then
maxv = 1073741823
elseif len == 8 then
maxv = 4611686018427387903
end
if v > maxv then v = maxv end
local out = {}
for i = len, 1, -1 do
out[i] = v % 256
v = math.floor(v / 256)
end
local pref = 0
if len == 2 then
pref = bitlshift(1, 6)
elseif len == 4 then
pref = bitlshift(2, 6)
elseif len == 8 then
pref = bitlshift(3, 6)
end
out[1] = bitor(out[1] or 0, pref)
return out, len
end
local function z2k_quic_randomize_range(bytes, pos, count)
if not bytes or not pos or not count or count <= 0 then
return
end
local n = #bytes
local p = tonumber(pos) or 1
local c = tonumber(count) or 0
if p < 1 then p = 1 end
if p > n then return end
local pend = p + c - 1
if pend > n then pend = n end
for i = p, pend do
bytes[i] = math.random(0, 255)
end
end
local function z2k_quic_morph_payload(payload, arg)
if type(payload) ~= "string" then
return payload
end
local n = #payload
if n < 12 then
return payload
end
local b = { string.byte(payload, 1, n) }
local h1 = b[1]
-- Only long-header QUIC packets are handled here.
if not h1 or bitand(h1, 0x80) == 0 then
return payload
end
local version_chance = z2k_clamp(arg.version_chance, 0, 100, 35)
local cid_chance = z2k_clamp(arg.cid_chance, 0, 100, 80)
local token_chance = z2k_clamp(arg.token_chance, 0, 100, 60)
local token_fill_chance = z2k_clamp(arg.token_fill_chance, 0, 100, 35)
local token_fill_len = z2k_clamp(arg.token_fill_len, 1, 8, 1)
if version_chance > 0 and math.random(100) <= version_chance and n >= 5 then
local v1, v2, v3, v4 = z2k_quic_reserved_version_bytes()
b[2], b[3], b[4], b[5] = v1, v2, v3, v4
end
local pos = 6
if pos > #b then
return string.char(z2k_unpack(b))
end
local dcid_len = b[pos] or 0
pos = pos + 1
if dcid_len < 0 then dcid_len = 0 end
if (pos + dcid_len - 1) > #b then
return string.char(z2k_unpack(b))
end
if dcid_len > 0 and cid_chance > 0 and math.random(100) <= cid_chance then
z2k_quic_randomize_range(b, pos, dcid_len)
end
pos = pos + dcid_len
if pos > #b then
return string.char(z2k_unpack(b))
end
local scid_len = b[pos] or 0
pos = pos + 1
if scid_len < 0 then scid_len = 0 end
if (pos + scid_len - 1) > #b then
return string.char(z2k_unpack(b))
end
if scid_len > 0 and cid_chance > 0 and math.random(100) <= cid_chance then
z2k_quic_randomize_range(b, pos, scid_len)
end
pos = pos + scid_len
if pos > #b then
return string.char(z2k_unpack(b))
end
local token_len, token_vlen = z2k_qvarint_decode_bytes(b, pos, #b)
if token_len == nil then
return string.char(z2k_unpack(b))
end
local token_pos = pos + token_vlen
local token_end = token_pos + token_len - 1
if token_len > 0 then
if token_end <= #b and token_chance > 0 and math.random(100) <= token_chance then
z2k_quic_randomize_range(b, token_pos, token_len)
end
elseif token_fill_chance > 0 and math.random(100) <= token_fill_chance then
-- Token fill for empty-token Initial packets.
-- Conservative path: only 1-byte token length varint is expanded.
if token_vlen == 1 and token_fill_len < 64 then
local enc, enc_len = z2k_qvarint_encode_bytes(token_fill_len, 1)
if enc and enc_len == 1 then
b[pos] = enc[1]
for i = 1, token_fill_len do
table.insert(b, token_pos + i - 1, math.random(0, 255))
end
-- Token expanded; QUIC Initial Length field does NOT need adjustment
-- because it only covers Packet Number and Payload lengths.
end
end
end
return string.char(z2k_unpack(b))
end
local function z2k_rawsend_ctx(desync, repeats)
local arg = desync and desync.arg or {}
return {
repeats = repeats or 1,
ifout = arg.ifout or desync.ifout,
fwmark = arg.fwmark or desync.fwmark
}
end
local function z2k_timing_state(desync)
if not desync or not desync.track then
return nil, nil
end
local st = desync.track.lua_state
if type(st) ~= "table" then
return nil, nil
end
local key = "__z2k_tm_" .. tostring(desync.func_instance or "z2k_timing_morph")
local rec = st[key]
if type(rec) ~= "table" then
rec = {
out_seen = 0,
drops = 0,
dropped_seq = {}
}
st[key] = rec
end
return st, rec
end
local function z2k_overlap_state(desync)
if not desync or not desync.track then
return nil
end
local st = desync.track.lua_state
if type(st) ~= "table" then
return nil
end
local key = "__z2k_ov3_" .. tostring(desync.func_instance or "z2k_tcpoverlap3")
local rec = st[key]
if type(rec) ~= "table" then
rec = { out_seen = 0 }
st[key] = rec
end
return rec
end
local function z2k_quic_state(desync)
if not desync or not desync.track then
return nil
end
local st = desync.track.lua_state
if type(st) ~= "table" then
return nil
end
local key = "__z2k_qmv2_" .. tostring(desync.func_instance or "z2k_quic_morph_v2")
local rec = st[key]
if type(rec) ~= "table" then
rec = { out_seen = 0, profile = 0 }
st[key] = rec
end
return rec
end
local function z2k_parse_order3(s)
local order = { 3, 2, 1 }
if s == nil or s == "" then
return order
end
local out = {}
local seen = {}
for part in tostring(s):gmatch("[^,]+") do
local n = tonumber(part)
if n and n >= 1 and n <= 3 and not seen[n] then
table.insert(out, n)
seen[n] = true
end
end
if #out ~= 3 then
return order
end
return out
end
local function z2k_resolve_marker_pos(payload, l7payload, marker, fallback)
if marker == nil or marker == "" then
return fallback
end
local n = tonumber(marker)
if n ~= nil then
return n
end
local ok, v = pcall(resolve_pos, payload, l7payload, marker, false)
if ok and v then
return tonumber(v) or fallback
end
return fallback
end
-- Reorder non-critical TLS ClientHello extensions in-place.
-- Intended to blur stable JA3/JA4-style extension-order fingerprints.
function z2k_tls_extshuffle(ctx, desync)
if not desync or not desync.dis or not desync.dis.tcp then
if desync and desync.dis and not desync.dis.icmp then
instance_cutoff_shim(ctx, desync)
end
return
end
direction_cutoff_opposite(ctx, desync, "out")
if not direction_check(desync, "out") then return end
if not payload_check(desync, "tls_client_hello") then return end
local tdis = tls_dissect(desync.dis.payload)
if not tdis or not tdis.handshake or not tdis.handshake[TLS_HANDSHAKE_TYPE_CLIENT] then
return
end
local ch = tdis.handshake[TLS_HANDSHAKE_TYPE_CLIENT].dis
if not ch or type(ch.ext) ~= "table" or #ch.ext < 4 then
return
end
local movable_idx = {}
local movable_ext = {}
for i = 1, #ch.ext do
if not z2k_tls_ext_is_fixed(ch.ext[i]) then
table.insert(movable_idx, i)
table.insert(movable_ext, ch.ext[i])
end
end
if #movable_ext < 2 then
return
end
z2k_shuffle(movable_ext)
for i = 1, #movable_idx do
ch.ext[movable_idx[i]] = movable_ext[i]
end
local tls_new = tls_reconstruct(tdis)
if not tls_new then
DLOG_ERR("z2k_tls_extshuffle: reconstruct error")
return
end
desync.dis.payload = tls_new
return VERDICT_MODIFY
end
-- TLS fingerprint morph pack v2.
-- Combines extension-order shuffle with bounded cipher/group/alpn permutation.
-- Goal: increase JA3/JA4 drift while preserving compatibility guardrails.
--
-- args:
-- dir=out
-- payload=tls_client_hello
-- cs_keep_head=3 ; keep first N cipher suites fixed
-- groups_keep_head=1 ; keep first N supported groups fixed
-- alpn_chance=50 ; chance (%) to shuffle ALPN order
-- pad_min=0 pad_max=0 ; add TLS Padding extension (type 21) with random length
function z2k_tls_fp_pack_v2(ctx, desync)
if not desync or not desync.dis or not desync.dis.tcp then
return
end
direction_cutoff_opposite(ctx, desync, "out")
if not direction_check(desync, "out") then return end
if not payload_check(desync, "tls_client_hello") then return end
local arg = desync.arg or {}
local tdis = tls_dissect(desync.dis.payload)
if not tdis or not tdis.handshake or not tdis.handshake[TLS_HANDSHAKE_TYPE_CLIENT] then
return
end
local ch = tdis.handshake[TLS_HANDSHAKE_TYPE_CLIENT].dis
if not ch then
return
end
local changed = false
local pad_min = z2k_clamp(arg.pad_min, 0, 2000, 0)
local pad_max = z2k_clamp(arg.pad_max, 0, 2000, 0)
local pad_len = z2k_rand_between(pad_min, pad_max)
if type(ch.ext) == "table" and pad_len > 0 then
-- Add TLS Padding extension (type 21)
table.insert(ch.ext, {
type = 21,
len = pad_len,
data = string.rep("\0", pad_len)
})
changed = true
end
if type(ch.ext) == "table" and #ch.ext >= 4 then
local movable_idx = {}
local movable_ext = {}
for i = 1, #ch.ext do
if not z2k_tls_ext_is_fixed(ch.ext[i]) then
table.insert(movable_idx, i)
table.insert(movable_ext, ch.ext[i])
end
end
if #movable_ext >= 2 then
z2k_shuffle(movable_ext)
for i = 1, #movable_idx do
ch.ext[movable_idx[i]] = movable_ext[i]
end
changed = true
end
end
if type(ch.cipher_suites) == "table" and #ch.cipher_suites >= 6 then
local keep = z2k_clamp(arg.cs_keep_head, 0, #ch.cipher_suites - 2, 3)
z2k_shuffle_range(ch.cipher_suites, keep + 1, #ch.cipher_suites)
changed = true
end
if type(ch.ext) == "table" then
local t_alpn = TLS_EXT_ALPN or 16
local t_groups = TLS_EXT_SUPPORTED_GROUPS or 10
local alpn_chance = z2k_clamp(arg.alpn_chance, 0, 100, 50)
for i = 1, #ch.ext do
local e = ch.ext[i]
if e and e.type == t_groups and e.dis and type(e.dis.list) == "table" and #e.dis.list >= 3 then
local keep_g = z2k_clamp(arg.groups_keep_head, 0, #e.dis.list - 2, 1)
z2k_shuffle_range(e.dis.list, keep_g + 1, #e.dis.list)
changed = true
elseif e and e.type == t_alpn and e.dis and type(e.dis.list) == "table" and #e.dis.list >= 2 then
if math.random(100) <= alpn_chance then
z2k_shuffle(e.dis.list)
changed = true
end
end
end
end
if not changed then
return
end
local tls_new = tls_reconstruct(tdis)
if not tls_new then
DLOG_ERR("z2k_tls_fp_pack_v2: reconstruct error")
return
end
desync.dis.payload = tls_new
return VERDICT_MODIFY
end
-- Timing/size/burst morphing for first handshake packets.
-- Adds controlled checksum-broken fakes to blur packet-size/burst signatures.
-- Optional guarded drop mode can force single retransmission jitter on TCP.
--
-- args:
-- dir=out (default)
-- payload=tls_client_hello,quic_initial,http_req (default)
-- packets=2 ; max packets to process in flow direction
-- chance=70 ; probability (%) to emit fake burst
-- fakes=1 ; number of fakes per packet (1..3)
-- pad_min=8 pad_max=48 ; fake payload padding bytes
-- drop_chance=0 ; probability (%) to drop original packet (TCP only)
-- drop_budget=1 ; max guarded drops per flow
-- seq_left=2048 seq_step=128 ; TCP fake left-shifted seq offset
function z2k_timing_morph(ctx, desync)
if not desync or not desync.dis then
return
end
if not (desync.dis.tcp or desync.dis.udp) then
return
end
direction_cutoff_opposite(ctx, desync, "out")
if not direction_check(desync, "out") then
return
end
if not payload_check(desync, "tls_client_hello,quic_initial,http_req") then
return
end
local arg = desync.arg or {}
local max_packets = z2k_clamp(arg.packets, 1, 16, 2)
local chance = z2k_clamp(arg.chance, 0, 100, 70)
local fake_count = z2k_clamp(arg.fakes, 1, 3, 1)
local pad_min = z2k_clamp(arg.pad_min, 0, 512, 8)
local pad_max = z2k_clamp(arg.pad_max, 0, 1024, 48)
local drop_chance = z2k_clamp(arg.drop_chance, 0, 100, 0)
local drop_budget = z2k_clamp(arg.drop_budget, 0, 4, 1)
local seq_left = z2k_clamp(arg.seq_left, 0, 262144, 2048)
local seq_step = z2k_clamp(arg.seq_step, 0, 16384, 128)
local _, rec = z2k_timing_state(desync)
if not rec then
return
end
rec.out_seen = (tonumber(rec.out_seen) or 0) + 1
if rec.out_seen > max_packets then
instance_cutoff_shim(ctx, desync, true)
return
end
if chance > 0 and math.random(100) <= chance then
local rs = z2k_rawsend_ctx(desync, 1)
local base_payload = desync.dis.payload or ""
if desync.dis.tcp then
for i = 1, fake_count do
local fake_payload = z2k_payload_pad(base_payload, pad_min, pad_max)
local seq_off = -seq_left - ((i - 1) * seq_step)
rawsend_payload_segmented(desync, fake_payload, seq_off, {
rawsend = rs,
reconstruct = { badsum = true },
fooling = { tcp_ts_up = arg.tcp_ts_up }
})
end
elseif desync.dis.udp then
for i = 1, fake_count do
local d = deepcopy(desync.dis)
d.payload = z2k_payload_pad(base_payload, pad_min, pad_max)
rawsend_dissect(d, rs, { badsum = true })
end
end
end
if drop_chance > 0 and drop_budget > 0 and desync.dis.tcp and not desync.replay then
local seq = desync.dis.tcp and tonumber(desync.dis.tcp.th_seq)
if seq and not rec.dropped_seq[seq] and rec.drops < drop_budget and math.random(100) <= drop_chance then
rec.drops = rec.drops + 1
rec.dropped_seq[seq] = true
DLOG("z2k_timing_morph: guarded drop seq=" .. tostring(seq))
return VERDICT_DROP
end
end
end
-- Advanced TCP overlap/reorder primitive.
-- Sends 3 overlapping pieces with custom send order, then drops original packet.
--
-- args:
-- dir=out
-- payload=tls_client_hello,http_req
-- packets=2
-- pos1=midsld|<num> ; first split point (1-based)
-- pos2=sld+1|<num> ; second split point (1-based)
-- span=24 ; used when pos2 is missing
-- ov12=8 ov23=8 ; overlap size in bytes
-- order=3,2,1 ; send order for parts
-- nodrop ; keep original packet (debug/fallback)
function z2k_tcpoverlap3(ctx, desync)
if not desync or not desync.dis or not desync.dis.tcp then
return
end
direction_cutoff_opposite(ctx, desync, "out")
if not direction_check(desync, "out") then
return
end
if not payload_check(desync, "tls_client_hello,http_req") then
return
end
if replay_drop(desync) then
return VERDICT_DROP
end
if not replay_first(desync) then
return
end
local arg = desync.arg or {}
local rec = z2k_overlap_state(desync)
if not rec then
return
end
local max_packets = z2k_clamp(arg.packets, 1, 16, 2)
rec.out_seen = (tonumber(rec.out_seen) or 0) + 1
if rec.out_seen > max_packets then
instance_cutoff_shim(ctx, desync, true)
return
end
local payload = desync.reasm_data or desync.dis.payload or ""
local plen = #payload
if plen < 12 then
return
end
local p1_def = math.floor(plen / 3)
if p1_def < 2 then p1_def = 2 end
local p2_def = p1_def + z2k_clamp(arg.span, 8, 4096, 24)
local p1 = z2k_resolve_marker_pos(payload, desync.l7payload, arg.pos1, p1_def)
local p2 = z2k_resolve_marker_pos(payload, desync.l7payload, arg.pos2, p2_def)
p1 = z2k_clamp(p1, 2, plen - 2, p1_def)
p2 = z2k_clamp(p2, p1 + 1, plen - 1, p2_def)
local ov12 = z2k_clamp(arg.ov12, 0, p1, 8)
local ov23 = z2k_clamp(arg.ov23, 0, p2, 8)
local off2 = p1 - ov12
local off3 = p2 - ov23
if off2 < 0 then off2 = 0 end
if off3 <= off2 then off3 = off2 + 1 end
if off3 >= plen then off3 = plen - 1 end
if off3 <= off2 then
return
end
local seg1 = payload:sub(1, p1)
local seg2 = payload:sub(off2 + 1, p2)
local seg3 = payload:sub(off3 + 1, plen)
if seg1 == "" or seg2 == "" or seg3 == "" then
return
end
local segs = { seg1, seg2, seg3 }
local seqs = { 0, off2, off3 }
local order = z2k_parse_order3(arg.order)
local rs = z2k_rawsend_ctx(desync, 1)
local ok_send = true
for i = 1, #order do
local idx = order[i]
local ok = pcall(rawsend_payload_segmented, desync, segs[idx], seqs[idx], {
rawsend = rs
})
if not ok then
ok_send = false
break
end
end
if not ok_send then
replay_drop_set(desync, false)
return
end
local nodrop = arg.nodrop ~= nil
replay_drop_set(desync, not nodrop)
if not nodrop then
return VERDICT_DROP
end
end
-- QUIC Initial morphing profile pack.
-- Chooses one profile per-flow (or forced profile) and applies a fragment-order
-- variant plus checksum-broken burst noise.
--
-- args:
-- dir=out
-- payload=quic_initial
-- packets=2
-- profile=1|2|3 ; optional forced profile
-- noise=1..3 ; number of badsum fake packets
-- pad_min=8 pad_max=64 ; extra bytes in fake noise payloads
-- version_chance=35 ; chance (%) to spoof QUIC version in fakes
-- cid_chance=80 ; chance (%) to randomize CID bytes in fakes
-- token_chance=60 ; chance (%) to randomize non-empty token bytes
-- token_fill_chance=35 ; chance (%) to fill empty token in fakes
-- token_fill_len=1 ; inserted token size for empty-token fill
-- live_chance=0 ; optional chance (%) to morph live outgoing packet
-- nodrop ; keep original packet
function z2k_quic_morph_v2(ctx, desync)
if not desync or not desync.dis or not desync.dis.udp then
return
end
direction_cutoff_opposite(ctx, desync, "out")
if not direction_check(desync, "out") then
return
end
if not payload_check(desync, "quic_initial") then
return
end
local arg = desync.arg or {}
local rec = z2k_quic_state(desync)
if not rec then
return
end
local max_packets = z2k_clamp(arg.packets, 1, 16, 2)
rec.out_seen = (tonumber(rec.out_seen) or 0) + 1
if rec.out_seen > max_packets then
instance_cutoff_shim(ctx, desync, true)
return
end
local profile_forced = tonumber(arg.profile)
local profile = profile_forced
if not profile or profile < 1 or profile > 3 then
if rec.profile == 0 then
rec.profile = math.random(1, 3)
end
profile = rec.profile
end
local noise = z2k_clamp(arg.noise, 0, 3, 1)
local pad_min = z2k_clamp(arg.pad_min, 0, 512, 8)
local pad_max = z2k_clamp(arg.pad_max, 0, 1024, 64)
local live_chance = z2k_clamp(arg.live_chance, 0, 100, 0)
local rs = z2k_rawsend_ctx(desync, 1)
local base_payload = desync.dis.payload or ""
if noise > 0 then
for i = 1, noise do
local fake = deepcopy(desync.dis)
fake.payload = z2k_payload_pad(base_payload, pad_min, pad_max)
fake.payload = z2k_quic_morph_payload(fake.payload, arg)
rawsend_dissect(fake, rs, { badsum = true })
end
end
local out_dis = deepcopy(desync.dis)
if live_chance > 0 and math.random(100) <= live_chance then
out_dis.payload = z2k_quic_morph_payload(out_dis.payload, arg)
end
local ipfrag = nil
if profile == 1 then
ipfrag = {
ipfrag = "z2k_ipfrag3_tiny",
ipfrag_pos_udp = z2k_align8(z2k_clamp(arg.ipfrag_pos_udp, 8, 1024, 8)),
ipfrag_pos2 = z2k_align8(z2k_clamp(arg.ipfrag_pos2, 16, 4096, 32)),
ipfrag_overlap12 = z2k_align8(z2k_clamp(arg.ipfrag_overlap12, 0, 512, 8)),
ipfrag_overlap23 = z2k_align8(z2k_clamp(arg.ipfrag_overlap23, 0, 512, 8)),
ipfrag_disorder = true,
ipfrag_next2 = tonumber(arg.ipfrag_next2) or 255
}
elseif profile == 2 then
ipfrag = {
ipfrag = "z2k_ipfrag3",
ipfrag_pos_udp = z2k_align8(z2k_clamp(arg.ipfrag_pos_udp, 8, 1024, 16)),
ipfrag_pos2 = z2k_align8(z2k_clamp(arg.ipfrag_pos2, 24, 4096, 56)),
ipfrag_overlap12 = z2k_align8(z2k_clamp(arg.ipfrag_overlap12, 0, 512, 16)),
ipfrag_overlap23 = z2k_align8(z2k_clamp(arg.ipfrag_overlap23, 0, 512, 8)),
ipfrag_disorder = true,
ipfrag_next2 = tonumber(arg.ipfrag_next2) or 0
}
else
ipfrag = {
ipfrag_pos_udp = z2k_align8(z2k_clamp(arg.ipfrag_pos_udp, 8, 1024, 16)),
ipfrag_disorder = true,
ipfrag_next = tonumber(arg.ipfrag_next) or 255
}
end
local ok = pcall(rawsend_dissect_ipfrag, out_dis, {
rawsend = rs,
ipfrag = ipfrag
})
if not ok then
return
end
if arg.nodrop == nil then
return VERDICT_DROP
end
end
-- ==============================================================================
-- ECH (Encrypted Client Hello) detection and preference
-- ==============================================================================
-- TLS extension type for ECH (RFC 9460, type 0xfe0d = 65037)
local TLS_EXT_ECH = 65037
-- TLS extension type for ECH outer (type 0xfe0e = 65038)
local TLS_EXT_ECH_OUTER = 65038
-- Detect if a TLS ClientHello contains ECH extension.
-- If ECH is present, DPI cannot see the real SNI — desync may be unnecessary.
-- Returns: true if ECH detected, false otherwise
local function z2k_detect_ech(desync)
if not desync or not desync.dis then return false end
local payload = desync.dis.payload
if type(payload) ~= "string" or #payload < 10 then return false end
-- Quick check: TLS record type 0x16 (handshake)
if payload:byte(1) ~= 0x16 then return false end
-- Need tls_dissect from nfqws2 runtime
if type(tls_dissect) ~= "function" then return false end
local ok, tdis = pcall(tls_dissect, payload)
if not ok or not tdis then return false end
local hs = tdis.handshake
if not hs then return false end
local ch_type = TLS_HANDSHAKE_TYPE_CLIENT
if not ch_type then ch_type = 1 end
local ch = hs[ch_type]
if not ch or not ch.dis or type(ch.dis.ext) ~= "table" then return false end
for _, ext in ipairs(ch.dis.ext) do
if ext.type == TLS_EXT_ECH or ext.type == TLS_EXT_ECH_OUTER then
return true
end
end
return false
end
-- ECH-aware desync action: skip desync if ECH is detected.
-- Use as: --lua-desync=z2k_ech_passthrough:payload=tls_client_hello:dir=out
-- When ECH is detected, passes the packet through unmodified.
-- When ECH is NOT detected, falls through to next desync action.
function z2k_ech_passthrough(ctx, desync)
if not desync or not desync.dis then return end
direction_cutoff_opposite(ctx, desync, "out")
if not direction_check(desync, "out") then return end
if not payload_check(desync, "tls_client_hello") then return end
if z2k_detect_ech(desync) then
DLOG("z2k_ech_passthrough: ECH detected, skipping desync")
-- Cut off all remaining desync instances for this packet
instance_cutoff_shim(ctx, desync, true)
return
end
-- No ECH — fall through to next desync action
end
-- Strategy profiling: track per-strategy success rate and latency
-- for automated strategy quality scoring.
-- Data stored in telemetry.tsv via z2k-autocircular.lua integration.
function z2k_strategy_profile(ctx, desync)
if not desync then return end
local arg = desync.arg or {}
local profile_key = arg.key or "default"
local strategy = tonumber(arg.strategy)
if not strategy then return end
-- Record start time for latency measurement
local st = desync.track and desync.track.lua_state
if type(st) ~= "table" then return end
local pkey = "__z2k_profile_" .. tostring(profile_key)
if not st[pkey] then
st[pkey] = { t0 = 0, strategy = strategy }
end
if desync.outgoing then
local p = desync.l7payload
if p == "tls_client_hello" or p == "quic_initial" or p == "http_req" then
if st[pkey].t0 == 0 then
st[pkey].t0 = (type(clock_getfloattime) == "function" and clock_getfloattime()) or os.time() or 0
st[pkey].strategy = strategy
end
end
end
end
-- ---------------------------------------------------------------------------
-- z2k_game_udp: UDP fake-injection desync for game/unknown protocols.
-- ---------------------------------------------------------------------------
-- Problem this solves
-- nfqws2's built-in `fake` action (zapret-antidpi.lua:fake) calls
-- rawsend_payload_segmented(desync, fake_payload) WITHOUT options and without
-- apply_fooling(), so BOTH `ip_ttl` AND `repeats` are silently dropped for
-- UDP fakes. That made it impossible to replicate the classic nfqws1 recipe
--
-- --dpi-desync=fake
-- --dpi-desync-any-protocol=1
-- --dpi-desync-fake-unknown-udp=<blob>
-- --dpi-desync-ttl=4
-- --dpi-desync-repeats=10
--
-- which works reliably for Roblox and other low-latency UDP game protocols
-- behind Russian DPI on Keenetic.
--
-- What this handler does
-- For each outgoing UDP datagram that matches the payload filter:
-- 1. deepcopy the current dissect
-- 2. replace the L7 payload with the configured blob
-- 3. apply_fooling(..) — honours ip_ttl / ip6_ttl / ip_autottl / tcp_*
-- 4. apply_ip_id(..) — keeps ip_id sane
-- 5. rawsend_dissect_ipfrag with desync_opts(desync) so `repeats=N` from
-- the command line actually takes effect
-- The original packet is kept (no drop), matching nfqws1's fake behaviour
-- where the real packet is allowed through after the fakes.
--
-- Args (all optional unless noted)
-- blob=<name> REQUIRED. fake payload blob (e.g. quic_initial_www_google_com)
-- ip_ttl=<int> IPv4 TTL for the fake packet (e.g. 4)
-- ip6_ttl=<int> IPv6 hop limit for the fake packet
-- ip_autottl=<spec> auto-derived TTL (see zapret-lib parse_autottl)
-- repeats=<int> how many copies of the fake to emit per real packet
-- dir=out|in|any direction filter (default: out)
-- payload=<list> comma-separated l7 filter (default: all)
-- optional skip silently if blob is missing
-- badsum send with deliberately bad L4 checksum (DPI fooling)
--
-- Usage example (place in /opt/zapret2/init.d/<platform>/custom.d/)
-- NFQWS_OPT_DESYNC_GAME="--filter-udp=1024-65535 ${GAME_IPSET_OPT}\
-- --in-range=a --out-range=-n2 --payload=all \
-- --lua-desync=z2k_game_udp:dir=out:blob=quic_initial_www_google_com:ip_ttl=4:repeats=10"
--
-- Mirrors nfqws1 cutoff=n2 via --out-range=-n2 at the wrapper level, and
-- repeats=10 / ip_ttl=4 / blob=... via the handler args.
function z2k_game_udp(ctx, desync)
-- Always fire on outgoing side by default; cut off opposite direction so
-- the instance doesn't waste cycles on inbound replies.
direction_cutoff_opposite(ctx, desync)
-- Only UDP. For related icmp packets (e.g. ICMP unreachable) pass through
-- without cutting off the instance, mirroring fake()/rst() in zapret-antidpi.
if not desync.dis.udp then
if not desync.dis.icmp then instance_cutoff_shim(ctx, desync) end
return
end
if not (direction_check(desync) and payload_check(desync, "all")) then
return
end
-- Only emit fakes on the first replay pass (mirrors built-in fake).
if not replay_first(desync) then
DLOG("z2k_game_udp: not acting on further replay pieces")
return
end
if not desync.arg.blob then
error("z2k_game_udp: 'blob' arg required")
end
if desync.arg.optional and not blob_exist(desync, desync.arg.blob) then
DLOG("z2k_game_udp: blob '"..desync.arg.blob.."' not found. skipped")
return
end
local fake_payload = blob(desync, desync.arg.blob)
if b_debug then
DLOG("z2k_game_udp: blob="..desync.arg.blob.." ttl="..tostring(desync.arg.ip_ttl).." repeats="..tostring(desync.arg.repeats))
end
-- Build the fake packet from the current dissect. deepcopy so we don't
-- perturb the real packet the kernel will deliver afterwards.
local dis = deepcopy(desync.dis)
dis.payload = fake_payload
-- Apply ip_ttl / ip_autottl / ip6_ttl / badsum etc. Crucially, this is
-- where ip_ttl actually gets written to dis.ip.ip_ttl — the built-in
-- fake() skips this step, which is the whole reason we exist.
apply_fooling(desync, dis)
apply_ip_id(desync, dis, nil, "none")
-- rawsend_dissect_ipfrag honours options.rawsend.repeats, so supplying
-- the full desync_opts bundle makes `repeats=N` on the command line
-- emit N copies per real packet.
rawsend_dissect_ipfrag(dis, desync_opts(desync))
end