mirror of
https://github.com/nmap/nmap.git
synced 2026-11-11 22:34:37 +00:00
801 lines
30 KiB
Lua
801 lines
30 KiB
Lua
description = [[
|
|
Lists DICOM objects stored on a PACS server using C-FIND queries at the
|
|
STUDY, SERIES, and IMAGE levels. Results are displayed hierarchically
|
|
showing patients, studies, series and (optionally) individual instances.
|
|
|
|
The script negotiates an A-ASSOCIATE with the Study Root Query/Retrieve
|
|
Information Model — FIND SOP Class and issues C-FIND-RQ at each level.
|
|
It can also retrieve (download) individual DICOM instances using C-GET
|
|
when the --script-args save option is set.
|
|
|
|
This is a read-only, non-intrusive reconnaissance script suitable for
|
|
auditing what data is accessible on a DICOM endpoint without proper
|
|
access controls.
|
|
|
|
NOTE: Many PACS servers accept C-FIND from any associated SCU with no
|
|
authentication. If the server returns results, it means patient data
|
|
is exposed to any host that can reach the DICOM port — a critical
|
|
finding for security assessments.
|
|
|
|
INSTALLATION:
|
|
This script requires the enhanced dicom.lua library.
|
|
Copy dicom.lua to your Nmap nselib/ directory:
|
|
cp dicom.lua /usr/share/nmap/nselib/dicom.lua
|
|
]]
|
|
|
|
---
|
|
-- @usage
|
|
-- nmap -p 4242 --script dicom-cfind-ls <target>
|
|
--
|
|
-- @usage
|
|
-- nmap -p 4242 --script dicom-cfind-ls \
|
|
-- --script-args 'dicom-cfind-ls.called_ae=ORTHANC,dicom-cfind-ls.calling_ae=LAUNCHER' <target>
|
|
--
|
|
-- @usage
|
|
-- nmap -p 4242 --script dicom-cfind-ls \
|
|
-- --script-args 'dicom-cfind-ls.level=SERIES,dicom-cfind-ls.max_results=50' <target>
|
|
--
|
|
-- @usage (download all matching instances to disk)
|
|
-- nmap -p 4242 --script dicom-cfind-ls \
|
|
-- --script-args 'dicom-cfind-ls.save=/tmp/dicom-dump' <target>
|
|
--
|
|
-- @args dicom-cfind-ls.called_ae Target AE Title (default: "ANY-SCP")
|
|
-- @args dicom-cfind-ls.calling_ae Our AE Title (default: "FINDSCU")
|
|
-- @args dicom-cfind-ls.level Deepest query level: PATIENT, STUDY,
|
|
-- SERIES, IMAGE (default: "IMAGE")
|
|
-- @args dicom-cfind-ls.max_results Maximum results per level (default: 100)
|
|
-- @args dicom-cfind-ls.timeout Response timeout in seconds (default: 10)
|
|
-- @args dicom-cfind-ls.max_pdu Max PDU Length in bytes (default: 16384)
|
|
-- @args dicom-cfind-ls.patient_name Filter by PatientName (wildcard *
|
|
-- allowed, default: "*")
|
|
-- @args dicom-cfind-ls.patient_id Filter by PatientID (default: all)
|
|
-- @args dicom-cfind-ls.study_date Filter by StudyDate (YYYYMMDD or range,
|
|
-- default: all)
|
|
-- @args dicom-cfind-ls.modality Filter by Modality (CT, MR, etc.,
|
|
-- default: all)
|
|
-- @args dicom-cfind-ls.save Directory to save DICOM files via C-GET
|
|
-- (default: disabled)
|
|
-- @args dicom-cfind-ls.info_model Q/R Information Model: "study" or
|
|
-- "patient" (default: "study")
|
|
-- @args dicom-cfind-ls.full Include extended return-key tags in
|
|
-- queries (StudyDate,
|
|
-- AccessionNumber, ModalitiesInStudy, etc.).
|
|
-- Some PACS servers
|
|
-- abort on unsupported tags; when set the
|
|
-- script auto-retries
|
|
-- with minimal tags on A-ABORT. Default:
|
|
-- off (lean query).
|
|
--
|
|
-- @output
|
|
-- PORT STATE SERVICE
|
|
-- 4242/tcp open dicom
|
|
-- | dicom-cfind-ls:
|
|
-- | DICOM Q/R Results (Study Root, FIND):
|
|
-- | Patients: 3 Studies: 5 Series: 12 Instances: 47
|
|
-- |
|
|
-- | [Patient] John Doe (ID: PATIENT001)
|
|
-- | [Study] 2025-01-15 - CT Abdomen (1.2.3.4.5.1)
|
|
-- | [Series] CT - 128 images (1.2.3.4.5.1.1)
|
|
-- | [Series] CT - 64 images (1.2.3.4.5.1.2)
|
|
-- | [Study] 2025-03-20 - MR Brain (1.2.3.4.5.2)
|
|
-- | [Series] MR - 256 images (1.2.3.4.5.2.1)
|
|
-- | [Patient] Jane Smith (ID: PATIENT002)
|
|
-- | [Study] 2025-02-10 - CR Chest (1.2.3.4.5.3)
|
|
-- | [Series] CR - 2 images (1.2.3.4.5.3.1)
|
|
-- |
|
|
-- | WARNING: Patient data accessible without authentication
|
|
-- |_ Saved 47 instances to /tmp/dicom-dump/
|
|
---
|
|
|
|
author = "Paulino Calderon <paulino@calderonpale.com>"
|
|
license = "Same as Nmap -- See https://nmap.org/book/man-legal.html"
|
|
categories = {"discovery", "safe"}
|
|
|
|
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 dicom = require "dicom"
|
|
local os = require "os"
|
|
|
|
-- Verify we have the enhanced library (not Nmap's built-in minimal version)
|
|
assert(dicom.explicit_elem,
|
|
"\n\n dicom-cfind-ls requires the enhanced dicom.lua library.\n" ..
|
|
" Copy dicom.lua to your Nmap nselib/ directory:\n" ..
|
|
" cp dicom.lua /usr/share/nmap/nselib/dicom.lua\n")
|
|
|
|
portrule = shortport.port_or_service(
|
|
{104, 2345, 2761, 2762, 4242, 11112}, "dicom", "tcp", "open")
|
|
|
|
-----------------------------------------------------------------------
|
|
-- HELPERS
|
|
-----------------------------------------------------------------------
|
|
|
|
local function get_arg(key, default)
|
|
return stdnse.get_script_args("dicom-cfind-ls." .. key) or default
|
|
end
|
|
|
|
-----------------------------------------------------------------------
|
|
-- C-FIND QUERY
|
|
-----------------------------------------------------------------------
|
|
|
|
--- Hex-dump helper for debug level >= 2.
|
|
local function hexdump(label, data)
|
|
if not data or #data == 0 then return end
|
|
local parts = {}
|
|
for i = 1, #data do
|
|
parts[#parts + 1] = string.format("%02X", string.byte(data, i))
|
|
if i % 32 == 0 then parts[#parts + 1] = "\n " end
|
|
end
|
|
stdnse.debug2("%s (%d bytes):\n %s", label, #data, table.concat(parts, " "))
|
|
end
|
|
|
|
--- Perform a single C-FIND association + query cycle.
|
|
-- Opens an association, sends C-FIND-RQ + dataset, collects PENDING
|
|
-- responses until SUCCESS or error, then releases.
|
|
--
|
|
-- @param host Nmap host object
|
|
-- @param port Nmap port object
|
|
-- @param called_ae Called AE Title
|
|
-- @param calling_ae Calling AE Title
|
|
-- @param find_sop SOP Class UID for the Q/R FIND model
|
|
-- @param query_tags List of {group, elem, vr, value} for the query dataset
|
|
-- @param level Q/R level string ("STUDY", "SERIES", "IMAGE")
|
|
-- @param max_pdu Max PDU length
|
|
-- @param timeout_s DIMSE timeout in seconds
|
|
-- @param max_results Max results to collect
|
|
-- @return ok (bool), results_or_err (list of tag tables, or error string)
|
|
local function do_cfind_once(host, port, called_ae, calling_ae, find_sop,
|
|
query_tags, level, max_pdu, timeout_s,
|
|
max_results)
|
|
-- Associate
|
|
local ok, sock, pctxs, server_max_pdu, elapsed =
|
|
dicom.do_associate(host, port, called_ae, calling_ae, {find_sop}, max_pdu,
|
|
timeout_s)
|
|
if not ok then
|
|
return false, sock
|
|
end
|
|
|
|
local pctx_id = dicom.pick_accepted_pctx(pctxs)
|
|
if not pctx_id then
|
|
dicom.do_release(sock, 3)
|
|
return false, "No accepted presentation context for C-FIND"
|
|
end
|
|
|
|
-- Determine the negotiated transfer syntax for dataset encoding
|
|
local ts = dicom.get_accepted_ts(pctxs, pctx_id)
|
|
or dicom.TRANSFER_SYNTAX.EXPLICIT_LE
|
|
local eff_max = math.min(server_max_pdu or max_pdu, max_pdu)
|
|
|
|
stdnse.debug1("C-FIND: pctx_id=%d TS=%s max_pdu=%d tags=%d",
|
|
pctx_id, ts, eff_max, #query_tags)
|
|
|
|
-- Build command set (always Implicit VR LE) and dataset (negotiated TS)
|
|
local cmd_bytes = dicom.build_cfind_rq(1, find_sop)
|
|
local ds_bytes = dicom.build_cfind_dataset(level, query_tags, ts)
|
|
|
|
hexdump("C-FIND command set", cmd_bytes)
|
|
hexdump("C-FIND dataset", ds_bytes)
|
|
|
|
-- Send command + dataset via send_dimse (combines into single P-DATA-TF PDU)
|
|
ok = dicom.send_dimse(sock, pctx_id, cmd_bytes, ds_bytes, eff_max)
|
|
if not ok then
|
|
sock:close()
|
|
return false, "Failed to send C-FIND DIMSE message"
|
|
end
|
|
|
|
-- Collect responses
|
|
local results = {}
|
|
local count = 0
|
|
local carry = "" -- carry buffer for leftover bytes between recv_dimse calls
|
|
|
|
while true do
|
|
local pdu_type, cmd_elems, resp_ds_bytes, raw
|
|
pdu_type, cmd_elems, resp_ds_bytes, raw, carry =
|
|
dicom.recv_dimse(sock, timeout_s, carry)
|
|
|
|
if not pdu_type then
|
|
stdnse.debug1("C-FIND recv error: %s", tostring(raw))
|
|
break
|
|
end
|
|
|
|
if pdu_type == dicom.PDU_CODES.ABORT then
|
|
stdnse.debug1("C-FIND: received A-ABORT (sent %d tags at level %s)",
|
|
#query_tags, level)
|
|
hexdump("A-ABORT PDU", raw)
|
|
sock:close()
|
|
if #results > 0 then return true, results end
|
|
return false, "A-ABORT"
|
|
end
|
|
|
|
if pdu_type ~= dicom.PDU_CODES.DATA then
|
|
stdnse.debug1("C-FIND: unexpected PDU type 0x%02X", pdu_type)
|
|
break
|
|
end
|
|
|
|
-- Parse response status
|
|
local cfind_status = 0xFFFF
|
|
if cmd_elems and cmd_elems["0000,0900"] then
|
|
cfind_status = cmd_elems["0000,0900"].value
|
|
end
|
|
|
|
if cfind_status == dicom.STATUS.PENDING
|
|
or cfind_status == dicom.STATUS.PENDING_WARN then
|
|
-- Parse dataset with negotiated transfer syntax
|
|
if #resp_ds_bytes > 0 then
|
|
local ds_elems = dicom.parse_dataset(resp_ds_bytes, ts)
|
|
if ds_elems then
|
|
results[#results + 1] = ds_elems
|
|
count = count + 1
|
|
if count >= max_results then
|
|
stdnse.debug1("C-FIND: max_results (%d) reached", max_results)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
elseif cfind_status == dicom.STATUS.SUCCESS then
|
|
stdnse.debug1("C-FIND: SUCCESS — %d results", #results)
|
|
break
|
|
else
|
|
stdnse.debug1("C-FIND: status 0x%04X — stopping", cfind_status)
|
|
break
|
|
end
|
|
end
|
|
|
|
dicom.do_release(sock, 3)
|
|
return true, results
|
|
end
|
|
|
|
--- Perform a C-FIND query with automatic fallback to minimal tags.
|
|
-- If the full query gets A-ABORT, retries with just QueryRetrieveLevel +
|
|
-- PatientName (the same minimal set that DCMTK findscu uses by default).
|
|
-- This handles servers that abort on unsupported return-key tags.
|
|
--
|
|
-- @param host Nmap host object
|
|
-- @param port Nmap port object
|
|
-- @param called_ae Called AE Title
|
|
-- @param calling_ae Calling AE Title
|
|
-- @param find_sop SOP Class UID for the Q/R FIND model
|
|
-- @param query_tags List of {group, elem, vr, value} for the query dataset
|
|
-- @param level Q/R level string ("STUDY", "SERIES", "IMAGE")
|
|
-- @param max_pdu Max PDU length
|
|
-- @param timeout_s DIMSE timeout in seconds
|
|
-- @param max_results Max results to collect
|
|
-- @param minimal_tags Optional fallback tag list (nil = auto-generate from
|
|
-- query_tags)
|
|
-- @return ok (bool), results_or_err (list of tag tables, or error string),
|
|
-- used_fallback (bool)
|
|
local function do_cfind(host, port, called_ae, calling_ae, find_sop,
|
|
query_tags, level, max_pdu, timeout_s, max_results,
|
|
minimal_tags)
|
|
local ok, results = do_cfind_once(host, port, called_ae, calling_ae,
|
|
find_sop, query_tags, level, max_pdu, timeout_s, max_results)
|
|
|
|
if ok then
|
|
return true, results, false
|
|
end
|
|
|
|
-- If we got A-ABORT and have more than 2 tags, retry with minimal set
|
|
if results == "A-ABORT" and #query_tags > 2 then
|
|
if not minimal_tags then
|
|
-- Build minimal: keep only tags that have a non-empty filter value,
|
|
-- plus PatientName=* as a universal wildcard match key
|
|
minimal_tags = {}
|
|
for _, t in ipairs(query_tags) do
|
|
if t.value and t.value ~= "" then
|
|
minimal_tags[#minimal_tags + 1] = t
|
|
end
|
|
end
|
|
-- Ensure at least PatientName is present
|
|
local has_pn = false
|
|
for _, t in ipairs(minimal_tags) do
|
|
if t.group == 0x0010 and t.elem == 0x0010 then has_pn = true
|
|
break end
|
|
end
|
|
if not has_pn then
|
|
minimal_tags[#minimal_tags + 1] = {group=0x0010, elem=0x0010, vr="PN",
|
|
value="*"}
|
|
end
|
|
end
|
|
|
|
stdnse.debug1("C-FIND: A-ABORT with %d tags; retrying with %d minimal" ..
|
|
" tags",
|
|
#query_tags, #minimal_tags)
|
|
|
|
ok, results = do_cfind_once(host, port, called_ae, calling_ae,
|
|
find_sop, minimal_tags, level, max_pdu, timeout_s, max_results)
|
|
if ok then
|
|
return true, results, true
|
|
end
|
|
end
|
|
|
|
return false, results, false
|
|
end
|
|
|
|
-----------------------------------------------------------------------
|
|
-- C-GET RETRIEVAL
|
|
-----------------------------------------------------------------------
|
|
|
|
--- Download DICOM instances via C-GET.
|
|
-- Handles incoming C-STORE sub-operations from the SCP and saves files.
|
|
local function do_cget(host, port, called_ae, calling_ae, get_sop,
|
|
storage_sops, query_tags, level, max_pdu,
|
|
timeout_s, save_dir)
|
|
local all_sops = {get_sop}
|
|
for _, sop in ipairs(storage_sops) do
|
|
all_sops[#all_sops + 1] = sop
|
|
end
|
|
|
|
local ok, sock, pctxs, server_max_pdu, elapsed =
|
|
dicom.do_associate(host, port, called_ae, calling_ae, all_sops, max_pdu,
|
|
timeout_s)
|
|
if not ok then
|
|
return 0, 0, sock
|
|
end
|
|
|
|
local pctx_id = dicom.pick_accepted_pctx(pctxs)
|
|
if not pctx_id then
|
|
dicom.do_release(sock, 3)
|
|
return 0, 0, "No accepted presentation context for C-GET"
|
|
end
|
|
|
|
local ts = dicom.get_accepted_ts(pctxs, pctx_id)
|
|
or dicom.TRANSFER_SYNTAX.EXPLICIT_LE
|
|
local eff_max = math.min(server_max_pdu or max_pdu, max_pdu)
|
|
|
|
-- Build and send C-GET-RQ
|
|
local cmd_bytes = dicom.build_cget_rq(1, get_sop)
|
|
local ds_bytes = dicom.build_cfind_dataset(level, query_tags, ts)
|
|
|
|
dicom.send_dimse(sock, pctx_id, cmd_bytes, ds_bytes, eff_max)
|
|
|
|
local saved, errors = 0, 0
|
|
local carry = "" -- carry buffer for leftover bytes between recv_dimse calls
|
|
|
|
while true do
|
|
local pdu_type, cmd_elems, resp_ds, raw
|
|
pdu_type, cmd_elems, resp_ds, raw, carry =
|
|
dicom.recv_dimse(sock, timeout_s, carry)
|
|
|
|
if not pdu_type then
|
|
stdnse.debug1("C-GET recv error: %s", tostring(raw))
|
|
break
|
|
end
|
|
if pdu_type == dicom.PDU_CODES.ABORT then
|
|
stdnse.debug1("C-GET: received A-ABORT")
|
|
break
|
|
end
|
|
if pdu_type ~= dicom.PDU_CODES.DATA then
|
|
stdnse.debug1("C-GET: unexpected PDU 0x%02X", pdu_type)
|
|
break
|
|
end
|
|
|
|
local cmd_field = 0
|
|
if cmd_elems and cmd_elems["0000,0100"] then
|
|
cmd_field = cmd_elems["0000,0100"].value
|
|
end
|
|
|
|
if cmd_field == dicom.COMMAND_FIELD.C_STORE_RQ then
|
|
-- Incoming C-STORE sub-operation: save file to disk
|
|
local sop_class = ""
|
|
if cmd_elems["0000,0002"] then
|
|
sop_class = tostring(cmd_elems["0000,0002"].value):gsub("%z",
|
|
""):gsub("%s+$", "")
|
|
end
|
|
local sop_instance = ""
|
|
if cmd_elems["0000,1000"] then
|
|
sop_instance = tostring(cmd_elems["0000,1000"].value):gsub("%z",
|
|
""):gsub("%s+$", "")
|
|
end
|
|
if sop_instance == "" then sop_instance = dicom.generate_fake_uid() end
|
|
local msg_id_rsp = 1
|
|
if cmd_elems["0000,0110"] then
|
|
msg_id_rsp = cmd_elems["0000,0110"].value
|
|
end
|
|
|
|
local filename = sop_instance:gsub("[^%w%.]", "_") .. ".dcm"
|
|
local filepath = save_dir:gsub("[/\\]+$", "") .. "/" .. filename
|
|
local wok, werr = dicom.write_file(filepath, resp_ds)
|
|
if wok then
|
|
saved = saved + 1
|
|
stdnse.debug1("C-GET: saved %s (%d bytes)", filename, #resp_ds)
|
|
else
|
|
errors = errors + 1
|
|
stdnse.debug1("C-GET: save failed: %s", tostring(werr))
|
|
end
|
|
|
|
-- Send C-STORE-RSP
|
|
local rsp_cmd = dicom.build_cstore_rsp(msg_id_rsp, sop_class,
|
|
sop_instance)
|
|
dicom.send_dimse(sock, pctx_id, rsp_cmd, nil, eff_max)
|
|
|
|
elseif cmd_field == dicom.COMMAND_FIELD.C_GET_RSP then
|
|
local cget_status = 0xFFFF
|
|
if cmd_elems["0000,0900"] then
|
|
cget_status = cmd_elems["0000,0900"].value
|
|
end
|
|
if cget_status ~= dicom.STATUS.PENDING
|
|
and cget_status ~= dicom.STATUS.PENDING_WARN then
|
|
stdnse.debug1("C-GET: final RSP status 0x%04X", cget_status)
|
|
break
|
|
end
|
|
else
|
|
stdnse.debug1("C-GET: unknown command field 0x%04X", cmd_field)
|
|
end
|
|
end
|
|
|
|
dicom.do_release(sock, 3)
|
|
return saved, errors
|
|
end
|
|
|
|
-----------------------------------------------------------------------
|
|
-- MAIN ACTION
|
|
-----------------------------------------------------------------------
|
|
action = function(host, port)
|
|
local called_ae = get_arg("called_ae", "ANY-SCP")
|
|
local calling_ae = get_arg("calling_ae", "FINDSCU")
|
|
local max_level = get_arg("level", "IMAGE"):upper()
|
|
local max_results = tonumber(get_arg("max_results", "100"))
|
|
local timeout_s = tonumber(get_arg("timeout", "10"))
|
|
local max_pdu = tonumber(get_arg("max_pdu", "16384"))
|
|
local pat_name = get_arg("patient_name", "*")
|
|
local pat_id = get_arg("patient_id", "")
|
|
local study_date = get_arg("study_date", "")
|
|
local modality = get_arg("modality", "")
|
|
local save_dir = get_arg("save", nil)
|
|
local info_model = get_arg("info_model", "study"):lower()
|
|
|
|
-- Select Q/R SOP Classes based on information model
|
|
local find_sop, get_sop
|
|
if info_model == "patient" then
|
|
find_sop = dicom.SOP_CLASS.PATIENT_ROOT_QR_FIND
|
|
get_sop = dicom.SOP_CLASS.PATIENT_ROOT_QR_GET
|
|
else
|
|
find_sop = dicom.SOP_CLASS.STUDY_ROOT_QR_FIND
|
|
get_sop = dicom.SOP_CLASS.STUDY_ROOT_QR_GET
|
|
end
|
|
|
|
local out = stdnse.output_table()
|
|
|
|
-- ── C-ECHO probe ─────────────────────────────────────────────────
|
|
-- Quick DIMSE connectivity check. If C-ECHO fails, C-FIND will too.
|
|
-- This distinguishes encoding/framing problems from access-control
|
|
-- restrictions (server may accept association but disallow FIND).
|
|
do
|
|
local echo_ok, echo_sock, echo_pctxs =
|
|
dicom.do_associate(host, port, called_ae, calling_ae,
|
|
{dicom.SOP_CLASS.VERIFICATION}, max_pdu, timeout_s)
|
|
if echo_ok then
|
|
local echo_pctx_id = dicom.pick_accepted_pctx(echo_pctxs)
|
|
if echo_pctx_id then
|
|
local echo_cmd = dicom.build_cecho_rq(1)
|
|
dicom.send_dimse(echo_sock, echo_pctx_id, echo_cmd, nil, max_pdu)
|
|
local pdu_type, cmd_elems = dicom.recv_dimse(echo_sock, timeout_s)
|
|
if pdu_type == dicom.PDU_CODES.DATA then
|
|
local echo_status = 0xFFFF
|
|
if cmd_elems and cmd_elems["0000,0900"] then
|
|
echo_status = cmd_elems["0000,0900"].value
|
|
end
|
|
if echo_status == dicom.STATUS.SUCCESS then
|
|
stdnse.debug1("C-ECHO: SUCCESS — DIMSE connectivity verified")
|
|
else
|
|
stdnse.debug1("C-ECHO: unexpected status 0x%04X", echo_status)
|
|
end
|
|
elseif pdu_type == dicom.PDU_CODES.ABORT then
|
|
stdnse.debug1("C-ECHO: A-ABORT — server rejects DIMSE from %s",
|
|
calling_ae)
|
|
out["Error"] = string.format(
|
|
"C-ECHO aborted — server rejects DIMSE from calling AE '%s'. " ..
|
|
"Check server's modality configuration (AllowEcho, AllowFind).",
|
|
calling_ae)
|
|
dicom.do_release(echo_sock, 3)
|
|
return out
|
|
end
|
|
end
|
|
dicom.do_release(echo_sock, 3)
|
|
else
|
|
stdnse.debug1("C-ECHO: association failed: %s", tostring(echo_sock))
|
|
end
|
|
end
|
|
|
|
-- ── STUDY-level C-FIND ───────────────────────────────────────────
|
|
-- Core tags: always sent (match keys + essential return keys).
|
|
-- These mirror what DCMTK findscu sends and work universally.
|
|
local study_tags = {
|
|
{group=0x0010, elem=0x0010, vr="PN", value=pat_name},
|
|
-- PatientName (match)
|
|
{group=0x0010, elem=0x0020, vr="LO", value=pat_id},
|
|
-- PatientID (match/return)
|
|
{group=0x0020, elem=0x000D, vr="UI", value=""},
|
|
-- StudyInstanceUID (return)
|
|
}
|
|
|
|
-- Additional return keys: requested when user sets level or uses filters.
|
|
-- Some PACS (e.g. certain Orthanc configs) abort on unsupported tags,
|
|
-- so these are added only with the "full" flag or specific filter args,
|
|
-- and we fall back to core-only if the server aborts.
|
|
local want_extra = (get_arg("full", nil) ~= nil)
|
|
or study_date ~= ""
|
|
or modality ~= ""
|
|
|
|
if want_extra then
|
|
-- Insert before PatientName to keep ascending (group,elem) order.
|
|
-- encode_dataset sorts anyway, but helps readability.
|
|
study_tags[#study_tags + 1] = {group=0x0008, elem=0x0020, vr="DA",
|
|
value=study_date} -- StudyDate
|
|
study_tags[#study_tags + 1] = {group=0x0008, elem=0x0030, vr="TM",
|
|
value=""} -- StudyTime
|
|
study_tags[#study_tags + 1] = {group=0x0008, elem=0x0050, vr="SH",
|
|
value=""} -- AccessionNumber
|
|
study_tags[#study_tags + 1] = {group=0x0008, elem=0x0061, vr="CS",
|
|
value=modality} -- ModalitiesInStudy (0008,0061 not 0008,0060)
|
|
study_tags[#study_tags + 1] = {group=0x0008, elem=0x1030, vr="LO",
|
|
value=""} -- StudyDescription
|
|
study_tags[#study_tags + 1] = {group=0x0010, elem=0x0030, vr="DA",
|
|
value=""} -- PatientBirthDate
|
|
study_tags[#study_tags + 1] = {group=0x0010, elem=0x0040, vr="CS",
|
|
value=""} -- PatientSex
|
|
study_tags[#study_tags + 1] = {group=0x0020, elem=0x0010, vr="SH",
|
|
value=""} -- StudyID
|
|
end
|
|
|
|
local ok, studies, used_fallback = do_cfind(host, port, called_ae,
|
|
calling_ae,
|
|
find_sop, study_tags, "STUDY", max_pdu, timeout_s, max_results)
|
|
if not ok then
|
|
if studies == "A-ABORT" then
|
|
out["Error"] = string.format(
|
|
"C-FIND aborted by server (A-ABORT source=0). " ..
|
|
"C-ECHO may succeed while C-FIND is denied. " ..
|
|
"If using Orthanc, check that calling AE '%s' has AllowFind=true " ..
|
|
"in /etc/orthanc/orthanc.json DicomModalities section. " ..
|
|
"Try: calling_ae=ORTHANC or calling_ae=FINDSCU",
|
|
calling_ae)
|
|
else
|
|
out["Error"] = "C-FIND STUDY failed: " .. tostring(studies)
|
|
end
|
|
return out
|
|
end
|
|
if used_fallback then
|
|
stdnse.verbose1("C-FIND: server aborted with extended tags; used" ..
|
|
" minimal query")
|
|
end
|
|
|
|
if #studies == 0 then
|
|
out["Result"] = "No studies found (server may require specific AE title" ..
|
|
" or filter)"
|
|
return out
|
|
end
|
|
|
|
-- ── Build patient/study tree ─────────────────────────────────────
|
|
local patients = {}
|
|
local patient_order = {}
|
|
local study_count = 0
|
|
local series_count = 0
|
|
local instance_count = 0
|
|
|
|
for _, s in ipairs(studies) do
|
|
local pid = s["0010,0020"] or "UNKNOWN"
|
|
local pname = s["0010,0010"] or "UNKNOWN"
|
|
local psex = s["0010,0040"] or ""
|
|
local pdob = s["0010,0030"] or ""
|
|
local suid = s["0020,000D"] or ""
|
|
local sdate = s["0008,0020"] or ""
|
|
local stime = s["0008,0030"] or ""
|
|
local sdesc = s["0008,1030"] or ""
|
|
local smod = s["0008,0060"] or ""
|
|
local sid = s["0020,0010"] or ""
|
|
local accno = s["0008,0050"] or ""
|
|
|
|
if not patients[pid] then
|
|
patients[pid] = { name=pname, sex=psex, dob=pdob, studies={} }
|
|
patient_order[#patient_order + 1] = pid
|
|
end
|
|
|
|
patients[pid].studies[#patients[pid].studies + 1] = {
|
|
uid = suid,
|
|
date = sdate,
|
|
time = stime,
|
|
description = sdesc,
|
|
modality = smod,
|
|
study_id = sid,
|
|
accession = accno,
|
|
series = {},
|
|
}
|
|
study_count = study_count + 1
|
|
end
|
|
|
|
-- ── SERIES-level C-FIND (if requested) ───────────────────────────
|
|
if max_level == "SERIES" or max_level == "IMAGE" then
|
|
for _, pid in ipairs(patient_order) do
|
|
for _, study in ipairs(patients[pid].studies) do
|
|
if study.uid == "" then goto next_study end
|
|
|
|
local series_tags = {
|
|
{group=0x0008, elem=0x0060, vr="CS", value=""}, -- Modality
|
|
{group=0x0008, elem=0x103E, vr="LO", value=""}, -- SeriesDescription
|
|
{group=0x0020, elem=0x000D, vr="UI", value=study.uid},
|
|
-- StudyInstanceUID
|
|
{group=0x0020, elem=0x000E, vr="UI", value=""}, -- SeriesInstanceUID
|
|
{group=0x0020, elem=0x0011, vr="IS", value=""}, -- SeriesNumber
|
|
}
|
|
|
|
local sok, series_list = do_cfind(host, port, called_ae, calling_ae,
|
|
find_sop, series_tags, "SERIES",
|
|
max_pdu, timeout_s, max_results)
|
|
if sok and series_list then
|
|
for _, sr in ipairs(series_list) do
|
|
study.series[#study.series + 1] = {
|
|
uid = sr["0020,000E"] or "",
|
|
modality = sr["0008,0060"] or "",
|
|
description = sr["0008,103E"] or "",
|
|
number = sr["0020,0011"] or "",
|
|
instances = {},
|
|
num_images = 0,
|
|
}
|
|
series_count = series_count + 1
|
|
end
|
|
end
|
|
::next_study::
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ── IMAGE-level C-FIND (if requested) ────────────────────────────
|
|
if max_level == "IMAGE" then
|
|
for _, pid in ipairs(patient_order) do
|
|
for _, study in ipairs(patients[pid].studies) do
|
|
for _, series in ipairs(study.series) do
|
|
if series.uid == "" then goto next_series end
|
|
|
|
local img_tags = {
|
|
{group=0x0008, elem=0x0016, vr="UI", value=""}, -- SOPClassUID
|
|
{group=0x0008, elem=0x0018, vr="UI", value=""}, -- SOPInstanceUID
|
|
{group=0x0020, elem=0x000D, vr="UI", value=study.uid},
|
|
-- StudyInstanceUID
|
|
{group=0x0020, elem=0x000E, vr="UI", value=series.uid},
|
|
-- SeriesInstanceUID
|
|
{group=0x0020, elem=0x0013, vr="IS", value=""}, -- InstanceNumber
|
|
}
|
|
|
|
local iok, imgs = do_cfind(host, port, called_ae, calling_ae,
|
|
find_sop, img_tags, "IMAGE",
|
|
max_pdu, timeout_s, max_results)
|
|
if iok and imgs then
|
|
for _, img in ipairs(imgs) do
|
|
series.instances[#series.instances + 1] = {
|
|
sop_class = img["0008,0016"] or "",
|
|
sop_instance = img["0008,0018"] or "",
|
|
number = img["0020,0013"] or "",
|
|
}
|
|
instance_count = instance_count + 1
|
|
end
|
|
series.num_images = #series.instances
|
|
end
|
|
::next_series::
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ── C-GET DOWNLOAD (if --save) ───────────────────────────────────
|
|
local saved_total = 0
|
|
if save_dir and max_level == "IMAGE" and instance_count > 0 then
|
|
os.execute("mkdir -p " .. save_dir .. " 2>/dev/null")
|
|
os.execute("mkdir " .. save_dir .. " 2>nul")
|
|
|
|
-- Collect unique storage SOP classes
|
|
local sop_set = {}
|
|
local storage_sops = {}
|
|
for _, pid in ipairs(patient_order) do
|
|
for _, study in ipairs(patients[pid].studies) do
|
|
for _, series in ipairs(study.series) do
|
|
for _, inst in ipairs(series.instances) do
|
|
if inst.sop_class ~= "" and not sop_set[inst.sop_class] then
|
|
sop_set[inst.sop_class] = true
|
|
storage_sops[#storage_sops + 1] = inst.sop_class
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Retrieve per-study via C-GET
|
|
for _, pid in ipairs(patient_order) do
|
|
for _, study in ipairs(patients[pid].studies) do
|
|
if study.uid == "" then goto next_dl_study end
|
|
|
|
local get_tags = {
|
|
{group=0x0020, elem=0x000D, vr="UI", value=study.uid},
|
|
}
|
|
local nsaved, nerrors = do_cget(host, port, called_ae, calling_ae,
|
|
get_sop, storage_sops, get_tags,
|
|
"STUDY", max_pdu, timeout_s, save_dir)
|
|
saved_total = saved_total + nsaved
|
|
if nsaved > 0 then
|
|
stdnse.verbose1("C-GET: saved %d instances for study %s", nsaved,
|
|
study.uid)
|
|
end
|
|
::next_dl_study::
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ── FORMAT OUTPUT ────────────────────────────────────────────────
|
|
local model_name = info_model == "patient" and "Patient Root" or "Study Root"
|
|
out["DICOM Q/R Results"] = string.format("(%s, FIND)", model_name)
|
|
out["Patients"] = #patient_order
|
|
out["Studies"] = study_count
|
|
if series_count > 0 then out["Series"] = series_count end
|
|
if instance_count > 0 then out["Instances"] = instance_count end
|
|
|
|
local lines = {}
|
|
for _, pid in ipairs(patient_order) do
|
|
local p = patients[pid]
|
|
local pline = string.format("[Patient] %s (ID: %s)", p.name, pid)
|
|
if p.sex ~= "" then pline = pline .. " " .. p.sex end
|
|
if p.dob ~= "" then pline = pline .. " DOB:" .. p.dob end
|
|
lines[#lines + 1] = pline
|
|
|
|
for _, study in ipairs(p.studies) do
|
|
local sline = string.format(" [Study] %s",
|
|
study.date ~= "" and study.date or "no-date")
|
|
if study.description ~= "" then
|
|
sline = sline .. " - " .. study.description
|
|
end
|
|
if study.modality ~= "" then
|
|
sline = sline .. " (" .. study.modality .. ")"
|
|
end
|
|
if study.uid ~= "" then sline = sline .. " " .. study.uid end
|
|
lines[#lines + 1] = sline
|
|
|
|
for _, series in ipairs(study.series) do
|
|
local srline = string.format(" [Series] #%s %s",
|
|
series.number ~= "" and series.number or "?",
|
|
series.modality)
|
|
if series.description ~= "" then
|
|
srline = srline .. " - " .. series.description
|
|
end
|
|
if series.num_images > 0 then
|
|
srline = srline .. string.format(" (%d images)", series.num_images)
|
|
end
|
|
if series.uid ~= "" then srline = srline .. " " .. series.uid end
|
|
lines[#lines + 1] = srline
|
|
|
|
for ii, inst in ipairs(series.instances) do
|
|
if ii <= 5 then
|
|
stdnse.debug1(" [Instance] #%s %s (%s)",
|
|
tostring(inst.number), tostring(inst.sop_instance),
|
|
tostring(inst.sop_class))
|
|
elseif ii == 6 then
|
|
stdnse.debug1(" ... and %d more instances",
|
|
#series.instances - 5)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
out["Listing"] = lines
|
|
|
|
if #patient_order > 0 then
|
|
out["WARNING"] = "Patient data accessible without authentication"
|
|
end
|
|
|
|
if saved_total > 0 then
|
|
out["Saved"] = string.format("%d instances to %s", saved_total, save_dir)
|
|
elseif save_dir and instance_count == 0 then
|
|
out["Save"] = "No instances found to download"
|
|
elseif save_dir and max_level ~= "IMAGE" then
|
|
out["Save"] = "Set level=IMAGE to enable C-GET download"
|
|
end
|
|
|
|
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
|