description = [[ DICOM C-STORE fuzzer for post-association PACS vulnerability testing. Reads a fuzz corpus generated by dicom_fuzzgen.py and transmits each malformed DICOM file to the target PACS via C-STORE (DIMSE), monitoring for crashes, hangs, unexpected responses, and anomalous behaviour. The attack surface is post-association but pre-authentication in any application-layer sense -- most PACS accept C-STORE from any associated SCU without further authentication checks. Test coverage: * Tag value overflow / boundary violations * VR type confusion and mismatch * Sequence nesting attacks (stack overflow, OOM) * Pixel data mutations (buffer size, bit depth, encapsulation) * Transfer syntax abuse (deflate bombs, JPEG corruption, encoding mismatch) * Private and unknown tag injection * File Meta Information attacks * String encoding and character set attacks * DICOM-specific logic bombs * CVE-specific payloads (multiple vendors) Anomaly classification: SUCCESS -- C-STORE-RSP Status 0x0000 (notable if input was heavily malformed) FAILURE -- C-STORE-RSP Status != 0x0000 (expected for malformed input) REJECT -- A-ASSOCIATE-RJ (SOP class not supported) ABORT -- A-ABORT from target (potential parser panic) TIMEOUT -- No response within timeout (potential crash or infinite loop) RESET -- TCP RST (process likely crashed) CONN_REFUSED -- Cannot connect (target service down / crashed) CORRUPT_RSP -- Response not a valid DICOM PDU (memory corruption indicator) Health checks run automatically after each configurable interval and after any TIMEOUT or RESET. Three consecutive health-check failures constitute a CRITICAL CRASH; fuzzing halts and the triggering case is reported. WARNING: This is an intrusive script. It WILL send malformed DICOM data to the target PACS and MAY cause crashes, hangs, data corruption, or exhaustion of service resources. Only use against systems for which you have explicit written authorisation. Medical imaging systems are life-critical infrastructure. Requires the dicom.lua library (place in nselib/ or same directory). ]] --- -- @usage -- nmap -p 104,11112 --script dicom-store-fuzzer \ -- --script-args 'dicom-store-fuzzer.corpus=/path/to/fuzz_corpus_v5,dicom-store-fuzzer.called_ae=ORTHANC' -- -- @usage -- nmap -p 11112 --script dicom-store-fuzzer \ -- --script-args 'dicom-store-fuzzer.corpus=./fuzz_corpus_v5,dicom-store-fuzzer.categories=cat03,cat04,dicom-store-fuzzer.timeout=15' -- -- @usage -- nmap -p 104 --script dicom-store-fuzzer \ -- --script-args 'dicom-store-fuzzer.corpus=./fuzz_corpus_v5,dicom-store-fuzzer.cases=TC0001,TC0042,TC0300,dicom-store-fuzzer.output=verbose' -- -- @args dicom-store-fuzzer.corpus Path to fuzz_corpus directory containing -- manifest.json (REQUIRED) -- @args dicom-store-fuzzer.called_ae Target AE Title (default: "ORTHANC") -- @args dicom-store-fuzzer.calling_ae Our AE Title (default: "NMAP-FUZZ") -- @args dicom-store-fuzzer.categories Comma-separated categories to send: -- "all","cat01","cat03,cat04" (default: "all") -- @args dicom-store-fuzzer.cases Specific case IDs: "TC0001,TC0042" -- (default: all) -- @args dicom-store-fuzzer.batch Cases per association; 1 = per-case -- isolation (default: 1) -- @args dicom-store-fuzzer.timeout C-STORE response timeout in seconds -- (default: 10) -- @args dicom-store-fuzzer.delay Delay between cases in milliseconds -- (default: 200) -- @args dicom-store-fuzzer.fallback_sc Use Secondary Capture fallback if SOP -- class rejected (default: true) -- @args dicom-store-fuzzer.baseline Send baseline before fuzzing to verify -- C-STORE works (default: true) -- @args dicom-store-fuzzer.max_pdu Max PDU Length to propose in bytes -- (default: 16384) -- @args dicom-store-fuzzer.resume Resume from this case ID after a -- previous crash (default: none) -- @args dicom-store-fuzzer.output Output verbosity: -- "summary","verbose","debug" (default: "summary") -- @args dicom-store-fuzzer.health_interval Health check every N cases -- (default: 10) -- @args dicom-store-fuzzer.results_file Write JSON results to this path -- (default: /tmp/dicom-store-fuzzer-.json) -- -- @output -- PORT STATE SERVICE -- 104/tcp open dicom -- | dicom-store-fuzzer: -- | Target AE: ORTHANC Calling AE: NMAP-FUZZ -- | Corpus: /path/to/fuzz_corpus_v5 (1357 cases queued) -- | Baseline: SUCCESS (C-STORE accepted) -- | Cases Sent: 1357 / 1357 -- | Results: SUCCESS=898 FAILURE=384 TIMEOUT=4 RESET=2 REJECT=18 ABORT=6 CORRUPT=0 -- | CRITICAL FINDINGS: -- | [CRASH] TC0215 cat03_sequence_nesting/TC0215_depth_500.dcm -- | Sequence depth 500 - TCP RST, target unresponsive for 8s -- | [TIMEOUT] TC0401 cat05_transfer_syntax/TC0401_deflate_bomb.dcm -- | Deflate bomb - No response after 10s, target recovered -- | [ACCEPT] TC0507 cat06_private_tags/TC0507_inject_shell_dollar.dcm -- | Shell injection in PatientName - Stored successfully -- | Results file: /tmp/dicom-store-fuzzer-192.168.1.50.json -- |_ --- author = "Paulino Calderon " license = "Same as Nmap -- See https://nmap.org/book/man-legal.html" categories = {"fuzzer", "intrusive"} local shortport = require "shortport" local stdnse = require "stdnse" local nmap = require "nmap" local string = require "string" local table = require "table" local math = require "math" local io = require "io" local json = require "json" local dicom = require "dicom" portrule = shortport.port_or_service({104, 2762, 11112, 4242}, "dicom", "tcp", "open") -- File I/O and DCM parsing now provided by dicom.lua library: -- dicom.read_file(path) -- dicom.generate_fake_uid() -- dicom.read_dcm_dataset(filepath) ----------------------------------------------------------------------- -- MANIFEST LOADER ----------------------------------------------------------------------- -- Load and parse manifest.json from the corpus directory. -- Returns list of case tables, baseline_file or nil, err. local function load_manifest(corpus_path) local path = corpus_path:gsub("[/\\]+$", "") .. "/manifest.json" local data, err = dicom.read_file(path) if not data then return nil, "Cannot read manifest.json from corpus: " .. tostring(err) end local ok, manifest = json.parse(data) if not ok or type(manifest) ~= "table" then return nil, "Failed to parse manifest.json: " .. tostring(manifest) end local cases = {} if type(manifest.categories) == "table" then for _, cat in ipairs(manifest.categories) do if type(cat.cases) == "table" then for _, c in ipairs(cat.cases) do cases[#cases + 1] = { id = c.id or "?", filename = c.filename or "", description = c.description or "", target_tag = c.target_tag or "", mutation = c.mutation or "", severity = c.severity or "medium", expected = c.expected_behavior or "", category = cat.id or "", } end end end end return cases, manifest.baseline_file end ----------------------------------------------------------------------- -- C-STORE VIA DICOM.LUA ----------------------------------------------------------------------- --- Send one C-STORE-RQ and receive the C-STORE-RSP. -- Uses dicom.lua's send_dimse/recv_dimse with proper MCH and carry buffer. -- @param sock Connected TCP socket with active association -- @param pctx_id Accepted presentation context ID -- @param sop_class SOP Class UID -- @param sop_inst SOP Instance UID -- @param ds_bytes Raw dataset bytes (post File Meta, just the DICOM dataset) -- @param msg_id DIMSE Message ID -- @param max_pdu Effective max PDU length -- @param timeout_s Response timeout in seconds -- @return result_string, status_code, error_comment, elapsed_ms local function do_cstore(sock, pctx_id, sop_class, sop_inst, ds_bytes, msg_id, max_pdu, timeout_s) local t0 = nmap.clock_ms() -- Build C-STORE-RQ command set local cmd_bytes = dicom.build_cstore_rq(msg_id, sop_class, sop_inst) -- Send command + dataset via dicom.lua (handles MCH, fragmentation, separate -- PDUs) local ok = dicom.send_dimse(sock, pctx_id, cmd_bytes, ds_bytes, max_pdu) if not ok then return "RESET", 0xFFFF, "send_dimse failed", nmap.clock_ms() - t0 end -- Receive C-STORE-RSP -- recv_dimse returns: pdu_type, cmd_elems, ds_bytes, raw_or_err, remaining -- On error: nil, nil, "", error_string, "" sock:set_timeout(timeout_s * 1000) local pdu_type, cmd_elems, _, raw_or_err, _ = dicom.recv_dimse(sock, timeout_s) local elapsed = nmap.clock_ms() - t0 if not pdu_type then local err_str = tostring(raw_or_err or "unknown") if err_str:find("TIMEOUT") then return "TIMEOUT", 0xFFFF, "", elapsed end return "RESET", 0xFFFF, err_str, elapsed end if pdu_type == dicom.PDU_CODES.ABORT then return "ABORT", 0xFFFF, "A-ABORT from server", elapsed elseif pdu_type ~= dicom.PDU_CODES.DATA then return string.format("CORRUPT_RSP(0x%02X)", pdu_type), 0xFFFF, "", elapsed end -- Parse response status from command elements local status_code = 0xFFFF local error_comment = "" if cmd_elems then if cmd_elems["0000,0900"] then status_code = cmd_elems["0000,0900"].value end if cmd_elems["0000,0902"] then error_comment = tostring(cmd_elems["0000,0902"].value or "") end end if status_code == dicom.STATUS.SUCCESS then return "SUCCESS", status_code, error_comment, elapsed elseif status_code == dicom.STATUS.PENDING or status_code == dicom.STATUS.PENDING_WARN then return "PENDING", status_code, error_comment, elapsed else return string.format("FAILURE(0x%04X)", status_code), status_code, error_comment, elapsed end end ----------------------------------------------------------------------- -- HEALTH CHECK ----------------------------------------------------------------------- --- Send the baseline file to confirm the target is alive. -- Returns true (alive) or false (dead/unreachable). local function health_check(host, port, corpus_path, baseline_rel, called_ae, calling_ae, max_pdu, timeout_s) if not baseline_rel then return false end local baseline_path = corpus_path:gsub("[/\\]+$", "") .. "/" .. baseline_rel local dataset_bytes, sop_class, sop_instance, ts_uid = dicom.read_dcm_dataset(baseline_path) if not dataset_bytes then return false end sop_class = (sop_class and #sop_class > 0) and sop_class or dicom.SOP_CLASS.CT_IMAGE_STORAGE sop_instance = (sop_instance and #sop_instance > 0) and sop_instance or dicom.generate_fake_uid() -- Propose file's native transfer syntax alongside standard fallbacks local transfer_uids = nil if ts_uid and ts_uid ~= "" then local seen = {} transfer_uids = {} for _, ts in ipairs({ts_uid, dicom.TRANSFER_SYNTAX.EXPLICIT_LE, dicom.TRANSFER_SYNTAX.IMPLICIT_LE}) do if not seen[ts] then seen[ts] = true transfer_uids[#transfer_uids + 1] = ts end end end local ok, sock, pctxs, server_max_pdu = dicom.do_associate( host, port, called_ae, calling_ae, {sop_class, dicom.SOP_CLASS.SECONDARY_CAPTURE}, max_pdu, timeout_s, transfer_uids) if not ok then return false end local pctx_id = dicom.pick_accepted_pctx(pctxs) if not pctx_id then dicom.do_release(sock, 3) return false end local eff_max = math.min(server_max_pdu or max_pdu, max_pdu) local result = do_cstore(sock, pctx_id, sop_class, sop_instance, dataset_bytes, 1, eff_max, timeout_s) dicom.do_release(sock, 3) return (result == "SUCCESS") end ----------------------------------------------------------------------- -- RESULT TRACKING ----------------------------------------------------------------------- local function new_results() return { SUCCESS=0, FAILURE=0, TIMEOUT=0, RESET=0, REJECT=0, ABORT=0, CONN_REFUSED=0, CORRUPT_RSP=0, total=0, anomalies={}, crashes={}, } end local function record(res, result, tc) res.total = res.total + 1 local key = result:match("^(%u[%u_]*)") or "FAILURE" if res[key] ~= nil then res[key] = res[key] + 1 else res.FAILURE = res.FAILURE + 1 end end local function is_critical(result) return (result == "TIMEOUT" or result == "RESET" or result == "CONN_REFUSED" or result:find("CRASH")) end ----------------------------------------------------------------------- -- JSON RESULTS WRITER ----------------------------------------------------------------------- local function write_results_json(filepath, target_ip, target_port, called_ae, corpus_path, results, all_case_results, start_time) local f, err = io.open(filepath, "w") if not f then stdnse.verbose1("Cannot write results file: %s", tostring(err)) return end -- JSON string escaping. Fuzz cases and server error comments can contain -- arbitrary control bytes (NUL, ESC, etc.); these are invalid raw in a JSON -- string and must be emitted as \uXXXX, otherwise the results file is not -- parseable. Backslash and quote are escaped first, then every control -- character in U+0000..U+001F (common ones as short escapes). local function esc(s) s = tostring(s):gsub('\\', '\\\\'):gsub('"', '\\"') s = s:gsub('[\x00-\x1f]', function(c) local b = string.byte(c) if b == 0x08 then return '\\b' elseif b == 0x09 then return '\\t' elseif b == 0x0a then return '\\n' elseif b == 0x0c then return '\\f' elseif b == 0x0d then return '\\r' else return string.format('\\u%04x', b) end end) return s end f:write('{\n') f:write(string.format(' "target": "%s:%d",\n', esc(target_ip), target_port)) f:write(string.format(' "target_ae": "%s",\n', esc(called_ae))) f:write(string.format(' "corpus": "%s",\n', esc(corpus_path))) f:write(string.format(' "scan_start": "%s",\n', esc(start_time))) f:write(string.format(' "scan_end": "%s",\n', esc(os.date("!%Y-%m-%dT%H:%M:%SZ")))) f:write(string.format(' "total_cases": %d,\n', results.total)) f:write(' "summary": {\n') f:write(string.format(' "SUCCESS": %d, "FAILURE": %d, "TIMEOUT": %d,\n', results.SUCCESS, results.FAILURE, results.TIMEOUT)) f:write(string.format(' "RESET": %d, "REJECT": %d, "ABORT": %d,\n', results.RESET, results.REJECT, results.ABORT)) f:write(string.format(' "CONN_REFUSED": %d, "CORRUPT_RSP": %d\n', results.CONN_REFUSED, results.CORRUPT_RSP)) f:write(' },\n') f:write(' "crashes": [\n') for i, c in ipairs(results.crashes) do f:write(string.format( ' {"id":"%s","file":"%s","result":"%s","desc":"%s"}%s\n', esc(c.id), esc(c.file), esc(c.result), esc(c.desc), i < #results.crashes and "," or "")) end f:write(' ],\n') f:write(' "anomalies": [\n') for i, a in ipairs(results.anomalies) do f:write(string.format( ' {"id":"%s","file":"%s","result":"%s",' .. '"desc":"%s","elapsed_ms":%d}%s\n', esc(a.id), esc(a.file), esc(a.result), esc(a.desc), a.elapsed or 0, i < #results.anomalies and "," or "")) end f:write(' ],\n') f:write(' "results": [\n') for i, r in ipairs(all_case_results) do f:write(string.format( ' {"id":"%s","category":"%s","file":"%s",' .. '"result":"%s","status":"0x%04X",' .. '"elapsed_ms":%d,"desc":"%s"}%s\n', esc(r.id), esc(r.category), esc(r.file), esc(r.result), r.status or 0xFFFF, r.elapsed or 0, esc(r.desc), i < #all_case_results and "," or "")) end f:write(' ]\n}\n') f:close() end ----------------------------------------------------------------------- -- MAIN ACTION ----------------------------------------------------------------------- action = function(host, port) math.randomseed(os.time()) local out = stdnse.output_table() local function arg(n) return stdnse.get_script_args(n) end local corpus_path = arg("dicom-store-fuzzer.corpus") local called_ae = arg("dicom-store-fuzzer.called_ae") or "ORTHANC" local calling_ae = arg("dicom-store-fuzzer.calling_ae") or "NMAP-FUZZ" local categories = arg("dicom-store-fuzzer.categories") or "all" local cases_filter = arg("dicom-store-fuzzer.cases") -- nil = all local batch_size = tonumber(arg("dicom-store-fuzzer.batch")) or 1 local timeout_s = tonumber(arg("dicom-store-fuzzer.timeout")) or 10 local delay_ms = tonumber(arg("dicom-store-fuzzer.delay")) or 200 local fallback_sc = (arg("dicom-store-fuzzer.fallback_sc") or "true"):lower() ~= "false" local do_baseline = (arg("dicom-store-fuzzer.baseline") or "true"):lower() ~= "false" local max_pdu = tonumber(arg("dicom-store-fuzzer.max_pdu")) or dicom.MAX_PDU_DEFAULT local resume_from = arg("dicom-store-fuzzer.resume") local output_lvl = arg("dicom-store-fuzzer.output") or "summary" local health_int = tonumber(arg("dicom-store-fuzzer.health_interval")) or 10 local results_file = arg("dicom-store-fuzzer.results_file") or string.format("/tmp/dicom-store-fuzzer-%s.json", host.ip or "unknown") local verbose = (output_lvl == "verbose" or output_lvl == "debug") local dbg = (output_lvl == "debug") if not corpus_path then out.Error = "dicom-store-fuzzer.corpus is required. Run" .. " dicom_fuzzgen.py first." return out end out["Target AE"] = called_ae out["Calling AE"] = calling_ae -- == Load manifest == local all_cases, baseline_rel = load_manifest(corpus_path) if not all_cases then out.Error = "Failed to load manifest: " .. tostring(baseline_rel) return out end -- == Filter by category == local cat_filter = {} if categories:lower() ~= "all" then for c in categories:gmatch("[^,]+") do cat_filter[c:lower():gsub("%s+", "")] = true end end -- == Filter by case ID == local case_id_filter = {} if cases_filter then for c in cases_filter:gmatch("[^,]+") do case_id_filter[c:upper():gsub("%s+", "")] = true end end -- == Apply filters == local queued = {} local resuming = (resume_from ~= nil) for _, tc in ipairs(all_cases) do if next(cat_filter) ~= nil and not cat_filter[tc.category:lower()] then goto continue_filter end if next(case_id_filter) ~= nil and not case_id_filter[tc.id:upper()] then goto continue_filter end if resuming then if tc.id == resume_from then resuming = false end if resuming then goto continue_filter end end queued[#queued + 1] = tc ::continue_filter:: end out.Corpus = string.format("%s (%d cases queued)", corpus_path, #queued) -- == Baseline check == if do_baseline then local alive = health_check(host, port, corpus_path, baseline_rel, called_ae, calling_ae, max_pdu, timeout_s) if not alive then out.Baseline = "FAILED - target does not accept C-STORE or AE Title" .. " is wrong" out.Warning = "Aborting: baseline C-STORE failed. Check called_ae and" .. " target." return out end out.Baseline = "SUCCESS" end -- == Prepare result tracking == local res = new_results() local all_case_res = {} local halted = false local msg_id = 1 local start_time = os.date("!%Y-%m-%dT%H:%M:%SZ") local consecutive_fails = 0 -- == Per-case or batched association == local sock_open = false local cur_sock = nil local cur_pctx_id = nil local cur_max_pdu = max_pdu local cases_in_batch = 0 local function close_session() if cur_sock then pcall(dicom.do_release, cur_sock, 3) cur_sock = nil end sock_open = false cases_in_batch = 0 end local function open_session(sop_class, file_ts_uid) close_session() local sop_list = {sop_class} if fallback_sc and sop_class ~= dicom.SOP_CLASS.SECONDARY_CAPTURE then sop_list[#sop_list + 1] = dicom.SOP_CLASS.SECONDARY_CAPTURE end -- Propose the file's native transfer syntax alongside standard fallbacks. -- This ensures files encoded in JPEG, JPEG-LS, RLE, etc. can be sent -- with a matching negotiated TS instead of forcing Implicit/Explicit VR -- LE. local transfer_uids = nil if file_ts_uid and file_ts_uid ~= "" then local seen = {} transfer_uids = {} for _, ts in ipairs({file_ts_uid, dicom.TRANSFER_SYNTAX.EXPLICIT_LE, dicom.TRANSFER_SYNTAX.IMPLICIT_LE}) do if not seen[ts] then seen[ts] = true transfer_uids[#transfer_uids + 1] = ts end end stdnse.debug2("Proposing transfer syntaxes: %s", table.concat(transfer_uids, ", ")) end local ok, sock_or_err, pctxs, srv_max_pdu = dicom.do_associate( host, port, called_ae, calling_ae, sop_list, max_pdu, timeout_s, transfer_uids) if not ok then return false, sock_or_err end -- Pick accepted presentation context (prefers lowest pctx_id = primary -- SOP) local pctx_id = dicom.pick_accepted_pctx(pctxs) if not pctx_id then pcall(dicom.do_release, sock_or_err, 3) return false, "No presentation context accepted" end cur_sock = sock_or_err cur_pctx_id = pctx_id cur_max_pdu = math.min(srv_max_pdu or max_pdu, max_pdu) sock_open = true cases_in_batch = 0 consecutive_fails = 0 -- association succeeded, target is alive return true, nil end -- == Main fuzzing loop == local total_run = 0 local since_health = 0 for _, tc in ipairs(queued) do if halted then break end total_run = total_run + 1 since_health = since_health + 1 -- Periodic liveness check (every health_int cases): a malformed case may -- crash or hang the target without producing an immediate error on the -- case that triggered it. Re-send the known-good baseline on a fresh -- association to confirm the target still processes valid C-STORE; if it -- does not, record a crash and halt so the offending region is preserved. if health_int > 0 and baseline_rel and since_health >= health_int then since_health = 0 close_session() local alive = health_check(host, port, corpus_path, baseline_rel, called_ae, calling_ae, max_pdu, timeout_s) if not alive then stdnse.verbose1("CRITICAL: health check failed after case %s --" .. " target may have crashed", tc.id) res.crashes[#res.crashes + 1] = { id=tc.id, file=tc.filename, result="HEALTH_CHECK_FAILED", desc="Baseline health check failed after " .. tc.id} halted = true break end end stdnse.debug2("Case %s: %s", tc.id, tc.description) -- Read DCM file local filepath = corpus_path:gsub("[/\\]+$", "") .. "/" .. tc.filename local dataset_bytes, sop_class, sop_instance, file_ts_uid = dicom.read_dcm_dataset(filepath) if not dataset_bytes then record(res, "FAILURE", tc) all_case_res[#all_case_res + 1] = { id=tc.id, category=tc.category, file=tc.filename, result="FAILURE(cannot read file)", status=0xFFFF, elapsed=0, desc=tc.description} goto next_case end sop_class = (sop_class and #sop_class > 0) and sop_class or dicom.SOP_CLASS.CT_IMAGE_STORAGE sop_instance = (sop_instance and #sop_instance > 0) and sop_instance or dicom.generate_fake_uid() -- Open/reuse association (propose file's native transfer syntax) if not sock_open or cases_in_batch >= batch_size then local ok, err = open_session(sop_class, file_ts_uid) if not ok then local fail_result = err or "CONN_REFUSED" if type(fail_result) == "string" and fail_result:find("REJECT") then record(res, "REJECT", tc) else record(res, "CONN_REFUSED", tc) consecutive_fails = consecutive_fails + 1 end if verbose then stdnse.verbose1("[%s] %s: %s", tostring(fail_result), tc.id, tc.description) end all_case_res[#all_case_res + 1] = { id=tc.id, category=tc.category, file=tc.filename, result=tostring(fail_result), status=0xFFFF, elapsed=0, desc=tc.description} -- Only halt if target is truly unreachable if consecutive_fails >= 5 then stdnse.verbose1("CRITICAL: 5 consecutive connection failures --" .. " target may have crashed") res.crashes[#res.crashes + 1] = { id=tc.id, file=tc.filename, result="CONN_REFUSED(5 consecutive)", desc="Target unresponsive after " .. tc.id} halted = true end goto next_case end end -- Run C-STORE do local result, status_code, error_comment, elapsed = do_cstore(cur_sock, cur_pctx_id, sop_class, sop_instance, dataset_bytes, msg_id, cur_max_pdu, timeout_s) msg_id = msg_id + 1 cases_in_batch = cases_in_batch + 1 record(res, result, tc) -- Anomaly detection local is_anom = false local anom_type = "" if result == "TIMEOUT" or result == "RESET" or result == "CONN_REFUSED" then is_anom = true anom_type = "CRASH/HANG" close_session() -- TIMEOUT is normal for fuzz cases (server may just be slow to parse -- malformed data). Only count RESET and CONN_REFUSED as hard -- failures. if result ~= "TIMEOUT" then consecutive_fails = consecutive_fails + 1 end -- Only halt if target is actually unreachable (not just slow) if consecutive_fails >= 5 then stdnse.verbose1("CRITICAL: 5 consecutive hard failures after %s", tc.id) res.crashes[#res.crashes + 1] = { id=tc.id, file=tc.filename, result=result, desc="5 consecutive hard failures -- target likely crashed"} halted = true end elseif result == "ABORT" then is_anom = true anom_type = "ABORT" close_session() elseif result:find("CORRUPT_RSP") then is_anom = true anom_type = "CORRUPT_RSP" close_session() elseif result == "SUCCESS" and (tc.severity == "high") then is_anom = true anom_type = "ACCEPT(HIGH_SEV)" else consecutive_fails = 0 end if is_anom then local entry = { id=tc.id, file=tc.filename, result=result .. (error_comment ~= "" and (":" .. error_comment) or ""), desc=tc.description, elapsed=elapsed} if is_critical(result) then res.crashes[#res.crashes + 1] = entry else res.anomalies[#res.anomalies + 1] = entry end if verbose then stdnse.verbose1("[%s] %s: %s - %s (%dms)", anom_type, tc.id, tc.description, result, elapsed or 0) end elseif dbg then stdnse.debug1("[%s] %s (%dms)", result, tc.id, elapsed or 0) end all_case_res[#all_case_res + 1] = { id=tc.id, category=tc.category, file=tc.filename, result=result, status=status_code, elapsed=elapsed, desc=tc.description} end -- Inter-case delay if delay_ms > 0 and not halted then stdnse.sleep(delay_ms / 1000.0) end ::next_case:: end close_session() -- == Output == out["Cases Sent"] = string.format("%d / %d", total_run, #queued) out["Results"] = string.format( "SUCCESS=%d FAILURE=%d TIMEOUT=%d RESET=%d REJECT=%d ABORT=%d CORRUPT=%d", res.SUCCESS, res.FAILURE, res.TIMEOUT, res.RESET, res.REJECT, res.ABORT, res.CORRUPT_RSP) if #res.crashes > 0 or #res.anomalies > 0 then local findings = {} for _, c in ipairs(res.crashes) do findings[#findings + 1] = string.format("[CRASH] %s %s\n " .. " %s", c.id, c.file, c.result) end for _, a in ipairs(res.anomalies) do findings[#findings + 1] = string.format("[%s] %s %s\n %s", a.result:match("^(%S+)") or "ANOMALY", a.id, a.file, a.desc) end out["CRITICAL FINDINGS"] = findings else out["Result"] = "No anomalies detected - target appears robust for" .. " tested cases" end -- Write JSON results write_results_json(results_file, host.ip or "unknown", port.number, called_ae, corpus_path, res, all_case_res, start_time) out["Results file"] = results_file nmap.set_port_state(host, port, "open") port.version.name = "dicom" port.version.product = "DICOM SCP" nmap.set_port_version(host, port, "hardmatched") return out end