local base64 = require "base64" local http = require "http" local json = require "json" local shortport = require "shortport" local stdnse = require "stdnse" local vulns = require "vulns" description = [[ Checks whether a Signal K Server instance is vulnerable to CVE-2025-66398 — an unauthenticated backup-restore endpoint that allows prototype-pollution of the security configuration and, in a second step, remote code execution. Affected versions: Signal K Server ≤ 2.18.0. The script sends a benign multipart POST to /skServer/validateBackup with an empty security.json ZIP archive (no credentials supplied). An HTTP 200 response confirms the endpoint is accessible without authentication. No exploitation or modification of server state is performed. References: https://github.com/joshuavanderpoll/cve-2025-66398 https://www.cve.org/CVERecord?id=CVE-2025-66398 ]] --- -- @usage -- nmap -p 3000 --script http-signalk-cve-2025-66398 -- nmap -p 3000,8111,9360 --script http-signalk-cve-2025-66398 -- -- @output -- PORT STATE SERVICE -- 3000/tcp open http -- | http-signalk-cve-2025-66398: -- | VULNERABLE: -- | Signal K Server Unauthenticated Backup Upload leading to RCE -- | State: VULNERABLE -- | IDs: CVE:CVE-2025-66398 -- | Risk factor: Critical CVSSv3: 10.0 -- | Description: -- | Signal K Server <= 2.18.0 exposes /skServer/validateBackup without -- | authentication, allowing backup upload, config pollution, and RCE. -- | Extra information: -- | Version: Signal K Server 2.17.1 -- | Restore path: /home/user/.signalk/backup-abc123.backup -- | References: -- | https://github.com/joshuavanderpoll/cve-2025-66398 -- |_ https://www.cve.org/CVERecord?id=CVE-2025-66398 -- -- @args http-signalk-cve-2025-66398.path -- Path used to probe the Signal K info endpoint when detecting the server -- version. Default: /signalk/v1/ author = "Joshua van der Poll" license = "Same as Nmap -- see https://nmap.org/book/man-legal.html" categories = {"vuln", "safe", "discovery"} -- Signal K commonly runs on 3000 (default), 8111, or 9360. -- Falls back to any port Nmap has already identified as HTTP. portrule = shortport.port_or_service({3000, 8111, 9360}, {"http", "https"}, "tcp") -- --------------------------------------------------------------------------- -- Pre-computed ZIP archive containing an empty security.json, base64-encoded. -- Equivalent to: -- zf.writestr("security.json", -- '{"users":[],"devices":[],"immutableConfig":false,"acls":[]}') -- The payload is always identical so it can be a compile-time constant. -- --------------------------------------------------------------------------- local ZIP_B64 = "UEsDBBQAAAAIAJBJa1whrWLaNgAAAEIAAAANAAAAc2VjdXJpdHkuanNvbqtW" .. "Ki1OLSpWslKIjtVRUEpJLctMToVzM3NzS0sSk3JSnfPz0jLTgcJpiTnFqU" .. "CZxOQciKpaAFBLAQIUAxQAAAAIAJBJa1whrWLaNgAAAEIAAAANAAAAAAAAA" .. "AAAAACAAQAAAABzZWN1cml0eS5qc29uUEsFBgAAAAABAAEAOwAAAGEAAAAAAA==" -- --------------------------------------------------------------------------- -- Helpers -- --------------------------------------------------------------------------- --- Build a minimal multipart/form-data body for a single file field. local function make_multipart(boundary, field, filename, data) return "--" .. boundary .. "\r\n" .. 'Content-Disposition: form-data; name="' .. field .. '"; filename="' .. filename .. '"\r\n' .. "Content-Type: application/octet-stream\r\n\r\n" .. data .. "\r\n--" .. boundary .. "--\r\n" end --- Parse a semver string "X.Y.Z" into a numeric table {X, Y, Z, ...}. local function parse_semver(v) if not v then return nil end local parts = {} for n in (v .. "."):gmatch("(%d+)%.") do parts[#parts + 1] = tonumber(n) end return #parts >= 2 and parts or nil end --- Return true when version table `a` is ≤ version table `b`. local function semver_lte(a, b) for i = 1, math.max(#a, #b) do local ai, bi = a[i] or 0, b[i] or 0 if ai < bi then return true end if ai > bi then return false end end return true end -- --------------------------------------------------------------------------- -- Main action -- --------------------------------------------------------------------------- action = function(host, port) local info_path = stdnse.get_script_args(SCRIPT_NAME .. ".path") or "/signalk/v1/" local ua = "Nmap-NSE/CVE-2025-66398-check" local vuln = { title = "Signal K Server Unauthenticated Backup Upload leading to RCE", IDS = { CVE = "CVE-2025-66398" }, risk_factor = "Critical", scores = { CVSSv3 = "10.0" }, description = [[ Signal K Server <= 2.18.0 exposes /skServer/validateBackup without authentication. An unauthenticated attacker can upload a crafted ZIP archive to pollute the security configuration and ultimately achieve unauthenticated remote code execution. ]], references = { "https://github.com/joshuavanderpoll/cve-2025-66398", "https://www.cve.org/CVERecord?id=CVE-2025-66398", }, dates = { disclosure = { year = "2025", month = "06", day = "01" }, }, state = vulns.STATE.NOT_TESTED, } local report = vulns.Report:new(SCRIPT_NAME, host, port) -- ── Step 1: optional version detection ─────────────────────────────────── local detected_version local info_resp = http.get(host, port, info_path, { header = { ["User-Agent"] = ua } }) if info_resp and info_resp.status == 200 and info_resp.body then local ok, parsed = pcall(json.parse, info_resp.body) if ok and type(parsed) == "table" then if type(parsed.server) == "table" then detected_version = parsed.server.version end if not detected_version and type(parsed.endpoints) == "table" then for _, ep in pairs(parsed.endpoints) do if type(ep) == "table" and ep.version then detected_version = ep.version break end end end end end -- ── Step 2: probe the unauthenticated backup endpoint ──────────────────── local zip_bytes = base64.dec(ZIP_B64:gsub("%s+", "")) local boundary = "NmapCVE202566398Boundary" local body = make_multipart(boundary, "file", "signalk-backup.backup", zip_bytes) local req_opts = { header = { ["User-Agent"] = ua, ["Content-Type"] = "multipart/form-data; boundary=" .. boundary, ["Content-Length"] = tostring(#body), }, content = body, } local resp = http.post(host, port, "/skServer/validateBackup", req_opts) if not resp then stdnse.debug1("No response from /skServer/validateBackup") return report:make_output(vuln) end -- ── Step 3: evaluate result ─────────────────────────────────────────────── if resp.status == 200 then vuln.state = vulns.STATE.VULN local extra = {} if detected_version then extra[#extra + 1] = "Version: Signal K Server " .. detected_version local vparts = parse_semver(detected_version) if vparts and not semver_lte(vparts, {2, 18, 0}) then extra[#extra + 1] = "Note: version " .. detected_version .. " is above 2.18.0 — may be a backport or mis-reported version" end end -- Surface the temporary restore path echoed back by the server. local ok2, data = pcall(json.parse, resp.body or "") if ok2 and type(data) == "table" then for _, k in ipairs({"restoreFilePath", "filePath", "path"}) do if type(data[k]) == "string" and data[k] ~= "" then extra[#extra + 1] = "Restore path: " .. data[k] break end end end if #extra > 0 then vuln.extra_info = table.concat(extra, "\n") end elseif resp.status == 401 then vuln.state = vulns.STATE.NOT_VULN if detected_version then vuln.extra_info = "Version: Signal K Server " .. detected_version end else stdnse.debug1("/skServer/validateBackup returned HTTP %d — inconclusive", resp.status) -- Leave state as NOT_TESTED; report:make_output will suppress output -- unless --script-args vulns.showall is set. end local output = report:make_output(vuln) -- Fallback for Nmap builds where make_output returns nil despite VULN state. if output == nil and vuln.state == vulns.STATE.VULN then local out = stdnse.output_table() out.state = "VULNERABLE" out.title = vuln.title out.IDs = "CVE-2025-66398" if vuln.extra_info then out.extra_info = vuln.extra_info end out.references = table.concat(vuln.references, " | ") return out end return output end