5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / http-signalk-cve-2025-66398.nse NSE
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