README.md
Rendering markdown...
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 <target>
-- nmap -p 3000,8111,9360 --script http-signalk-cve-2025-66398 <target>
--
-- @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