-- The NetBridge client your loadstring runs. The served copy differs in one line: the relay address, with your room code in it. -- Other files: ?file=rakspy ?file=prelude ?file=tree ?file=decompile ?file=decompile_one ?file=inspect ?file=scanner -- Privacy and security: /docs/netbridge/getting-started/privacy --[[ The loadstring passes the session key as this chunk's argument: loadstring(game:HttpGet(".../netbridge/client.lua?room=..."))("") Read here, at the top of the chunk, the one place its varargs are. ]] local LAUNCH_ARGS = table.pack(...) if not game:IsLoaded() then game.Loaded:Wait() end -- What the page checks before it sends anything (lib/netbridge/auth.ts). local PROTOCOL_VERSION = 2 local CLIENT_VERSION = "2.3.1" do local g = getgenv() g.__BridgeGen = (g.__BridgeGen or 0) + 1 end local MY_GEN = getgenv().__BridgeGen local function isCurrent() return getgenv().__BridgeGen == MY_GEN end local typeof, tostring, pcall, xpcall = typeof, tostring, pcall, xpcall local select, setmetatable, rawset = select, setmetatable, rawset local concat, pack = table.concat, table.pack local function envUrl() local ok, v = pcall(function() return getgenv().NETBRIDGE_URL end) return ok and type(v) == "string" and v ~= "" and v or nil end local CONFIG = { URL = envUrl() or "ws://localhost:9000/", MAX_RESULT_SIZE = 200000, MAX_TABLE_DEPTH = 4, MAX_TABLE_ENTRIES= 100, MAX_STR_FIELD = 500, MAX_ERR_SIZE = 2000, PING_INTERVAL = 15, PONG_TIMEOUT = 35, RETRY_PERIOD = 5, PROBE_PERIOD = 2, COMPRESS_OVER = 2048, -- The relay closes a socket that sends a frame over 4 MiB; stay clear of it. MAX_FRAME = 3800000, CAPTURE_NET = false, CAPTURE_HTTP = false, CAPTURE_RAKNET = false, -- Remote Spy reads remote calls out of network packets instead of hooking -- Luau functions (the "rakspy" feature below). RAKNET_SPY = false, CAPTURE_RETURNS = false, TRACK_TREE = false, MAX_NET_ARGS = 1500, SPY_ALL = false, SPY_RATE = 0.2, SPY_TICK = 0.1, SPY_MAX_PER_TICK = 40, SPY_DENY = { GetService = true, FindFirstChild = true, WaitForChild = true, IsA = true, GetChildren = true, FindFirstChildOfClass = true, GetAttribute = true, IsDescendantOf = true, GetDescendants = true, Clone = true, Raycast = true, GetState = true, Move = true, GetPivot = true, PivotTo = true, BulkMoveTo = true, ViewportPointToRay = true, GetRootPart = true, GetMouseLocation = true, GetBoundingBox = true, GetCameraYInvertValue = true, GetServerTimeNow = true, GetPartBoundsInBox = true, GetPropertyChangedSignal = true, GetAttributeChangedSignal = true, FindFirstAncestorWhichIsA = true, }, BLOCK = {}, HTTP_OVERRIDES = {}, RAKNET_RATE = 0.5, RAKNET_MAX_BYTES = 512, RAKNET_IDS = nil, RAKNET_DENY = { [0x83] = true }, } --[[ The session key: the loadstring's argument, else getgenv().NETBRIDGE_KEY (for a bridge of your own), else one the server wrote in for a pinned local room. Without one the client connects and reports itself, and refuses every command, because a command it cannot verify could have come from anyone who knows the room code. ]] local SESSION_KEY = (function() local function usable(k) return type(k) == "string" and #k >= 16 and #k <= 64 and not string.find(k, "[^%w]") end if usable(LAUNCH_ARGS[1]) then return LAUNCH_ARGS[1] end local ok, v = pcall(function() return getgenv().NETBRIDGE_KEY end) if ok and usable(v) then return v end if usable(CONFIG.LOCAL_KEY) then return CONFIG.LOCAL_KEY end return nil end)() CONFIG.SELF_HOST = (function() local host = tostring(CONFIG.URL):match("^wss?://([^/?#]+)") return host and host:lower() or nil end)() -- The site that served this client, over HTTP(S): where the packet decoder is. CONFIG.ORIGIN = (function() local scheme, host = tostring(CONFIG.URL):match("^(wss?)://([^/?#]+)") if not host then return nil end return (scheme == "wss" and "https" or "http") .. "://" .. host end)() do local ggv = getgenv and getgenv() if ggv then local own = ggv.__NB_OWN if type(own) ~= "table" then own = {}; ggv.__NB_OWN = own end own[CONFIG] = true own[CONFIG.SPY_DENY] = true own[CONFIG.BLOCK] = true own[CONFIG.HTTP_OVERRIDES] = true own[CONFIG.RAKNET_DENY] = true end end local HTTP_METHODS = { HttpGet = true, HttpGetAsync = true, HttpPost = true, HttpPostAsync = true, RequestAsync = true, GetAsync = true, PostAsync = true, } local HTTP_WRAP = { HttpGet = true, HttpPost = true } --[[ The client's own HTTP: helper downloads (/netbridge/script/…), the packet decoder (/netbridge/rakspy.lua) and the helpers' result posts (/bridge/ingest/…). Mocks and capture are for the game's traffic. When they caught these, a mock whose match text appeared in a helper URL answered the helper download with the mock's body ("helper fetch: empty body" the moment a game connected), and the Debugger's own requests filled HTTP Traffic. Matched by path, not host: the page that sends the helper bootstrap may be on www or origin while the loadstring came from the apex. ]] local function ownUrl(url) if type(url) ~= "string" then return false end local path = url:match("^https?://[^/]+(/[^?#]*)") if not path then return false end return path:sub(1, 18) == "/netbridge/script/" or path == "/netbridge/rakspy.lua" or path:sub(1, 15) == "/bridge/ingest/" end local function matchHttpOverride(url) if type(url) ~= "string" or ownUrl(url) then return nil end local rules = CONFIG.HTTP_OVERRIDES if type(rules) ~= "table" then return nil end for _, rule in ipairs(rules) do if type(rule) == "table" and type(rule.match) == "string" and rule.match ~= "" and url:find(rule.match, 1, true) then return rule end end return nil end local function overrideResponse(rule) return { Success = (rule.status == nil) or (tonumber(rule.status) or 200) < 400, StatusCode = tonumber(rule.status) or 200, StatusMessage = rule.statusMessage or "OK", Body = type(rule.body) == "string" and rule.body or "", Headers = type(rule.headers) == "table" and rule.headers or {}, } end local ENV = getgenv() --[[ Crash breadcrumbs — the client's half; the helpers' prelude has the other. The executor natives this tool leans on (decompile, hidden-property reads, getgc, getconnections, hooking remote listeners) can take the whole client down, pcall cannot catch that, and getgenv does not survive it. A file does. Every such call writes its name and target to disk first and clears it after, so a file still holding an entry when this runs names what killed the last session. That entry joins a poison list here, is reported to the browser in the Authorization frame, and is never made again on that target. The helpers cache the list in getgenv; clearing that here is what makes a new session reload it. ]] local Crash = { last = nil, poison = {} } do local function native(name) local ok, v = pcall(function() return getgenv()[name] end) if ok and v ~= nil then return v end return rawget(getfenv(), name) end local writefile, readfile, isfile = native("writefile"), native("readfile"), native("isfile") local isfolder, makefolder, appendfile = native("isfolder"), native("makefolder"), native("appendfile") local FS = type(writefile) == "function" and type(readfile) == "function" and type(isfile) == "function" local DIR, LAST, POISON = "rbx-netbridge", "rbx-netbridge/last-native-call.txt", "rbx-netbridge/poison.txt" if FS then pcall(function() if type(isfolder) == "function" and type(makefolder) == "function" and not isfolder(DIR) then makefolder(DIR) end if isfile(LAST) then local last = readfile(LAST) if type(last) == "string" and #last > 0 then Crash.last = last if type(appendfile) == "function" then appendfile(POISON, last .. "\n") else writefile(POISON, ((isfile(POISON) and readfile(POISON)) or "") .. last .. "\n") end writefile(LAST, "") end end if isfile(POISON) then for line in readfile(POISON):gmatch("[^\r\n]+") do Crash.poison[line] = true end end end) end ENV.__NB_POISON = nil ENV.__NB_LAST_CRASH = Crash.last function Crash.poisoned(name, target) local key = name .. "\t" .. tostring(target or "*") return Crash.poison[key] == true or Crash.poison[name .. "\t*"] == true end -- fn(...) under a breadcrumb: nil, "poisoned" when this call on this -- target has killed the client before; else what pcall(fn, ...) returns. function Crash.guard(name, target, fn, ...) if Crash.poisoned(name, target) then return nil, "poisoned" end if FS then pcall(writefile, LAST, name .. "\t" .. tostring(target or "*")) end local r = table.pack(pcall(fn, ...)) if FS then pcall(writefile, LAST, "") end return table.unpack(r, 1, r.n) end end local Caps = { cache = {}, revision = 0 } function Caps.acquire(name, required) local value = ENV[name] if value == nil then Caps.cache[name] = nil if required then error("[RBX Debugger] missing required capability: " .. name, 0) end return nil end Caps.cache[name] = value return value end function Caps.get(name) local value = ENV[name] if value ~= Caps.cache[name] then Caps.cache[name] = value end return value end function Caps.invalidate(name) Caps.cache[name] = nil Caps.revision += 1 end local oth = Caps.acquire("oth", false) local crypt = Caps.acquire("crypt", false) local HttpRequest = Caps.acquire("request", false) local getScriptFromThread = Caps.acquire("getscriptfromthread", false) local WebSocket = Caps.acquire("WebSocket", false) local function waitSignal(signal) local thread = coroutine.running() local done, conn = false, nil conn = signal:Connect(function(...) if done then return end done = true conn:Disconnect() task.spawn(thread, ...) end) return coroutine.yield() end local Events = { listeners = {} } function Events.on(name, fn) local list = Events.listeners[name] if not list then list = {}; Events.listeners[name] = list end local slot = { fn = fn, live = true } list[#list + 1] = slot return { Disconnect = function() slot.live = false end, } end function Events.emit(name, ...) local list = Events.listeners[name] if not list then return end local args = table.pack(...) for _, slot in list do if slot.live then local current = slot task.defer(function() if current.live then current.fn(table.unpack(args, 1, args.n)) end end) end end end local RingQueue = {} function RingQueue.new(cap) return { cap = cap, buf = {}, head = 1, size = 0 } end function RingQueue.push(q, value) local dropped = 0 if q.size >= q.cap then q.buf[q.head] = nil q.head = (q.head % q.cap) + 1 q.size -= 1 dropped = 1 end local idx = ((q.head - 1 + q.size) % q.cap) + 1 q.buf[idx] = value q.size += 1 return dropped end function RingQueue.drain(q) local out = {} for i = 1, q.size do local idx = ((q.head - 1 + (i - 1)) % q.cap) + 1 out[i] = q.buf[idx] q.buf[idx] = nil end q.head, q.size = 1, 0 return out end -- The newest item, or nil. Lets a repeat of it be counted instead of queued. function RingQueue.last(q) if q.size == 0 then return nil end return q.buf[((q.head - 1 + (q.size - 1)) % q.cap) + 1] end -- Up to n items from the front, oldest first; the rest stay queued. function RingQueue.take(q, n) local count = math.min(n, q.size) local out = table.create(count) for i = 1, count do out[i] = q.buf[q.head] q.buf[q.head] = nil q.head = (q.head % q.cap) + 1 end q.size -= count if q.size == 0 then q.head = 1 end return out end function RingQueue.clear(q) for i = 1, q.size do local idx = ((q.head - 1 + (i - 1)) % q.cap) + 1 q.buf[idx] = nil end q.head, q.size = 1, 0 end --@@begin guard --[[ A first-in first-out queue with no capacity: push at the tail, take from the head, both O(1). table.remove(t, 1) shifts every remaining element, so a burst of N queued frames cost N^2/2 moves to drain. ]] local Fifo = {} function Fifo.new() return { buf = {}, head = 1, tail = 0 } end function Fifo.push(q, value) q.tail += 1 q.buf[q.tail] = value end function Fifo.pop(q) if q.head > q.tail then return nil end local value = q.buf[q.head] q.buf[q.head] = nil q.head += 1 if q.head > q.tail then q.head, q.tail = 1, 0 end return value end function Fifo.size(q) return q.tail - q.head + 1 end function Fifo.clear(q) table.clear(q.buf) q.head, q.tail = 1, 0 end --[[ What the game accepts from the browser. Everything between the guard markers (Fifo above included) depends on plain Luau only, so scripts/check-netbridge-guard.mjs runs it outside Roblox against the browser's own signing code (lib/netbridge/auth.ts). Three checks, in this order, before a command is acted on: 1. It is signed. The page sends `NB1::::`, the mac an HMAC-SHA256 under the session key over this connection's nonce, the tab, the sequence number and the JSON. The key reaches the game as the loadstring's argument and is never in a URL, so the relay and anyone else holding only the room code cannot produce one. The nonce changes with every connection and the sequence only grows, so a recorded command cannot be played back. 2. It fits. Every command has a shape and every field a type and a size. Anything else is refused whole, with the reason, rather than acted on as far as it happens to go. 3. It is not a flood. A bucket bounds commands per second. ]] local Guard = {} do local band, bxor, bnot, rrotate, rshift = bit32.band, bit32.bxor, bit32.bnot, bit32.rrotate, bit32.rshift local K = { 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, } local M = 0x100000000 local W = table.create(64, 0) -- About 4.5 MB/s in the Luau VM. `breathe`, when given, is called every -- 256 KB so a large command does not hold the game thread in one piece. function Guard.sha256(msg, breathe) local len = #msg msg = msg .. "\128" .. string.rep("\0", (55 - len) % 64) .. string.pack(">I4I4", math.floor(len / 0x20000000), (len * 8) % M) local h0, h1, h2, h3 = 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a local h4, h5, h6, h7 = 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 local w = W local blocks = 0 for at = 1, #msg, 64 do blocks += 1 if breathe and blocks % 4096 == 0 then breathe() end w[1], w[2], w[3], w[4], w[5], w[6], w[7], w[8], w[9], w[10], w[11], w[12], w[13], w[14], w[15], w[16] = string.unpack(">I4I4I4I4I4I4I4I4I4I4I4I4I4I4I4I4", msg, at) for i = 17, 64 do local a, b = w[i - 15], w[i - 2] w[i] = (w[i - 16] + bxor(rrotate(a, 7), rrotate(a, 18), rshift(a, 3)) + w[i - 7] + bxor(rrotate(b, 17), rrotate(b, 19), rshift(b, 10))) % M end local a, b, c, d, e, f, g, h = h0, h1, h2, h3, h4, h5, h6, h7 for i = 1, 64 do local t1 = h + bxor(rrotate(e, 6), rrotate(e, 11), rrotate(e, 25)) + bxor(band(e, f), band(bnot(e), g)) + K[i] + w[i] local t2 = bxor(rrotate(a, 2), rrotate(a, 13), rrotate(a, 22)) + bxor(band(a, b), band(a, c), band(b, c)) h, g, f, e, d, c, b, a = g, f, e, (d + t1) % M, c, b, a, (t1 + t2) % M end h0 = (h0 + a) % M; h1 = (h1 + b) % M; h2 = (h2 + c) % M; h3 = (h3 + d) % M h4 = (h4 + e) % M; h5 = (h5 + f) % M; h6 = (h6 + g) % M; h7 = (h7 + h) % M end return string.pack(">I4I4I4I4I4I4I4I4", h0, h1, h2, h3, h4, h5, h6, h7) end local function toHex(s) return (string.gsub(s, ".", function(c) return string.format("%02x", string.byte(c)) end)) end Guard.toHex = toHex function Guard.hmacHex(key, message, breathe) if #key > 64 then key = Guard.sha256(key) end key = key .. string.rep("\0", 64 - #key) local ipad, opad = table.create(64), table.create(64) for i = 1, 64 do local b = string.byte(key, i) ipad[i] = string.char(bxor(b, 0x36)) opad[i] = string.char(bxor(b, 0x5c)) end local inner = Guard.sha256(table.concat(ipad) .. message, breathe) return toHex(Guard.sha256(table.concat(opad) .. inner)) end -- Compared in full whatever the first difference, so the time taken says -- nothing about how much of a guess was right. local function sameText(a, b) if type(a) ~= "string" or type(b) ~= "string" or #a ~= #b then return false end local diff = 0 for i = 1, #a do diff = bit32.bor(diff, bxor(string.byte(a, i), string.byte(b, i))) end return diff == 0 end Guard.PREFIX = "NB1:" Guard.LIMITS = { FRAME = 2400000, -- one inbound frame, prefix and JSON escaping included SOURCE = 2000000, -- Luau sent to Execute EXEC_ID = 160, ARGS_LUA = 262144, -- FireRemote's argument expression PATH = 2000, URL = 8192, HEADERS = 64, HEADER_NAME = 256, HEADER_VALUE = 8192, BODY = 1048576, -- a replayed request or an override's response body BLOCK_RULES = 256, OVERRIDES = 100, RULE_TEXT = 2000, REASON = 200, TABS = 32, -- tabs whose sequence numbers are remembered } local L = Guard.LIMITS -- A session: the key the loadstring carried (nil if it carried none) and -- this connection's nonce. function Guard.session(key, nonce) return { key = key, nonce = nonce, seen = {}, order = {} } end -- NB1:::: -> tab, seq, mac, json; nil for anything else. function Guard.split(raw) if string.sub(raw, 1, 4) ~= Guard.PREFIX then return nil end local tab, seq, mac, at = string.match(raw, "^NB1:(%w+):([1-9]%d*):(%x+):()") if not tab or #tab > 32 or #seq > 15 or #mac ~= 64 then return nil end return tab, seq, mac, string.sub(raw, at) end --[[ ok, or false and why: "nokey" (the loadstring carried no key), "signature" (wrong key, or a nonce from another connection), "replay" (a sequence number this tab already used). ]] function Guard.verify(s, tab, seq, mac, json, breathe) if not s.key then return false, "nokey" end local n = tonumber(seq) local last = s.seen[tab] if last and n <= last then return false, "replay" end local want = Guard.hmacHex(s.key, s.nonce .. ":" .. tab .. ":" .. seq .. ":" .. json, breathe) if not sameText(want, string.lower(mac)) then return false, "signature" end if last == nil then s.order[#s.order + 1] = tab if #s.order > L.TABS then s.seen[table.remove(s.order, 1)] = nil end end s.seen[tab] = n return true end -- Field checks. Each answers nil when the value is fine, else what is wrong. local function str(v, name, max, optional) if v == nil and optional then return nil end if type(v) ~= "string" then return name .. " must be a string" end if #v > max then return name .. " is " .. #v .. " bytes, over the " .. max .. " limit" end return nil end local function bool(v, name) if v ~= nil and type(v) ~= "boolean" then return name .. " must be true or false" end return nil end local function list(v, name, max) if type(v) ~= "table" then return name .. " must be a list" end local n = 0 for _ in pairs(v) do n += 1 end if n ~= #v then return name .. " must be a list" end if n > max then return name .. " has " .. n .. " entries, over the " .. max .. " limit" end return nil end local function stringMap(v, name) if v == nil then return nil end if type(v) ~= "table" then return name .. " must be an object of strings" end local n = 0 for k, val in pairs(v) do n += 1 if n > L.HEADERS then return name .. " has more than " .. L.HEADERS .. " entries" end if type(k) ~= "string" or #k == 0 or #k > L.HEADER_NAME then return name .. " has a bad name" end if type(val) ~= "string" then return name .. "." .. k .. " must be a string" end if #val > L.HEADER_VALUE then return name .. "." .. k .. " is over " .. L.HEADER_VALUE .. " bytes" end end return nil end local VERBS = { GET = true, POST = true, PUT = true, PATCH = true, DELETE = true, HEAD = true, OPTIONS = true } local SHAPES = { Hello = function() return nil end, Disconnect = function(c) return str(c.reason, "reason", L.REASON, true) end, Execute = function(c) return str(c.Data, "Data", L.SOURCE) or bool(c.priority, "priority") or str(c.Cap, "Cap", 32, true) end, FireRemote = function(c) if c.method ~= "FireServer" and c.method ~= "InvokeServer" then return "method must be FireServer or InvokeServer" end return str(c.path, "path", L.PATH) or str(c.argsLua, "argsLua", L.ARGS_LUA, true) end, ReplayHttp = function(c) local r = c.req if type(r) ~= "table" then return "req must be an object" end local bad = str(r.url, "req.url", L.URL) if bad then return bad end if not string.match(string.lower(r.url), "^https?://[^/%s]+") then return "req.url must be an http(s) address" end if r.method ~= nil then if type(r.method) ~= "string" or not VERBS[string.upper(r.method)] then return "req.method is not an HTTP method" end end return stringMap(r.headers, "req.headers") or str(r.body, "req.body", L.BODY, true) or stringMap(r.cookies, "req.cookies") end, Config = function(c) for _, name in { "SpyAll", "CaptureNet", "CaptureHttp", "CaptureRaknet", "CaptureReturns", "TrackTree", "RaknetSpy" } do local bad = bool(c[name], name) if bad then return bad end end if c.Block ~= nil then local bad = list(c.Block, "Block", L.BLOCK_RULES) if bad then return bad end for i, p in ipairs(c.Block) do bad = str(p, "Block[" .. i .. "]", L.RULE_TEXT) if bad then return bad end end end if c.HttpOverrides ~= nil then local bad = list(c.HttpOverrides, "HttpOverrides", L.OVERRIDES) if bad then return bad end for i, rule in ipairs(c.HttpOverrides) do local at = "HttpOverrides[" .. i .. "]" if type(rule) ~= "table" then return at .. " must be an object" end bad = str(rule.match, at .. ".match", L.RULE_TEXT) or str(rule.body, at .. ".body", L.BODY, true) or str(rule.statusMessage, at .. ".statusMessage", L.REASON, true) or stringMap(rule.headers, at .. ".headers") if bad then return bad end if rule.match == "" then return at .. ".match is empty" end local st = rule.status if st ~= nil and type(st) ~= "number" and not (type(st) == "string" and #st <= 8) then return at .. ".status must be a number" end end end return nil end, } Guard.COMMANDS = SHAPES -- nil when `cmd` is a command this client knows, with every field in -- shape; else the reason it is refused. function Guard.check(cmd) if type(cmd) ~= "table" then return "not a command" end local method = cmd.Method if type(method) ~= "string" then return "no Method" end local shape = SHAPES[method] if not shape then return "unknown command " .. string.sub(method, 1, 40) end local bad = str(cmd._execId, "_execId", L.EXEC_ID, true) if bad then return bad end return shape(cmd) end --[[ A token bucket: `rate` a second, bursts of up to `burst`. Opening the Explorer on a big place is a burst of small reads; a stuck loop in a page is a sustained stream, and that is what this stops. ]] function Guard.bucket(rate, burst, clock) return { rate = rate, burst = burst, tokens = burst, at = clock() } end function Guard.take(b, now) b.tokens = math.min(b.burst, b.tokens + (now - b.at) * b.rate) b.at = now if b.tokens < 1 then return false end b.tokens -= 1 return true end end --@@end guard local OthSlots = ENV.__NB_OTH_SLOTS if type(OthSlots) ~= "table" then OthSlots = setmetatable({}, { __mode = "k" }) ENV.__NB_OTH_SLOTS = OthSlots end local FunctionSlots = ENV.__NB_FUNCTION_SLOTS if type(FunctionSlots) ~= "table" then FunctionSlots = setmetatable({}, { __mode = "k" }) ENV.__NB_FUNCTION_SLOTS = FunctionSlots end local function claimOthHook(target, body) oth = Caps.acquire("oth", true) if type(oth) ~= "table" or type(oth.hook) ~= "function" then error("oth.hook unavailable", 0) end if type(target) ~= "function" or type(body) ~= "function" then error("invalid oth hook claim", 0) end local slot = OthSlots[target] if slot and type(slot.orig) == "function" then slot.body = body return { slot = slot, body = body } end slot = { body = body, orig = nil } OthSlots[target] = slot local orig orig = oth.hook(target, function(...) local current = slot.body if current then return current(slot.orig, ...) end return slot.orig(...) end) if type(orig) ~= "function" then OthSlots[target] = nil error("oth.hook did not return original", 0) end slot.orig = orig return { slot = slot, body = body } end local function claimFunctionHook(target, body) if type(target) ~= "function" or type(body) ~= "function" then error("invalid function hook claim", 0) end local slot = FunctionSlots[target] if slot and type(slot.orig) == "function" then slot.body = body return { slot = slot, body = body } end local hookfn = Caps.acquire("hookfunction", true) if type(hookfn) ~= "function" then error("hookfunction unavailable", 0) end slot = { body = body, orig = nil } FunctionSlots[target] = slot local orig orig = hookfn(target, function(...) local current = slot.body if current then return current(slot.orig, ...) end return slot.orig(...) end) if type(orig) ~= "function" then FunctionSlots[target] = nil error("hookfunction did not return original", 0) end slot.orig = orig return { slot = slot, body = body } end local function releaseHookClaim(claim) if claim and claim.slot and claim.slot.body == claim.body then claim.slot.body = nil end end getgenv().__BridgeRunning = true local Services = setmetatable({}, { __index = function(self, key) local ok, service = pcall(game.GetService, game, key) if ok and service then rawset(self, key, service); return service end return nil end, }) local Players = Services.Players local JSON = Services.HttpService local Client = Players.LocalPlayer if not Client then waitSignal(Players:GetPropertyChangedSignal("LocalPlayer")) Client = Players.LocalPlayer end local OrigLoadstring = loadstring local OrigWarn = getgenv().warn if not SESSION_KEY then pcall(OrigWarn, "[RBX Debugger] This loadstring carried no session key, so the page cannot send this game commands." .. " Copy the line again from the Debugger and run it.") end local Serialize local SIMPLE = { Vector3=1, CFrame=1, Color3=1, UDim2=1, UDim=1, Vector2=1, Rect=1, BrickColor=1, Enum=1, EnumItem=1, NumberRange=1, NumberSequence=1, ColorSequence=1, } local function getClassNameOf(v) return v.ClassName end local function getFullNameOf(v) return v:GetFullName() end local function getNameOf(v) return v.Name end local function getParentOf(v) return v.Parent end local function hasControlByte(v) local i = string.find(v, "%c") while i do local b = string.byte(v, i) if b ~= 9 and b ~= 10 and b ~= 13 then return true end i = string.find(v, "%c", i + 1) end return false end local Dispatch = { ["nil"] = function() return "nil" end, boolean = tostring, number = tostring, string = function(v) local full = #v local body = full > CONFIG.MAX_STR_FIELD and v:sub(1, CONFIG.MAX_STR_FIELD) or v if not utf8.len(body) or hasControlByte(body) then local n = math.min(#body, 64) local hex = {} for i = 1, n do hex[i] = string.format("%02x", string.byte(body, i)) end return '""' end if full > CONFIG.MAX_STR_FIELD then return '"' .. body .. '...[' .. full .. ' chars]"' end return '"' .. v .. '"' end, Instance = function(v) local okC, cls = pcall(getClassNameOf, v) cls = okC and cls or "?" local okN, full = pcall(getFullNameOf, v) return okN and (cls .. ' "' .. full .. '"') or (cls .. " (destroyed)") end, table = function(v, depth) if depth >= CONFIG.MAX_TABLE_DEPTH then return "{...}" end local parts, n, isArray, idx = {}, 0, true, 0 for k, val in v do idx += 1 if isArray and (typeof(k) ~= "number" or k ~= idx) then isArray = false end n += 1 if n > CONFIG.MAX_TABLE_ENTRIES then parts[n] = "...more"; break end local okV, sv = pcall(Serialize, val, depth + 1) if not okV then sv = "" end if isArray then parts[n] = sv else local okK, sk = pcall(tostring, k) parts[n] = (okK and sk or "") .. " = " .. sv end end return "{" .. concat(parts, ", ") .. "}" end, ["function"] = function(v) local ok, info = pcall(debug.getinfo, v) return (ok and info) and ("function<" .. (info.name or "anon") .. ":" .. (info.currentline or "?") .. ">") or "function" end, } function Serialize(value, depth) depth = depth or 0 local t = typeof(value) local h = Dispatch[t] if h then return h(value, depth) end if SIMPLE[t] then return t .. "(" .. tostring(value) .. ")" end local ok, str = pcall(tostring, value) return ok and str or (t .. "") end local function InjectEnv(fn) local setEnv = Caps.acquire("setfenv", true) if type(setEnv) ~= "function" then error("setfenv unavailable", 0) end setEnv(fn, ENV) end local function ProbeEnv() local info = { Name = Client.Name } local probes = { UserId = function() return Client.UserId end, DisplayName = function() return Client.DisplayName end, PlaceId = function() return game.PlaceId end, JobId = function() return game.JobId end, GameId = function() return game.GameId end, PlayerCount = function() return #Players:GetPlayers() end, Executor = function() local n, ver = identifyexecutor() return tostring(n) .. (ver and (" " .. tostring(ver)) or "") end, } for key, fn in probes do local ok, val = pcall(fn) if ok then info[key] = val end end -- What the previous session died doing, when a breadcrumb was left (see Crash). if type(Crash.last) == "string" then local call, target = Crash.last:match("^([^\t]*)\t?(.*)$") info.LastCrash = { call = call or Crash.last, target = (target and #target > 0) and target or "*" } end local function fn(name) return type(Caps.get(name)) == "function" end local function debugFn(name) local ok, d = pcall(function() return debug[name] end) return (ok and type(d) == "function") or fn(name) end local cryptLib = Caps.get("crypt") --[[ What this executor offers, for the page's capability report. Presence only: a function that exists can still fail when called, which the feature that calls it reports in its own words. ]] info.Caps = { oth = oth ~= nil and type(oth.hook) == "function", lz4 = crypt ~= nil and crypt.lz4compress ~= nil and crypt.base64encode ~= nil, request = fn("request"), websocket = type(Caps.get("WebSocket")) == "table", hookfunction = fn("hookfunction"), hookmetamethod = fn("hookmetamethod"), getnamecallmethod = fn("getnamecallmethod"), getconnections = fn("getconnections"), getcallbackvalue = fn("getcallbackvalue"), raknet = type(Caps.get("raknet")) == "table", raknetHooks = (function() local lib = Caps.get("raknet") return type(lib) == "table" and type(lib.add_send_hook) == "function" and type(lib.add_receive_hook) == "function" end)(), scriptId = getScriptFromThread ~= nil, decompile = fn("decompile"), getgc = fn("getgc"), getscriptclosure = fn("getscriptclosure"), getnilinstances = fn("getnilinstances"), upvalues = debugFn("getupvalues") and debugFn("setupvalue"), firesignal = fn("firesignal"), fireproximityprompt = fn("fireproximityprompt"), saveinstance = fn("saveinstance"), setfflag = fn("setfflag"), filesystem = fn("writefile") and fn("readfile") and fn("isfile"), hash = type(cryptLib) == "table" and type(cryptLib.hash) == "function", } return info end --[[ Set when the relay tells us this room already has a live game attached. Declared out here rather than inside connect() because it has to outlive the socket that learned it: the retry timer fires after connect() has returned, and a value scoped to one connection would be gone by then. It BACKS OFF rather than gives up. Standing down permanently would trade a reconnect loop for a dead session: the incumbent holding the room is often this client's own earlier socket, and when the relay finally reaps it nothing would ever dial back in and the user's game would sit detached with no way back short of re-running the loadstring. So a refusal only slows the retry down, and the next attempt after the incumbent goes away succeeds. ]] local DUPLICATE_BACKOFF = 30 local duplicateUntil = 0 --[[ This game is going away (teleport, leaving, a disconnect message). Set once and never cleared: redialling now only opened a socket in a game about to stop, the executor kept dialling that socket back after the script was gone, and the relay took it for the attached game and turned the real one away. The next game runs the loadstring fresh. ]] local leaving = false local function connect() WebSocket = Caps.acquire("WebSocket", true) HttpRequest = Caps.acquire("request", true) oth = Caps.acquire("oth", false) crypt = Caps.acquire("crypt", false) getScriptFromThread = Caps.acquire("getscriptfromthread", false) if type(WebSocket) ~= "table" or type(WebSocket.connect) ~= "function" then error("WebSocket.connect unavailable", 0) end if type(HttpRequest) ~= "function" then error("request capability unavailable", 0) end if not isCurrent() then return end --[[ Close every socket this game has ever opened for the Debugger before opening another. Some executors' WebSocket objects (Potassium's among them, measured) redial on their own after the server closes them — a dozen times a second, for as long as the object is not closed — so a socket the relay dropped kept coming back as a second "game" in the room, the real reconnect was refused as its duplicate, and the executor eventually went down under ~1000 connections. The registry lives in getgenv so a socket left by an earlier run of the loadstring is closed too. ]] local registry = ENV.__NB_SOCKETS if type(registry) ~= "table" then registry = {} ENV.__NB_SOCKETS = registry end local closedAny = false for i = #registry, 1, -1 do local old = registry[i] registry[i] = nil closedAny = true pcall(function() old:Close() end) end -- Closing an earlier copy's socket also wakes that copy, which then -- releases its hooks and connections. A moment for the relay to see the -- old socket go, so this one is not turned away as its duplicate. if closedAny then task.wait(0.5) end if not isCurrent() then return end local ok, Socket = pcall(WebSocket.connect, CONFIG.URL) if not ok or not Socket then return end registry[#registry + 1] = Socket local alive = true local wake --[[ This connection's nonce. It goes out in every Authorization frame and into every command's MAC, so a command signed for an earlier connection (recorded, or simply late) is refused by this one. ]] local nonce = string.gsub(JSON:GenerateGUID(false), "-", "") local guardSession = Guard.session(SESSION_KEY, nonce) -- The packet capture's state (see "Remote Spy over RakNet" below). local RakSpy = { installed = false, failed = 0 } -- Counters for the page's diagnostics; they reset with the connection. local Diag = { connectedAt = os.clock(), framesOut = 0, bytesOut = 0, framesIn = 0, bytesIn = 0, chunked = 0, oversizedOut = 0, commands = 0, rejected = { size = 0, auth = 0, shape = 0, rate = 0, malformed = 0, backlog = 0 }, } local uiPresent = true local SPY_CAP, HTTP_CAP, CONSOLE_CAP = 800, 800, 3000 -- Lines per ConsoleBatch frame: at 4000 characters a line at most, well -- inside the relay's 4 MB frame limit even uncompressed. local CONSOLE_BATCH = 400 local queue = RingQueue.new(CONSOLE_CAP) local hookClaims = {} local queueConsole local send local connections = {} local Supervisor = { features = {} } function Supervisor.register(name, spec) spec.name = name spec.state = "idle" spec.repairing = false Supervisor.features[name] = spec return spec end function Supervisor.ensure(name, reason) local feature = Supervisor.features[name] if not feature or not alive then return false end local desired = feature.desired == nil or feature.desired() if not desired then feature.state = "disabled" if feature.disable then pcall(feature.disable) end return true end local okV, healthy = pcall(feature.verify) if okV and healthy then feature.state = "healthy" return true end if feature.repairing then return false end local previousState = feature.state feature.repairing = true feature.state = "repairing" local okR, err = pcall(feature.repair, reason) local okAfter, healthyAfter = pcall(feature.verify) feature.repairing = false if okR and okAfter and healthyAfter then local wasBroken = previousState == "broken" feature.state = "healthy" if wasBroken and queueConsole then queueConsole("print", "[RBX Debugger] repaired " .. name, "supervisor") end return true end feature.state = "broken" if queueConsole then queueConsole("warn", "[RBX Debugger] " .. name .. " repair failed: " .. tostring(err or "verification failed"), "supervisor") end task.delay(feature.retry or 1, function() if alive and isCurrent() then Events.emit("RepairRequested", MY_GEN, name, "retry") end end) return false end function Supervisor.ensureAll(reason) for name in pairs(Supervisor.features) do Supervisor.ensure(name, reason) end end connections[#connections + 1] = Events.on("RepairRequested", function(gen, name, reason) if gen == MY_GEN and alive then Supervisor.ensure(name, reason) end end) local canCompress = crypt and crypt.lz4compress and crypt.base64encode local function sanHasControlByte(v) local i = string.find(v, "%c") while i do local b = string.byte(v, i) if b ~= 9 and b ~= 10 and b ~= 13 then return true end i = string.find(v, "%c", i + 1) end return false end local function sanStr(v) if utf8.len(v) and not sanHasControlByte(v) then return v end local n = math.min(#v, 64) local hex = {} for i = 1, n do hex[i] = string.format("%02x", string.byte(v, i)) end return "" end local function sanitizeForJson(v, depth, seen) depth = depth or 0 local t = type(v) if t == "string" then return sanStr(v) end if t == "number" then if v ~= v then return "nan" end if v == math.huge then return "inf" end if v == -math.huge then return "-inf" end return v end if t == "boolean" or t == "nil" then return v end if t == "table" then if depth >= 16 then return "" end seen = seen or {} if seen[v] then return "" end seen[v] = true local out = {} for k, val in pairs(v) do local tk = type(k) local key = (tk == "string" or tk == "number") and k or tostring(k) out[key] = sanitizeForJson(val, depth + 1, seen) end seen[v] = nil return out end local ok, s = pcall(tostring, v) return ok and s or ("<" .. t .. ">") end local dropCount, lastDropWarn = 0, 0 local sendSized --[[ One frame on the wire at a time. Several threads send (the console flush, the tree walk, a Run's answer), and an executor's Send can yield while it writes a large frame; a second Send started in that gap wrote its bytes into the middle of the first, and the relay read garbage ("Invalid WebSocket frame: RSV2 and RSV3 must be clear") and dropped the game — right after connecting, when the first bursts go out, and more often on a slow link or a busy machine. A frame sent while another is going out waits its turn, in order. ]] local sending, waitingFrames = false, Fifo.new() local function rawSend(encoded) if sending then Fifo.push(waitingFrames, encoded) return true end sending = true local okSend = pcall(Socket.Send, Socket, encoded) Diag.framesOut += 1 Diag.bytesOut += #encoded while okSend do local nextFrame = Fifo.pop(waitingFrames) if nextFrame == nil then break end okSend = pcall(Socket.Send, Socket, nextFrame) Diag.framesOut += 1 Diag.bytesOut += #nextFrame end sending = false if not okSend then Fifo.clear(waitingFrames) end if okSend then return true end if alive then alive = false pcall(function() Socket:Close() end) Events.emit("SocketLost", MY_GEN, "send") if wake then wake() end end return false end --[[ One frame too big for the relay and the relay hangs up on the game: the socket closes, the game is gone for a reconnect, and the answer is lost with it. A huge decompile, a Run returning megabytes, or a client with no lz4 to shrink them all did that. Nothing that size is sent. A reply somebody is waiting on is answered with why instead, so they are told rather than left to time out; anything else is dropped and counted. ]] --[[ No frame over 60 KB goes out whole. An executor's WebSocket (Potassium, measured) sends a broken frame once its compressed size passes 64 KB, and the relay drops the game for it ("RSV2 and RSV3 must be clear"): the first large answer after attaching — the explorer's first read — cut the game off every few seconds. A larger message goes as numbered pieces the page puts back together (decoder.worker.ts). Cut on character boundaries, so a piece of text is valid UTF-8; a reply's pieces keep its marker, so the relay treats them as a reply. ]] local PIECE = 60000 local pieceSeq = 0 local function sendInPieces(frame) pieceSeq += 1 Diag.chunked += 1 local isReply = string.sub(frame, 1, 6) == '{"R":1' local parts, at = {}, 1 while at <= #frame do local stop = math.min(at + PIECE - 1, #frame) -- Back off a UTF-8 continuation byte, so no character is split. while stop < #frame and stop > at do local b = string.byte(frame, stop + 1) if b < 0x80 or b >= 0xC0 then break end stop -= 1 end table.insert(parts, string.sub(frame, at, stop)) at = stop + 1 end for i, part in ipairs(parts) do local out = (isReply and '{"R":1,' or '{') .. '"K":[' .. pieceSeq .. ',' .. i .. ',' .. #parts .. '],"D":' .. JSON:JSONEncode(part) .. '}' if not rawSend(out) then return false end end return true end sendSized = function(tbl, frame) if #frame <= PIECE then return rawSend(frame) end if #frame <= CONFIG.MAX_FRAME then return sendInPieces(frame) end local mb = string.format("%.1f", #frame / 1048576) Diag.oversizedOut += 1 if tbl._execId ~= nil then return rawSend(JSON:JSONEncode({ Method = "Error", _execId = tbl._execId, Message = "The answer was " .. mb .. " MB, more than one message to the page can carry (3.6 MB), so it was not sent. Ask for less at a time.", })) end dropCount += 1 local now = os.clock() if now - lastDropWarn > 5 then lastDropWarn = now if queueConsole then queueConsole("warn", "[RBX Debugger] dropped a " .. mb .. " MB " .. tostring(tbl.Method or "frame") .. " too big to send", "netbridge") end end return false end send = function(tbl) if not alive then return false end local enc, encoded = pcall(JSON.JSONEncode, JSON, tbl) if not enc then local okS, sEncoded = pcall(function() return JSON:JSONEncode(sanitizeForJson(tbl)) end) if okS and type(sEncoded) == "string" then encoded = sEncoded else dropCount += 1 local now = os.clock() if now - lastDropWarn > 5 then lastDropWarn = now if queueConsole then queueConsole("error", "[RBX Debugger] dropped " .. dropCount .. " unencodable frame(s)", "netbridge") end end return false end end if canCompress and #encoded >= CONFIG.COMPRESS_OVER then local okC, packed = pcall(crypt.lz4compress, encoded) if okC and packed then local okB, b64 = pcall(crypt.base64encode, packed) if okB and b64 and #b64 < #encoded then --[[ Built by hand so a reply's marker comes first. The relay spares replies (a Result somebody is waiting on) from its rate and backlog limits, and it can only tell a compressed one from its opening bytes. Base64 needs no escaping. ]] local reply = tbl._execId ~= nil or tbl.Method == "Result" or tbl.Method == "Error" return sendSized(tbl, (reply and '{"R":1,"Z":1,"B":"' or '{"Z":1,"B":"') .. b64 .. '"}') end end end return sendSized(tbl, encoded) end local spyRaw = RingQueue.new(SPY_CAP) local spySuppress = false local function withSpySuppressed(fn) local saved = spySuppress spySuppress = true local packed = pack(pcall(fn)) spySuppress = saved return table.unpack(packed, 1, packed.n) end local SpyGetFullName do local ok, fn = pcall(function() return game.GetFullName end); if ok and type(fn) == "function" then SpyGetFullName = fn end end local pathCache = setmetatable({}, { __mode = "k" }) local function spyPathOf(inst) if inst == nil then return "?" end local hit = pathCache[inst] if hit ~= nil then return hit end if type(SpyGetFullName) ~= "function" then return "?" end local ok, full = pcall(SpyGetFullName, inst) if not ok or type(full) ~= "string" or full == "" then return "?" end pathCache[inst] = full return full end local httpRaw = RingQueue.new(HTTP_CAP) local HTTP_BODY_CAP = 16384 local function httpSafe(s, cap) if type(s) ~= "string" then return s end local full = #s local body = (cap and full > cap) and s:sub(1, cap) or s if utf8.len(body) then return body end local n = math.min(#body, 64) local hex = {} for i = 1, n do hex[i] = string.format("%02x", string.byte(body, i)) end return "" end local function safeHeaders(h) if type(h) ~= "table" then return nil end local out = {} for k, v in pairs(h) do out[tostring(k)] = httpSafe(tostring(v), 2048) end return out end local function buildHttp(source, reqArg, resVal, t0, caller, endT) local url, method, reqHeaders, reqBody, reqCookies if type(reqArg) == "table" then url = reqArg.Url or reqArg.url method = reqArg.Method or reqArg.method reqHeaders = reqArg.Headers or reqArg.headers reqBody = reqArg.Body or reqArg.body reqCookies = reqArg.Cookies or reqArg.cookies elseif type(reqArg) == "string" then url = reqArg end if type(url) ~= "string" then return nil end if url:find("localhost", 1, true) or url:find("127.0.0.1", 1, true) then return nil end if CONFIG.SELF_HOST then local host = url:match("^https?://([^/?#]+)") if host and host:lower() == CONFIG.SELF_HOST then local path = url:match("^https?://[^/?#]*([^?#]*)") or "" if path:find("^/bridge/") or path:find("^/netbridge/") then return nil end end end if not method then method = source:find("Post") and "POST" or "GET" end local status, statusMsg, success, respHeaders, respBody, respFull = "?", nil, nil, nil, nil, 0 if type(resVal) == "table" then status = tostring(resVal.StatusCode or resVal.Status or resVal.status_code or "?") statusMsg = resVal.StatusMessage or resVal.StatusText success = resVal.Success respHeaders = resVal.Headers or resVal.headers respBody = resVal.Body or resVal.body elseif type(resVal) == "string" then respBody = resVal status = "200"; success = true end if type(respBody) == "string" then respFull = #respBody end if success == nil and status ~= "?" then local n = tonumber(status); success = n ~= nil and n >= 200 and n < 400 end return { source = source, url = url, method = tostring(method):upper(), reqHeaders = safeHeaders(reqHeaders), reqBody = httpSafe(reqBody, HTTP_BODY_CAP), reqBytes = type(reqBody) == "string" and #reqBody or 0, reqCookies = safeHeaders(reqCookies), status = status, statusMessage = statusMsg and tostring(statusMsg) or nil, success = success, respHeaders = safeHeaders(respHeaders), respBody = httpSafe(respBody, HTTP_BODY_CAP), respBytes = respFull, respTrunc = respFull > HTTP_BODY_CAP, ms = math.floor(((endT or os.clock()) - t0) * 1000), t = os.time() * 1000, caller = caller, } end local queueDropped = 0 local function queueHttp(rec) if not rec or not alive or not uiPresent then return end queueDropped += RingQueue.push(httpRaw, rec) end local httpSpec = RingQueue.new(HTTP_CAP) local function queueHttpSpec(spec) if not spec or not alive or not uiPresent then return end queueDropped += RingQueue.push(httpSpec, spec) end local function pushSpy(rec) if not alive or not uiPresent then return end queueDropped += RingQueue.push(spyRaw, rec) end local TREE = { cap = 200, q = {}, n = 0, dirty = false, conns = nil, dirtyAt = 0, dirtyEvery = 5, -- Match tree.lua's exclusion rule instead of maintaining a service -- allow-list. New Roblox services then participate automatically. internalRoots = { RobloxReplicatedStorage = true, CorePackages = true, CoreScripts = true, RobloxGui = true, PlayerScriptsLoader = true } } local flushScheduled = false local scheduleFlush -- defined below; flush reschedules itself when a flood is left over local function flush() flushScheduled = false if (httpRaw.size > 0 or httpSpec.size > 0) and alive then local hraw = RingQueue.drain(httpRaw) if httpSpec.size > 0 then local specs = RingQueue.drain(httpSpec) for _, spec in specs do local caller = spec.cs and spyPathOf(spec.cs) or nil if caller == "?" then caller = nil end local okB, rec = pcall(buildHttp, spec.source, spec.reqArg, spec.resVal, spec.t0, caller, spec.endT) if okB and rec then if spec.overridden then rec.overridden = true; rec.status = spec.ovStatus or rec.status end hraw[#hraw + 1] = rec end end end send({ Method = "HttpCallBatch", Calls = hraw }) end if spyRaw.size > 0 and alive then local raw = RingQueue.drain(spyRaw) local okCalls, calls = withSpySuppressed(function() local calls = {} for _, rec in raw do local full = rec.path or spyPathOf(rec.self) local n = rec.argv.n or 0 local sargs, disp = {}, {} for i = 1, math.min(n, 12) do local sv = Serialize(rec.argv[i]) sargs[i] = { type = typeof(rec.argv[i]), value = sv } disp[i] = sv end local caller = rec.caller or (rec.cs and spyPathOf(rec.cs)) or "namecall" if caller == "?" then caller = "namecall" end local returned, returnCount, retError if rec.retv then if rec.retv[1] then returned = {} for i = 2, math.min(rec.retv.n or 1, 13) do returned[#returned + 1] = Serialize(rec.retv[i]) end returnCount = (rec.retv.n or 1) - 1 else retError = tostring(rec.retv[2]) end end calls[#calls + 1] = { path = full, method = rec.method, caller = caller, t = rec.t, args = sargs, argCount = n, returned = returned, returnCount = returnCount, retError = retError } local argStr = concat(disp, ", ") if #argStr > CONFIG.MAX_NET_ARGS then argStr = argStr:sub(1, CONFIG.MAX_NET_ARGS) .. "...[truncated]" end queueDropped += RingQueue.push(queue, { Level = rec.kind, Message = full .. ":" .. rec.method .. "(" .. argStr .. ")", Src = caller }) end return calls end) if okCalls and calls then send({ Method = "SpyCallBatch", Calls = calls }) end end if (TREE.n > 0 or TREE.dirty) and alive then if TREE.dirty then TREE.q, TREE.n, TREE.dirty = {}, 0, false local now = os.clock() if now - TREE.dirtyAt >= TREE.dirtyEvery then TREE.dirtyAt = now send({ Method = "TreeDelta", Dirty = true }) end else local nodes = TREE.q; TREE.q, TREE.n = {}, 0 TREE.dirtyAt = 0 send({ Method = "TreeDelta", Nodes = nodes }) end end if queueDropped > 0 and alive then local droppedNow = queueDropped queueDropped = 0 RingQueue.push(queue, { Level = "warn", Message = "[RBX Debugger] dropped " .. droppedNow .. " capture row(s) (queue cap)", Src = "netbridge" }) end if queue.size == 0 or not alive then return end -- A slice, not the lot: a flood leaves in paced frames of bounded -- size instead of one frame big enough for the relay to hang up on. local batch = RingQueue.take(queue, CONSOLE_BATCH) send({ Method = "ConsoleBatch", Lines = batch }) if queue.size > 0 then scheduleFlush() end end --[[ At most one flush per FLUSH_EVERY. It was deferred on every resumption, so a script printing in a loop sent frames as fast as the game could make them — past the relay's per-second limit, where replies were dropped along with the spam and the page read it as a dead connection. ]] local FLUSH_EVERY = 0.1 local lastFlushAt = 0 scheduleFlush = function() if flushScheduled or not alive or not uiPresent then return end flushScheduled = true local wait = FLUSH_EVERY - (os.clock() - lastFlushAt) local function run() lastFlushAt = os.clock() flush() end if wait > 0 then task.delay(wait, run) else task.defer(run) end end --[[ One line of output. A line longer than MAX_LINE is cut, saying how much was left out; the same line again is counted on the one already queued (the page shows it as "×N") instead of queued again — a print in a Heartbeat loop is one line with a climbing count, not thousands. ]] local MAX_LINE = 4000 --[[ Bytes that are not UTF-8 become "?". JSON cannot carry them, and one such line (binary printed, or a character cut in half by MAX_LINE) failed the encode of its whole batch and sent every line in it through the slow sanitising path instead. ]] local function validUtf8(s) if utf8.len(s) then return s end local parts, i = {}, 1 while i <= #s do local n, bad = utf8.len(s, i) if n then parts[#parts + 1] = string.sub(s, i) break end parts[#parts + 1] = string.sub(s, i, bad - 1) .. "?" i = bad + 1 end return table.concat(parts) end function queueConsole(level, msg, src) if not alive or not uiPresent then return end msg = tostring(msg) if #msg > MAX_LINE then msg = string.sub(msg, 1, MAX_LINE) .. " … [" .. (#msg - MAX_LINE) .. " more characters]" end msg = validUtf8(msg) local last = RingQueue.last(queue) if last and last.Message == msg and last.Level == level and last.Src == src then last.Count = (last.Count or 1) + 1 else queueDropped += RingQueue.push(queue, { Level = level, Message = msg, Src = src }) end scheduleFlush() end do local FREEZE_S = 1.0 local okRun, RunService = pcall(function() return game:GetService("RunService") end) if okRun and RunService then local last = os.clock() pcall(function() connections[#connections + 1] = RunService.Heartbeat:Connect(function() if not alive or not isCurrent() then return end local now = os.clock() local gap = now - last last = now if gap < FREEZE_S then return end local what = NB_LAST_EXEC if type(NB_LAST_EXEC_AT) ~= "number" or (now - NB_LAST_EXEC_AT) > gap + 1 then what = nil end queueConsole("warn", ("[RBX Debugger] the game thread was blocked for %.1fs%s"):format( gap, what and (" during " .. tostring(what)) or " (no debugger command was running)"), "watchdog") end) end) end end function TREE.markDirty() TREE.q, TREE.n, TREE.dirty = {}, 0, true scheduleFlush() end local function treePathAllowed(path) local root = type(path) == "string" and string.match(path, "^[^%.]+") or nil return root ~= nil and not TREE.internalRoots[root] end function TREE.onAdded(inst) if not alive or not uiPresent or not CONFIG.TRACK_TREE then return end if TREE.dirty then return end if TREE.n >= TREE.cap then TREE.markDirty(); return end local okP, parent = pcall(getParentOf, inst) if not okP or parent == nil then return end local ppath = spyPathOf(parent) if ppath == "?" or not treePathAllowed(ppath) then return end local okN, name = pcall(getNameOf, inst) local okC, cls = pcall(getClassNameOf, inst) TREE.n += 1 TREE.q[TREE.n] = { op = "+", parent = ppath, name = okN and name or "?", class = okC and cls or "?" } scheduleFlush() end function TREE.onRemoving(inst) if not alive or not uiPresent or not CONFIG.TRACK_TREE then return end if TREE.dirty then return end if TREE.n >= TREE.cap then TREE.markDirty(); return end local path = spyPathOf(inst) if path == "?" or not treePathAllowed(path) then return end TREE.n += 1 TREE.q[TREE.n] = { op = "-", path = path } scheduleFlush() end function TREE.setTracking(on) if on and not TREE.conns then local okA, addConn = pcall(function() return game.DescendantAdded:Connect(TREE.onAdded) end) local okR, remConn = pcall(function() return game.DescendantRemoving:Connect(TREE.onRemoving) end) if not (okA and addConn and okR and remConn) then if okA and addConn then pcall(function() addConn:Disconnect() end) end if okR and remConn then pcall(function() remConn:Disconnect() end) end return false, "tree signal connect failed" end TREE.conns = { addConn, remConn } connections[#connections + 1] = addConn connections[#connections + 1] = remConn elseif not on and TREE.conns then for _, c in TREE.conns do pcall(function() c:Disconnect() end) end TREE.conns = nil TREE.q, TREE.n, TREE.dirty = {}, 0, false end return true end Supervisor.register("tree", { retry = 1, desired = function() return CONFIG.TRACK_TREE == true end, verify = function() return TREE.conns ~= nil and TREE.conns[1] ~= nil and TREE.conns[2] ~= nil end, repair = function() return TREE.setTracking(true) end, disable = function() TREE.setTracking(false) end, }) --[[ What the page's diagnostics show. Counters only: nothing here names a room, a key, a URL or anything the game sent. ]] local function diagSnapshot() local features = {} for name, feature in pairs(Supervisor.features) do features[name] = feature.state end return { uptime = math.floor(os.clock() - Diag.connectedAt), framesOut = Diag.framesOut, bytesOut = Diag.bytesOut, framesIn = Diag.framesIn, bytesIn = Diag.bytesIn, commands = Diag.commands, rejected = Diag.rejected, chunked = Diag.chunked, oversizedOut = Diag.oversizedOut, unencodable = dropCount, sendQueue = Fifo.size(waitingFrames), captureDropped = queueDropped, hooks = #hookClaims, features = features, packetCapture = RakSpy.installed, packetError = RakSpy.error, packetFailed = RakSpy.failed, } end --[[ Who this is, and what the page needs before it sends anything: the protocol, whether this client has a key at all, and the nonce every command's MAC has to cover. Sent on connect, when a page comes back, and in answer to Hello, which is how a tab that opened later learns it. ]] local lastAuthAt = -math.huge local function sendAuthorization() lastAuthAt = os.clock() send({ Method = "Authorization", Name = Client.Name, Env = ProbeEnv(), Protocol = PROTOCOL_VERSION, Version = CLIENT_VERSION, Nonce = nonce, Keyed = SESSION_KEY ~= nil, Diag = diagSnapshot(), }) end sendAuthorization() --[[ What the person's own executor scripts print. Most executors send print and warn to their own console, not Roblox's, so LogService never hears them (Potassium only with "redirect output" on) and the Debugger showed nothing a script printed. So print and warn are wrapped, once per game: the wrapper lives in getgenv and outlives this copy of the Debugger, and each session plugs its console into the wrapper's slot and unplugs on the way out, so running the loadstring again never stacks wrappers. A line that also arrives through LogService (an executor that does redirect) is shown once. ]] local forwarded, forwardedN = {}, 0 local function printBody(level, ...) local n = select("#", ...) local parts = table.create(n) for i = 1, n do parts[i] = tostring((select(i, ...))) end local msg = table.concat(parts, " ") -- Only a redirecting executor ever consumes these; bounded for the rest. forwardedN += 1 if forwardedN > 512 then table.clear(forwarded); forwardedN = 0 end forwarded[msg] = os.clock() queueConsole(level, msg, nil) end pcall(function() local genv = getgenv() local slot = genv.__NB_PRINT if type(slot) ~= "table" then slot = {} local origPrint, origWarn = genv.print, genv.warn if type(origPrint) == "function" then genv.print = function(...) local body = slot.body if body then pcall(body, "print", ...) end return origPrint(...) end end if type(origWarn) == "function" then genv.warn = function(...) local body = slot.body if body then pcall(body, "warn", ...) end return origWarn(...) end end genv.__NB_PRINT = slot end slot.body = printBody end) local logService = Services.LogService if logService then pcall(function() connections[#connections + 1] = logService.MessageOut:Connect(function(message, messageType) if not alive or not isCurrent() or not uiPresent then return end local at = forwarded[message] if at then forwarded[message] = nil if os.clock() - at < 2 then return end end local level = messageType == Enum.MessageType.MessageWarning and "warn" or messageType == Enum.MessageType.MessageError and "error" or "print" queueConsole(level, message, nil) end) end) end --[[ packetMode: Remote Spy is reading remote calls from packets (rakspy, below), so the Luau hooks that did the same job stand down: no FireServer/InvokeServer capture through __namecall, no hooks on the game's incoming listeners. Only once the packet hooks are actually in place; until then, or if they cannot be, the Luau hooks carry on. ]] local packetMode = false local netCaptureOn, blockOn, overrideOn, httpOn, hookActive = false, false, false, false, false local function recomputeFlags() packetMode = CONFIG.RAKNET_SPY == true and RakSpy.installed == true netCaptureOn = CONFIG.CAPTURE_NET == true and (not packetMode or CONFIG.SPY_ALL == true) httpOn = CONFIG.CAPTURE_HTTP == true blockOn = CONFIG.BLOCK ~= nil and next(CONFIG.BLOCK) ~= nil overrideOn = CONFIG.HTTP_OVERRIDES ~= nil and next(CONFIG.HTTP_OVERRIDES) ~= nil hookActive = netCaptureOn or httpOn or blockOn or overrideOn end recomputeFlags() local namecallInstalled = false local namecallBody = nil local function installNamecallHook() recomputeFlags() if namecallInstalled then return true end local hookmeta = Caps.acquire("hookmetamethod", true) local getNamecall = Caps.acquire("getnamecallmethod", true) if type(hookmeta) ~= "function" or type(getNamecall) ~= "function" then error("namecall dependencies unavailable", 0) end local getCallingScript = Caps.acquire("getcallingscript", false) local lastByMethod = {} local inHook = false local function isBlocked(self, method) if not (CONFIG.BLOCK and next(CONFIG.BLOCK)) then return false end if method ~= "FireServer" and method ~= "InvokeServer" then return false end local full = spyPathOf(self) if full == "?" then return false end for pat in pairs(CONFIG.BLOCK) do if full:find(pat, 1, true) then queueConsole("blocked", full .. ":" .. method .. "() [BLOCKED]", "block") return true end end return false end local callerFns = (getgenv and getgenv().__NB_callerFns) if not callerFns then callerFns = setmetatable({}, { __mode = "kv" }) if getgenv then pcall(function() getgenv().__NB_callerFns = callerFns end) end end local getInfoFn = debug and debug.getinfo local ownSource = nil local function grabCallerFn() if not getInfoFn then return nil end if ownSource == nil then local ok, info = pcall(getInfoFn, 1) ownSource = (ok and type(info) == "table" and info.source) or false end for lvl = 3, 8 do local ok, info = pcall(getInfoFn, lvl) if not ok or type(info) ~= "table" then break end if type(info.func) == "function" and info.what ~= "C" and not (ownSource and info.source == ownSource) then return info.func end end return nil end local tickStart, tickCount = 0, 0 local function packArgs(...) local n = select("#", ...) local a = { n = n } for i = 1, math.min(n, 12) do a[i] = select(i, ...) end return a end local function capture(self, method, ...) local kind if not CONFIG.CAPTURE_NET or not uiPresent then return elseif method == "FireServer" or method == "InvokeServer" then if packetMode then return end kind = "net" elseif method == "Fire" or method == "Invoke" then -- BindableEvent / BindableFunction: never on the network, so -- packets cannot see them; only this hook can. local okC, cls = pcall(getClassNameOf, self) if not okC or (cls ~= "BindableEvent" and cls ~= "BindableFunction") then return end kind = "net" elseif CONFIG.SPY_ALL and not (CONFIG.SPY_DENY and CONFIG.SPY_DENY[method]) then local now = os.clock() if (now - (lastByMethod[method] or 0)) < CONFIG.SPY_RATE then return end if (now - tickStart) >= CONFIG.SPY_TICK then tickStart = now; tickCount = 0 end if tickCount >= CONFIG.SPY_MAX_PER_TICK then return end tickCount += 1 lastByMethod[method] = now kind = "namecall" else return end local cs = nil if getCallingScript then local okC, c = pcall(getCallingScript); if okC then cs = c end end if not alive then return end pushSpy({ kind = kind, self = self, method = method, cs = cs, argv = packArgs(...), t = os.time() }) scheduleFlush() end local function captureInvokeReturn(self, ...) local co = coroutine.running() local argv = table.pack(...) local cs = nil if getCallingScript then local okC, c = pcall(getCallingScript); if okC then cs = c end end task.spawn(function() local fn; pcall(function() fn = self.InvokeServer end) local packed if type(fn) == "function" then packed = table.pack(pcall(fn, self, table.unpack(argv, 1, argv.n))) else packed = table.pack(false, "InvokeServer unavailable") end if alive then pushSpy({ kind = "net", self = self, method = "InvokeServer", cs = cs, argv = argv, t = os.time(), retv = packed }) scheduleFlush() end if packed[1] then task.spawn(co, table.unpack(packed, 2, packed.n)) else task.spawn(co) end end) return coroutine.yield() end local old local hookBody = function(self, ...) if inHook or spySuppress then return old(self, ...) end if not hookActive then return old(self, ...) end inHook = true local okM, method = pcall(getNamecall) local httpCap = okM and method and HTTP_METHODS[method] and (httpOn or overrideOn) if okM and method then if blockOn then local okB, blocked = pcall(isBlocked, self, method) if okB and blocked then inHook = false; return end end if netCaptureOn and callerFns[self] == nil and (method == "FireServer" or method == "InvokeServer") then local cf = grabCallerFn() if cf then pcall(rawset, callerFns, self, cf) end end if method == "InvokeServer" and netCaptureOn and not packetMode and CONFIG.CAPTURE_RETURNS and not httpCap then inHook = false return captureInvokeReturn(self, ...) end if netCaptureOn then pcall(capture, self, method, ...) end end if httpCap then local reqArg = (...) local url = (type(reqArg) == "table" and (reqArg.Url or reqArg.url)) or reqArg if ownUrl(url) then inHook = false; return old(self, ...) end local ov = matchHttpOverride(url) local cs = nil if getCallingScript then local okC, c = pcall(getCallingScript); if okC then cs = c end end inHook = false local t0 = os.clock() if ov then local fakeResp = (method == "RequestAsync") and overrideResponse(ov) or (type(ov.body) == "string" and ov.body or "") queueHttpSpec({ source = method, reqArg = reqArg, resVal = fakeResp, t0 = t0, endT = os.clock(), cs = cs, overridden = true, ovStatus = tostring(ov.status or 200) }) scheduleFlush() return fakeResp end if not httpOn then return old(self, ...) end if HTTP_WRAP[method] then local packed = table.pack(pcall(old, self, ...)) if not packed[1] then error(packed[2], 0) end queueHttpSpec({ source = method, reqArg = reqArg, resVal = packed[2], t0 = t0, endT = os.clock(), cs = cs }) scheduleFlush() return table.unpack(packed, 2, packed.n) end queueHttpSpec({ source = method, reqArg = reqArg, resVal = nil, t0 = t0, endT = os.clock(), cs = cs }) scheduleFlush() return old(self, ...) end inHook = false return old(self, ...) end local shared = getgenv().__NB_NC if shared and shared.orig then old = shared.orig shared.body = hookBody namecallBody = hookBody namecallInstalled = true return true end local okH = pcall(function() local slot = { orig = nil, body = hookBody } local trampoline = function(self, ...) local body = slot.body if not body then return slot.orig(self, ...) end return body(self, ...) end old = hookmeta(game, "__namecall", trampoline) slot.orig = old getgenv().__NB_NC = slot namecallBody = hookBody end) if okH and old then namecallInstalled = true return true end error("failed to install __namecall trampoline", 0) end Supervisor.register("namecall", { retry = 1, desired = function() return hookActive end, verify = function() local slot = ENV.__NB_NC return namecallInstalled and namecallBody ~= nil and slot ~= nil and slot.orig ~= nil and slot.body == namecallBody end, repair = function() Caps.invalidate("hookmetamethod") Caps.invalidate("getnamecallmethod") namecallInstalled = false return installNamecallHook() end, disable = function() local slot = ENV.__NB_NC if slot and slot.body == namecallBody then slot.body = nil end namecallInstalled = false end, }) Supervisor.ensure("namecall", "connect") local inboundInstalled = false local inboundClaims = {} local inboundAddConn = nil local function installInboundCapture() if inboundInstalled or not CONFIG.CAPTURE_NET then return true end local getconnections = Caps.acquire("getconnections", true) local getCb = Caps.acquire("getcallbackvalue", true) if type(getconnections) ~= "function" then error("getconnections unavailable", 0) end if type(getCb) ~= "function" then error("getcallbackvalue unavailable", 0) end local seen = setmetatable({}, { __mode = "k" }) local function noteInbound(name, label, ...) if not CONFIG.CAPTURE_NET or not alive then return end pushSpy({ kind = "net_in", path = name, method = label, caller = "network", argv = table.pack(...), t = os.time() }) scheduleFlush() end local function hookConnections(signal, name, label) if type(getconnections) ~= "function" or type(oth.hook) ~= "function" then error("inbound hook dependencies unavailable", 0) end local ok, conns = pcall(getconnections, signal) if not ok or not conns then return false end for _, c in conns do local okF, fn = pcall(function() return c.Function end) if okF and type(fn) == "function" and not seen[fn] then seen[fn] = true local body = function(orig, ...) pcall(noteInbound, name, label, ...) return orig(...) end local okH, claim = pcall(claimOthHook, fn, body) if okH and claim then hookClaims[#hookClaims + 1] = claim inboundClaims[#inboundClaims + 1] = claim end end end return true end local function watch(remote) if not CONFIG.CAPTURE_NET or not alive then return end if seen[remote] then return end seen[remote] = true local okCls, cls = pcall(getClassNameOf, remote) cls = okCls and cls or "?" local name = spyPathOf(remote) -- Reading a signal's connections and hooking each listener are -- natives that have crashed clients; under a breadcrumb, a remote -- that did so is left alone next time instead of doing it again. if cls == "RemoteEvent" or cls == "UnreliableRemoteEvent" then Crash.guard("inbound-hooks", name, hookConnections, remote.OnClientEvent, name, "OnClientEvent") elseif cls == "RemoteFunction" then local okCb, cb = pcall(getCb, remote, "OnClientInvoke") if okCb and type(cb) == "function" and not seen[cb] then seen[cb] = true local body = function(orig, ...) pcall(noteInbound, name, "OnClientInvoke", ...) return orig(...) end local okH, claim = pcall(claimOthHook, cb, body) if okH and claim then hookClaims[#hookClaims + 1] = claim inboundClaims[#inboundClaims + 1] = claim end end end end task.spawn(function() local ok, kids = pcall(function() return game:GetDescendants() end) if ok then local i = 0 for _, inst in kids do if not alive or not CONFIG.CAPTURE_NET then break end i += 1 if i % 500 == 0 then task.wait() end local c = inst.ClassName if c == "RemoteEvent" or c == "RemoteFunction" or c == "UnreliableRemoteEvent" then watch(inst) end end end end) local addConn = game.DescendantAdded:Connect(function(inst) local c = inst.ClassName if c == "RemoteEvent" or c == "RemoteFunction" or c == "UnreliableRemoteEvent" then task.defer(watch, inst) end end) connections[#connections + 1] = addConn inboundAddConn = addConn inboundInstalled = true return true end Supervisor.register("inbound", { retry = 1, desired = function() return CONFIG.CAPTURE_NET == true and not packetMode end, verify = function() return inboundInstalled == true end, repair = function() Caps.invalidate("getconnections") Caps.invalidate("getcallbackvalue") return installInboundCapture() end, disable = function() for i = #inboundClaims, 1, -1 do releaseHookClaim(inboundClaims[i]) end inboundClaims = {} if inboundAddConn then pcall(function() inboundAddConn:Disconnect() end) end inboundAddConn = nil inboundInstalled = false end, }) Supervisor.ensure("inbound", "connect") local httpHooksInstalled = false local httpHookTarget = nil local httpHookClaim = nil local function installHttpCapture() if httpHooksInstalled then return true end HttpRequest = Caps.acquire("request", true) local hookfn = Caps.acquire("hookfunction", true) if type(HttpRequest) ~= "function" or type(hookfn) ~= "function" then error("HTTP capture dependencies unavailable", 0) end local seen = {} local function extract(a, b) if type(a) == "table" and (a.Url or a.url) then return a.Url or a.url, (a.Method or a.method or "GET"), (type(a.Body) == "string" and #a.Body or 0) elseif type(a) == "string" then return a, "GET", 0 elseif type(b) == "string" then return b, "GET", 0 elseif type(b) == "table" and (b.Url or b.url) then return b.Url or b.url, (b.Method or b.method or "GET"), (type(b.Body) == "string" and #b.Body or 0) end return nil end local function record(a, b, t0, res) if not CONFIG.CAPTURE_HTTP then return end local url, method, reqBytes = extract(a, b) if type(url) ~= "string" then return end if url:find("localhost", 1, true) or url:find("127.0.0.1", 1, true) then return end reqBytes = reqBytes or 0 local r = res[1] local status, respBytes = "?", 0 if type(r) == "table" then status = tostring(r.StatusCode or r.Status or r.status_code or "200") if type(r.Body) == "string" then respBytes = #r.Body end elseif type(r) == "string" then respBytes = #r; status = "200" end local ms = math.floor((os.clock() - t0) * 1000) queueConsole("http", ("%s %s -> %s %dms req:%dB resp:%dB"):format(tostring(method):upper(), url, status, ms, reqBytes, respBytes), "http") local reqArg = (type(a) == "table" and (a.Url or a.url)) and a or (type(a) == "string" and a) or b queueHttp(buildHttp("request", reqArg, r, t0, nil)) scheduleFlush() end local function requestBody(orig, ...) local a = (...) local url = (type(a) == "table" and (a.Url or a.url)) or (type(a) == "string" and a) or (select(2, ...)) if ownUrl(url) then return orig(...) end local ov = matchHttpOverride(url) if ov then local resp = overrideResponse(ov) pcall(function() local rec = buildHttp("request", a, resp, os.clock(), nil) if rec then rec.overridden = true end queueHttp(rec); scheduleFlush() end) return resp end local t0 = os.clock() local res = table.pack(orig(...)) pcall(record, (...), (select(2, ...)), t0, res) return table.unpack(res, 1, res.n) end local function hookReq(fn) if type(fn) ~= "function" or seen[fn] then return nil end seen[fn] = true local claim = claimFunctionHook(fn, requestBody) hookClaims[#hookClaims + 1] = claim return claim end httpHookClaim = hookReq(HttpRequest) if not httpHookClaim then error("failed to claim request hook", 0) end httpHookTarget = HttpRequest httpHooksInstalled = true return true end Supervisor.register("http", { retry = 1, desired = function() return httpOn or overrideOn end, verify = function() return httpHooksInstalled and httpHookClaim ~= nil and httpHookClaim.slot.body == httpHookClaim.body and Caps.get("request") == httpHookTarget end, repair = function() Caps.invalidate("request") Caps.invalidate("hookfunction") releaseHookClaim(httpHookClaim) httpHookClaim = nil httpHooksInstalled = false httpHookTarget = nil return installHttpCapture() end, disable = function() releaseHookClaim(httpHookClaim) httpHookClaim = nil httpHooksInstalled = false httpHookTarget = nil end, }) Supervisor.ensure("http", "connect") local raknet = nil local raknetHook = nil local function installRaknet() if raknetHook then return true end raknet = Caps.acquire("raknet", true) if type(raknet) ~= "table" or type(raknet.add_send_hook) ~= "function" or type(raknet.remove_send_hook) ~= "function" then error("raknet add/remove hook capability unavailable", 0) end local lastSent = 0 local b64 = crypt and crypt.base64encode raknetHook = function(packet) if not CONFIG.CAPTURE_RAKNET then return end local okId, id = pcall(function() return packet.PacketId end) if not okId or id == nil then return end if CONFIG.RAKNET_IDS and not CONFIG.RAKNET_IDS[id] then return end if CONFIG.RAKNET_DENY and CONFIG.RAKNET_DENY[id] then return end local now = os.clock() if now - lastSent < CONFIG.RAKNET_RATE then return end lastSent = now local ok, size, str, pri, rel = pcall(function() return packet.Size, packet.AsString, packet.Priority, packet.Reliability end) if not ok then size, str, pri, rel = nil, nil, nil, nil end size = size or 0 local data if type(str) == "string" then if #str > CONFIG.RAKNET_MAX_BYTES then str = str:sub(1, CONFIG.RAKNET_MAX_BYTES) end data = b64 and (select(2, pcall(b64, str)) or nil) or nil end queueConsole("raknet", ("id=0x%X size=%d pri=%s rel=%s"):format(id, size, tostring(pri or "?"), tostring(rel or "?")) .. (data and (" b64=" .. data) or ""), "raknet") end local ok, err = pcall(raknet.add_send_hook, raknetHook) if not ok then raknetHook = nil; error(tostring(err), 0) end return true end Supervisor.register("raknet", { retry = 1, desired = function() return CONFIG.CAPTURE_RAKNET == true end, verify = function() return raknetHook ~= nil end, repair = function() Caps.invalidate("raknet") if raknetHook and raknet and raknet.remove_send_hook then pcall(raknet.remove_send_hook, raknetHook) end raknetHook = nil return installRaknet() end, disable = function() if raknetHook and raknet and raknet.remove_send_hook then pcall(raknet.remove_send_hook, raknetHook) end raknetHook = nil end, }) Supervisor.ensure("raknet", "connect") --[[ Remote Spy over RakNet ("Capture via packets" in Remote Spy). Remote calls travel inside Roblox's replication packet (0x83, ID_DATA) as invocation items. With the executor's raknet send and receive hooks on that packet, every FireServer / InvokeServer leaving the client and every OnClientEvent / OnClientInvoke arriving is read from the bytes, however the script made the call and without hooking any Luau function. The decoder (services/client/rakspy.lua) is fetched from the site that served this client the first time it is needed and kept in getgenv for the rest of the game. What is lost: a packet does not say which script sent it, so rows read "packet" as their caller. RemoteFunction replies are matched to their requests by invoke id; with return capture on, an InvokeServer row waits for its reply (or 15 seconds) like the Luau path does. ]] do local PENDING_S = 15 local sent, received = {}, {} -- [instance][invokeId] = entry local function track(map, inst, id, entry) local per = map[inst] if not per then per = {}; map[inst] = per end per[id] = entry end local function take(map, inst, id) local per = map[inst] local entry = per and per[id] if entry then per[id] = nil; if next(per) == nil then map[inst] = nil end end return entry end local function has(map, inst, id) local per = map[inst] return per ~= nil and per[id] ~= nil end local function prune(now) for _, map in { sent, received } do for inst, per in map do for id, entry in per do if now - entry.at > PENDING_S then per[id] = nil if entry.emit then entry.emit(nil) end end end if next(per) == nil then map[inst] = nil end end end end local function argvOf(call) local n = math.min(call.n or 0, 12) local argv = { n = call.n or 0 } for i = 1, n do argv[i] = call.args[i] end return argv end local function loadDecoder() local cached = ENV.__NB_RAKDEC if type(cached) == "table" and cached.version == CLIENT_VERSION and type(cached.make) == "function" then return cached.make end if not CONFIG.ORIGIN then error("no site to fetch the packet decoder from", 0) end local requestFn = Caps.acquire("request", true) local url = CONFIG.ORIGIN .. "/netbridge/rakspy.lua?v=" .. CLIENT_VERSION local ok, res = pcall(requestFn, { Url = url, Method = "GET" }) if not ok or type(res) ~= "table" or type(res.Body) ~= "string" or (res.StatusCode ~= nil and res.StatusCode ~= 200) then error("could not download the packet decoder from " .. CONFIG.ORIGIN, 0) end local chunk, err = OrigLoadstring(res.Body, "=rakspy") if not chunk then error("the packet decoder did not compile: " .. tostring(err), 0) end local okMake, make = pcall(chunk) if not okMake or type(make) ~= "function" then error("the packet decoder did not load", 0) end ENV.__NB_RAKDEC = { version = CLIENT_VERSION, make = make } return make end --[[ A packet names a remote by (peer, instance id). The executor may resolve that itself; otherwise every instance is indexed by its debug id, which has the same shape, and the index follows the game. The first pass yields between slices so a big place does not stall the frame it starts in. ]] local function buildLookup() local lib = Caps.get("raknet") local byId = type(lib) == "table" and lib.get_instance_by_id local okE, execName = pcall(function() return (identifyexecutor()) end) if type(byId) == "function" and not (okE and execName == "Synapse Z") then return function(peer, id) local ok, inst = pcall(byId, peer, id) return ok and inst or nil end end local index = {} RakSpy.index = index local function add(inst) local ok, key = pcall(inst.GetDebugId, inst) if ok and type(key) == "string" then index[key] = inst end end local function remove(inst) local ok, key = pcall(inst.GetDebugId, inst) if ok and type(key) == "string" then index[key] = nil end end RakSpy.conns = { game.DescendantAdded:Connect(add), game.DescendantRemoving:Connect(remove), } task.spawn(function() local lists = { game:GetDescendants() } local getNil = Caps.get("getnilinstances") if type(getNil) == "function" then local okN, nils = pcall(getNil) if okN and type(nils) == "table" then lists[2] = nils end end local n = 0 for _, list in lists do for _, inst in list do if RakSpy.index ~= index then return end add(inst) n += 1 if n % 3000 == 0 then task.wait() end end end end) return function(peer, id) return index[string.format("%d_%d", peer, id)] end end local function emitOut(inst, method, argv, retv) pushSpy({ kind = "net", self = inst, method = method, caller = "packet", argv = argv, t = os.time(), retv = retv }) scheduleFlush() end local function emitIn(inst, label, argv) pushSpy({ kind = "net_in", self = inst, path = spyPathOf(inst), method = label, caller = "packet", argv = argv, t = os.time() }) scheduleFlush() end local function onPacket(outgoing, msg) if not alive or not CONFIG.CAPTURE_NET or not uiPresent or not RakSpy.decoder then return end local okId, id = pcall(function() return msg.PacketId end) if not okId or id ~= 0x83 then return end local okB, data = pcall(function() return msg.AsBuffer end) if not okB or typeof(data) ~= "buffer" then local okS, str = pcall(function() return msg.AsString end) if not okS or type(str) ~= "string" then return end data = buffer.fromstring(str) end -- Which RemoteFunction items are replies: one going out answers -- the server's invoke of us; one coming in answers ours. local pendingMap = outgoing and received or sent local okP, calls = pcall(RakSpy.decoder.parse, data, function(inst, invokeId) return has(pendingMap, inst, invokeId) end) if not okP or type(calls) ~= "table" then RakSpy.failed += 1 return end local now = os.clock() prune(now) for _, call in calls do local inst = call.instance if call.kind == "event" then if outgoing then emitOut(inst, "FireServer", argvOf(call)) else emitIn(inst, "OnClientEvent", argvOf(call)) end elseif outgoing then if call.isResponse then take(received, inst, call.invokeId) -- our reply to OnClientInvoke else local argv = argvOf(call) if CONFIG.CAPTURE_RETURNS then track(sent, inst, call.invokeId, { at = now, emit = function(retv) emitOut(inst, "InvokeServer", argv, retv) end }) else track(sent, inst, call.invokeId, { at = now }) emitOut(inst, "InvokeServer", argv) end end else if call.isResponse then local entry = take(sent, inst, call.invokeId) if entry and entry.emit then local retv if call.error then retv = table.pack(false, call.error) else retv = table.pack(true, table.unpack(call.args, 1, call.n or 0)) end entry.emit(retv) end else track(received, inst, call.invokeId, { at = now }) emitIn(inst, "OnClientInvoke", argvOf(call)) end end end end local function uninstall() local lib = RakSpy.lib if lib and RakSpy.sendHook then pcall(lib.remove_send_hook, RakSpy.sendHook) end if lib and RakSpy.recvHook then pcall(lib.remove_receive_hook, RakSpy.recvHook) end RakSpy.sendHook, RakSpy.recvHook = nil, nil for _, c in RakSpy.conns or {} do pcall(function() c:Disconnect() end) end RakSpy.conns, RakSpy.index = nil, nil RakSpy.decoder = nil RakSpy.installed = false table.clear(sent); table.clear(received) end RakSpy.uninstall = uninstall local function install() if RakSpy.installed then return true end local lib = Caps.acquire("raknet", true) if type(lib) ~= "table" or type(lib.add_send_hook) ~= "function" or type(lib.add_receive_hook) ~= "function" or type(lib.remove_send_hook) ~= "function" or type(lib.remove_receive_hook) ~= "function" then error("this executor has no raknet send and receive hooks", 0) end if type(lib.is_enabled) == "function" then local okEn, on = pcall(lib.is_enabled) if okEn and on == false then error("the executor's raknet hooks are turned off", 0) end end local make = loadDecoder() local schema if type(lib.get_network_schema) == "function" then local okS, sch = pcall(lib.get_network_schema) if okS and type(sch) == "table" then schema = sch end end RakSpy.decoder = make({ schema = schema, lookup = buildLookup() }) RakSpy.lib = lib local sendFn = function(msg) pcall(onPacket, true, msg) end local recvFn = function(msg) pcall(onPacket, false, msg) end local okS, sh = pcall(lib.add_send_hook, sendFn) local okR, rh = pcall(lib.add_receive_hook, recvFn) if not okS or not okR then if okS then pcall(lib.remove_send_hook, sh ~= nil and sh or sendFn) end if okR then pcall(lib.remove_receive_hook, rh ~= nil and rh or recvFn) end uninstall() error("the executor refused a raknet hook", 0) end -- Some executors return a handle to remove by, some expect the function. RakSpy.sendHook = sh ~= nil and sh or sendFn RakSpy.recvHook = rh ~= nil and rh or recvFn RakSpy.installed = true RakSpy.error = nil return true end Supervisor.register("rakspy", { -- A missing executor function or a failed download will not fix -- itself in a second; try again every half minute, not every second. retry = 30, desired = function() return CONFIG.RAKNET_SPY == true and CONFIG.CAPTURE_NET == true end, verify = function() return RakSpy.installed == true end, repair = function() local ok, err = pcall(install) -- The page shows whether this took; tell it now, not on its next poll. if not ok then RakSpy.error = tostring(err) sendAuthorization() error(err, 0) end sendAuthorization() recomputeFlags() Supervisor.ensure("namecall", "rakspy") Supervisor.ensure("inbound", "rakspy") return true end, disable = function() if not RakSpy.installed and not RakSpy.conns then return end uninstall() recomputeFlags() Supervisor.ensure("namecall", "rakspy") Supervisor.ensure("inbound", "rakspy") end, }) end Supervisor.ensure("rakspy", "connect") Supervisor.ensureAll("connect-ready") local function report(execId, errMsg) send({ Method = "Error", Message = errMsg, _execId = execId }) end local lastPong = os.clock() -- Forward-declared: a Disconnect command and pingTick both end the -- session through it, and its definition below needs `send` and `flush`. local sayBye --[[ A command turned away. Whoever sent it with an id is answered with why, so the page says so instead of waiting out a timeout; the executor console gets one line per kind of refusal every few seconds. ]] local refusalWarnAt = {} local function refuse(kind, execId, message) Diag.rejected[kind] = (Diag.rejected[kind] or 0) + 1 if type(execId) == "string" and #execId <= Guard.LIMITS.EXEC_ID then send({ Method = "Error", _execId = execId, Message = message, Refused = kind }) end local now = os.clock() if now - (refusalWarnAt[kind] or -math.huge) > 5 then refusalWarnAt[kind] = now queueConsole("warn", "[RBX Debugger] refused a command (" .. kind .. "): " .. message, "netbridge") end end -- The id a frame names, read without trusting (or fully parsing) it. local function execIdIn(json) local id = string.match(json, '"_execId"%s*:%s*"([^"\\]*)"') return id end --[[ Frames with no signature: the relay's own (it drops any a viewer tries to pass off as one), its Pong, and Hello, which asks for the Authorization frame a tab needs before it can sign anything. Hello is answered at most twice a second; it only repeats what this client already announces on connect. ]] local function handleUnsigned(parsed) if parsed.Method == "Pong" then return end if parsed.Method == "Hello" then if os.clock() - lastAuthAt >= 0.5 then sendAuthorization() end return end if parsed.__relay and parsed.__relay.type == "replaced" then -- Only ever sent to a socket the relay considered stale, so this is -- a real takeover rather than this client racing its own redial. OrigWarn("[RBX Debugger] Another client took over this room. Stopping.") getgenv().__BridgeGen = (getgenv().__BridgeGen or MY_GEN) + 1 alive = false pcall(function() Socket:Close() end) return end if parsed.__relay and parsed.__relay.type == "duplicate" then --[[ This game is already attached on a socket the relay can still hear from, so THIS connection is the redundant one -- almost always our own previous socket's replacement arriving before the old one was reaped, or the loadstring run twice. Standing down silently is deliberate. The session the user is actually using is the incumbent and it is unaffected; warning here would report a problem that does not exist -- which is exactly what the old "Another client took over" notice did. The retry is slowed rather than stopped: redialing every 5s would just walk into the same refusal, while giving up would strand this client if the incumbent later goes away. ]] alive = false duplicateUntil = os.clock() + DUPLICATE_BACKOFF pcall(function() Socket:Close() end) return end if parsed.__relay and parsed.__relay.type == "presence" then local nowPresent = parsed.__relay.ui == true if nowPresent ~= uiPresent then uiPresent = nowPresent if not uiPresent then RingQueue.clear(spyRaw) RingQueue.clear(httpRaw) RingQueue.clear(httpSpec) RingQueue.clear(queue) if TREE.conns then TREE.q, TREE.n, TREE.dirty = {}, 0, true end else sendAuthorization() Supervisor.ensureAll("presence-resume") scheduleFlush() end end return end if parsed.__relay then return end if type(parsed.Method) == "string" then refuse("auth", parsed._execId, "the page sent an unsigned command. Reload the Debugger tab; if this game's loadstring is older than the page, run the new one.") end end local function handleCommand(parsed) Diag.commands += 1 if parsed.Method == "Hello" then if os.clock() - lastAuthAt >= 0.5 then sendAuthorization() end return end if parsed.Method == "Disconnect" then --[[ The page ended the session. Everything this connection hooked is released on the way out (the cleanup after `wake` below), and `leaving` stops the redial; running the loadstring again is the way back in. ]] pcall(OrigWarn, "[RBX Debugger] The session was ended from the browser. Run the loadstring again to reconnect.") if parsed._execId then send({ Method = "Result", Data = "disconnected", _execId = parsed._execId }) end sayBye("ended") return end if parsed.Method == "Config" then if type(parsed.SpyAll) == "boolean" then CONFIG.SPY_ALL = parsed.SpyAll end if type(parsed.CaptureNet) == "boolean" then CONFIG.CAPTURE_NET = parsed.CaptureNet end if type(parsed.CaptureHttp) == "boolean" then CONFIG.CAPTURE_HTTP = parsed.CaptureHttp end if type(parsed.CaptureRaknet) == "boolean" then CONFIG.CAPTURE_RAKNET = parsed.CaptureRaknet end if type(parsed.CaptureReturns) == "boolean" then CONFIG.CAPTURE_RETURNS = parsed.CaptureReturns end if type(parsed.RaknetSpy) == "boolean" then CONFIG.RAKNET_SPY = parsed.RaknetSpy end if type(parsed.TrackTree) == "boolean" then CONFIG.TRACK_TREE = parsed.TrackTree Supervisor.ensure("tree", "config") end if type(parsed.Block) == "table" then local b = {} for _, p in parsed.Block do if type(p) == "string" then b[p] = true end end CONFIG.BLOCK = b end if type(parsed.HttpOverrides) == "table" then CONFIG.HTTP_OVERRIDES = parsed.HttpOverrides end -- Packets first: whether it took decides whether the Luau hooks run. Supervisor.ensure("rakspy", "config") recomputeFlags() Supervisor.ensure("namecall", "config") Supervisor.ensure("http", "config") Supervisor.ensure("raknet", "config") Supervisor.ensure("inbound", "config") return end if parsed.Method == "ReplayHttp" then local req = parsed.req local execId = parsed._execId local function reply(out) if not execId then return end local okE, enc = pcall(JSON.JSONEncode, JSON, out) if not okE then enc = "{\"ok\":false,\"error\":\"response not encodable\"}" end send({ Method = "Result", Data = enc, _execId = execId }) end if type(req) ~= "table" or type(req.url) ~= "string" then reply({ ok = false, error = "bad request" }); return end local okReq, requestFn = pcall(Caps.acquire, "request", true) if not okReq or type(requestFn) ~= "function" then reply({ ok = false, error = "request capability unavailable" }); return end task.spawn(function() local opts = { Url = req.url, Method = req.method or "GET", Headers = req.headers, Body = req.body, Cookies = req.cookies } local t0 = os.clock() local ok, res = pcall(requestFn, opts) if ok and type(res) == "table" then reply({ ok = true, status = tostring(res.StatusCode or "?"), statusMessage = res.StatusMessage and tostring(res.StatusMessage) or nil, success = res.Success, headers = safeHeaders(res.Headers), body = httpSafe(res.Body, 65536), respBytes = type(res.Body) == "string" and #res.Body or 0, ms = math.floor((os.clock() - t0) * 1000) }) elseif ok and type(res) == "string" then reply({ ok = true, status = "200", body = httpSafe(res, 65536), respBytes = #res, ms = math.floor((os.clock() - t0) * 1000) }) else reply({ ok = false, error = tostring(res) }) end end) return end if parsed.Method == "FireRemote" then local execId = parsed._execId local method = parsed.method == "InvokeServer" and "InvokeServer" or "FireServer" local reqPath = tostring(parsed.path or "") local argsLua = type(parsed.argsLua) == "string" and parsed.argsLua or "" local function reply(out) if not execId then return end local okE, enc = pcall(JSON.JSONEncode, JSON, out) if not okE then enc = "{\"ok\":false,\"error\":\"result not encodable\"}" end send({ Method = "Result", Data = enc, _execId = execId }) end local function fireResolve(path) if path == "" then return nil end local segs = string.split(path, ".") local first = table.remove(segs, 1) local okS, cur = pcall(game.GetService, game, first) if not okS or not cur then return nil end for _, segment in segs do cur = cur:FindFirstChild(segment) if not cur then return nil end end return cur end local okPrep, result = withSpySuppressed(function() local remote = fireResolve(reqPath) if not remote then return { err = "remote not found: " .. reqPath } end local full = spyPathOf(remote); if not full or full == "?" then full = reqPath end local argv, argn = {}, 0 if #argsLua > 0 then local af = OrigLoadstring("return " .. argsLua) if not af then return { err = "bad args expression" } end InjectEnv(af) local packed = pack(pcall(af)) if not packed[1] then return { err = "bad args: " .. tostring(packed[2]) } end for i = 2, packed.n do argv[i - 1] = packed[i] end argn = packed.n - 1 end local sargs = {} for i = 1, math.min(argn, 12) do sargs[i] = { type = typeof(argv[i]), value = Serialize(argv[i]) } end pcall(send, { Method = "SpyCallBatch", Calls = { { path = full, method = method, caller = "▶ you (fired)", t = os.time(), args = sargs, argCount = argn } } }) return { remote = remote, full = full, argv = argv, argn = argn } end) if not okPrep then reply({ ok = false, error = "fire prep failed: " .. tostring(result) }); return end if result.err then reply({ ok = false, error = result.err }); return end local remote, full, argv, argn = result.remote, result.full, result.argv, result.argn task.spawn(function() local fn = remote[method] local packed = pack(pcall(fn, remote, table.unpack(argv, 1, argn))) if not packed[1] then reply({ ok = false, method = method, path = full, error = tostring(packed[2]) }); return end local rets = {} for i = 2, packed.n do rets[i - 1] = Serialize(packed[i]) end reply({ ok = true, method = method, path = full, returned = rets, returnCount = packed.n - 1 }) end) return end if parsed.Method ~= "Execute" or type(parsed.Data) ~= "string" then return end local execId, code = parsed._execId, parsed.Data -- A read the user is waiting on. The tree walk reads this between -- its slices and before each batch (tree.lua: pausedForReads) and -- steps aside, so the read answers now instead of after the batch. if parsed.priority == true then ENV.__NB_YIELD_UNTIL = os.clock() + 0.35 end NB_LAST_EXEC = tostring(execId or "?") NB_LAST_EXEC_AT = os.clock() local fn, err = OrigLoadstring(code) if not fn then fn = OrigLoadstring("return " .. code); if fn then err = nil end end if not fn then return report(execId, "Compile error: " .. tostring(err)) end local prepared, prepErr = pcall(InjectEnv, fn) if not prepared then return report(execId, "Could not prepare the chunk: " .. tostring(prepErr)) end task.spawn(function() local r = pack(xpcall(fn, function(e) return debug.traceback(tostring(e), 2) end)) if not r[1] then local m = tostring(r[2]) if #m > CONFIG.MAX_ERR_SIZE then m = m:sub(1, CONFIG.MAX_ERR_SIZE) .. "\n...[truncated]" end return report(execId, m) end if not execId then return end if type(r[2]) == "string" and ((type(execId) == "string" and execId:sub(1, 6) == "__run:") or (type(execId) == "string" and execId:sub(1, 6) == "__raw:")) then send({ Method = "Result", Data = r[2], _execId = execId }) return end local parts = {} for i = 2, r.n do local o, s = pcall(Serialize, r[i]) parts[i - 1] = o and s or "" end local out = concat(parts, "\t") if #out > CONFIG.MAX_RESULT_SIZE then out = out:sub(1, CONFIG.MAX_RESULT_SIZE) .. "\n...[truncated at " .. CONFIG.MAX_RESULT_SIZE .. " bytes]" end send({ Method = "Result", Data = out, _execId = execId }) end) end --[[ Inbound frames are handled one at a time, in arrival order, by one thread. Verifying a large command's signature can take a few frames' worth of time, and it yields while it does rather than hold the game; a second thread started meanwhile would let a later command overtake an earlier one. The checks, cheapest first: the size (before anything is decoded), a bound on how much unverified work is accepted per second, the signature, a bound on commands per second, the JSON, the shape. ]] local inbound = Fifo.new() local inboundBytes = 0 local INBOUND_BACKLOG = 16 * 1024 * 1024 local verifyBucket = Guard.bucket(400, 800, os.clock) local commandBucket = Guard.bucket(150, 300, os.clock) local lastResyncAt = -math.huge local draining = false local function breathe() task.wait() end local function handleFrame(raw) local tab, seq, mac, json = Guard.split(raw) if not tab then -- Unsigned frames are the relay's and Hello, all well under this; -- anything bigger is not decoded at all. if #raw > 16384 then return refuse("auth", execIdIn(string.sub(raw, 1, 4096)), "the page sent an unsigned command. Reload the Debugger tab; if this game's loadstring is older than the page, run the new one.") end local ok, parsed = pcall(JSON.JSONDecode, JSON, raw) if not ok or type(parsed) ~= "table" then Diag.rejected.malformed += 1 return end return handleUnsigned(parsed) end if not Guard.take(verifyBucket, os.clock()) then return refuse("rate", execIdIn(json), "too many commands at once; wait a moment and try again") end local okV, why = Guard.verify(guardSession, tab, seq, mac, json, #json > 262144 and breathe or nil) if not alive then return end if not okV then if why == "nokey" then return refuse("auth", execIdIn(json), "this game's loadstring carried no session key, so it cannot take commands. Copy the line again from the Debugger and run it.") end -- Most often a tab still signing for this game's previous -- connection. Telling it the current nonce is what lets it recover. if os.clock() - lastResyncAt >= 1 then lastResyncAt = os.clock() sendAuthorization() end return refuse("auth", execIdIn(json), why == "replay" and "a command was replayed, so it was refused." or "the command's signature did not match this session. Reload the Debugger tab, or run its loadstring again.") end if not Guard.take(commandBucket, os.clock()) then return refuse("rate", execIdIn(json), "too many commands at once; wait a moment and try again") end local okJ, parsed = pcall(JSON.JSONDecode, JSON, json) if not okJ or type(parsed) ~= "table" then return refuse("malformed", execIdIn(json), "the command was not valid JSON") end local bad = Guard.check(parsed) if bad then return refuse("shape", parsed._execId, "refused " .. tostring(parsed.Method or "command") .. ": " .. bad) end return handleCommand(parsed) end local function drain() draining = true while alive do local raw = Fifo.pop(inbound) if raw == nil then break end inboundBytes -= #raw local ok, err = pcall(handleFrame, raw) if not ok then queueConsole("error", "[RBX Debugger] command failed: " .. tostring(err), "netbridge") end end draining = false end Socket.OnMessage:Connect(function(raw) if not alive or type(raw) ~= "string" then return end lastPong = os.clock() Diag.framesIn += 1 Diag.bytesIn += #raw if #raw > Guard.LIMITS.FRAME then local mb = string.format("%.1f", #raw / 1048576) return refuse("size", execIdIn(string.sub(raw, 1, 4096)), "the command was " .. mb .. " MB, over the " .. string.format("%.1f", Guard.LIMITS.FRAME / 1048576) .. " MB limit") end if inboundBytes + #raw > INBOUND_BACKLOG then return refuse("backlog", execIdIn(string.sub(raw, 1, 4096)), "the game is still working through earlier commands") end Fifo.push(inbound, raw) inboundBytes += #raw if not draining then task.spawn(drain) end end) local mainThread = coroutine.running() local parked, woken = false, false function wake() if woken then return end woken = true if parked then task.spawn(mainThread) end end Socket.OnClose:Connect(function() alive = false -- Closed from our side too, at once: an executor that redials a -- server-closed socket by itself stops only when told to. pcall(function() Socket:Close() end) Events.emit("SocketLost", MY_GEN, "close") wake() end) local function pingTick() if not alive then return end if not isCurrent() then -- Superseded by a newer injection. Say so before going, so the relay -- frees the room at once: without it this socket stays "alive" for -- up to gameSilentMs and the replacement is refused as a duplicate -- for half a minute, which looks like the new inject not working. if sayBye then sayBye("superseded") end alive = false pcall(function() Socket:Close() end) wake() return end send({ Method = "Ping" }) if os.clock() - lastPong > CONFIG.PONG_TIMEOUT then alive = false pcall(function() Socket:Close() end) Events.emit("SocketLost", MY_GEN, "pong-timeout") wake() return end task.delay(CONFIG.PING_INTERVAL, pingTick) end task.delay(CONFIG.PING_INTERVAL, pingTick) local saidBye = false function sayBye(why) if saidBye then return end saidBye = true if why ~= "superseded" then leaving = true end pcall(function() send({ Method = "Bye", reason = tostring(why or "left") }) end) pcall(flush) -- Closed from this side, which is what stops an executor redialling it. alive = false pcall(function() Socket:Close() end) if wake then wake() end end if Client then pcall(function() connections[#connections + 1] = Client.OnTeleport:Connect(function(state) if state == Enum.TeleportState.Started then sayBye("teleport") end end) end) pcall(function() connections[#connections + 1] = Players.PlayerRemoving:Connect(function(who) if who == Client then sayBye("left") end end) end) end pcall(function() local GuiService = game:GetService("GuiService") --[[ A disconnect or kick is a message that stays on screen. One that clears again (a passing notice) is not the game going away, and saying goodbye to it stopped the Debugger for good in a game that carried on. So it counts only if it is still there a moment later. ]] local function messageUp() local ok, message = pcall(function() return GuiService:GetErrorMessage() end) return ok and type(message) == "string" and message ~= "" end connections[#connections + 1] = GuiService.ErrorMessageChanged:Connect(function() if not messageUp() then return end task.delay(5, function() if alive and messageUp() then sayBye("disconnected") end end) end) end) if alive and not woken then parked = true coroutine.yield() end flush() local stillMine = isCurrent() for i = #hookClaims, 1, -1 do releaseHookClaim(hookClaims[i]) end for _, conn in connections do pcall(function() conn:Disconnect() end) end if stillMine then local slot = getgenv().__NB_NC if slot and slot.body == namecallBody then slot.body = nil end end -- Unplug from the print wrapper (a newer copy may already have plugged in). local printSlot = getgenv().__NB_PRINT if type(printSlot) == "table" and printSlot.body == printBody then printSlot.body = nil end if raknetHook and raknet and raknet.remove_send_hook then pcall(raknet.remove_send_hook, raknetHook) end if RakSpy.uninstall then pcall(RakSpy.uninstall) end end local ProbeUrl = (function() local url = CONFIG.URL local scheme = url:match("^(wss?)://") == "wss" and "https" or "http" local host = url:gsub("^wss?://", ""):gsub("[/?#].*$", "") return scheme .. "://" .. host .. "/" end)() local function ServerUp() local okReq, requestFn = pcall(Caps.acquire, "request", true) if not okReq or type(requestFn) ~= "function" then return false end local ok, res = pcall(requestFn, { Url = ProbeUrl, Method = "GET" }) if not ok or not res then return false end if res.Success then return true end local code = res.StatusCode return code ~= nil and code ~= 0 end local connectScheduled = false local function scheduleConnect(delaySeconds, reason) if connectScheduled or leaving or not isCurrent() then return end -- Turned away as a duplicate: wait out the backoff rather than redialing -- into the same refusal every RETRY_PERIOD. local wait = duplicateUntil - os.clock() if wait > 0 then delaySeconds = math.max(delaySeconds, wait) end connectScheduled = true task.delay(delaySeconds, function() connectScheduled = false if isCurrent() then Events.emit("ConnectRequested", MY_GEN, reason) end end) end Events.on("SocketLost", function(gen, reason) if gen == MY_GEN and isCurrent() then scheduleConnect(CONFIG.RETRY_PERIOD, "socket:" .. tostring(reason or "lost")) end end) Events.on("ConnectRequested", function(gen, reason) if gen ~= MY_GEN or not isCurrent() then return end if not ServerUp() then scheduleConnect(CONFIG.PROBE_PERIOD, "probe") return end local ok, err = pcall(connect) if not ok then pcall(OrigWarn, "[RBX Debugger] connect failed: " .. tostring(err)) end if isCurrent() then scheduleConnect(CONFIG.RETRY_PERIOD, "closed") end end) Events.emit("ConnectRequested", MY_GEN, "startup")