---
-- DICOM protocol library for Nmap NSE scripts.
--
-- Implements the DICOM Upper Layer protocol (PS3.8) and core DIMSE
-- services (PS3.7). Provides connection management, A-ASSOCIATE
-- negotiation, P-DATA-TF PDU construction/parsing, and helpers for
-- C-ECHO, C-STORE, C-FIND, C-GET, and C-MOVE.
--
-- Supports both Implicit VR Little Endian and Explicit VR Little Endian
-- transfer syntaxes for dataset encoding and parsing.
--
-- Designed to be shared across multiple DICOM NSE scripts
-- (dicom-ping, dicom-store-fuzzer, dicom-cfind-ls, etc.) so that
-- protocol logic lives in one place.
--
-- INSTALLATION:
-- Copy this file to your Nmap nselib/ directory to replace the
-- built-in minimal dicom.lua:
-- cp dicom.lua /usr/share/nmap/nselib/dicom.lua
-- (or /usr/local/share/nmap/nselib/dicom.lua on macOS)
-- This library is fully backward-compatible with the original: the legacy
-- API (start_connection, send, receive, pdu_header_encode, associate,
-- send_pdata) keeps its exact signatures and behaviour, so the official
-- dicom-ping and dicom-brute scripts run unchanged.
--
-- CHANGES vs. the upstream nmap nselib/dicom.lua:
-- * Legacy API preserved verbatim (drop-in compatible).
-- * Added DIMSE service support: C-ECHO, C-STORE, C-FIND, C-GET, C-MOVE.
-- * Added A-ASSOCIATE-RQ builder with multiple presentation contexts,
-- configurable max PDU, and SCP/SCU Role Selection (PS3.7 D.3.3.4) so
-- C-GET can receive C-STORE sub-operations.
-- * Added A-ASSOCIATE-AC parser (accepted contexts, transfer syntaxes,
-- max PDU, implementation identification, accepted roles) for
-- fingerprinting and negotiation.
-- * Added P-DATA-TF fragmentation/reassembly that surfaces the PDV
-- presentation-context ID (needed to answer C-STORE sub-ops correctly).
-- * Added Implicit/Explicit VR LE element encoders/decoders and Part-10
-- (.dcm) dataset extraction.
--
-- OPTIONS:
-- *called_aet - Called Application Entity Title (default:
-- ANY-SCP)
-- *calling_aet - Calling Application Entity Title (default:
-- ECHOSCU)
--
-- @args dicom.called_aet Called Application Entity Title. Default: ANY-SCP
-- @args dicom.calling_aet Calling Application Entity Title. Default: ECHOSCU
--
-- @author Paulino Calderon
-- @copyright Same as Nmap--See https://nmap.org/book/man-legal.html
---
local nmap = require "nmap"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"
local math = require "math"
local io = require "io"
local os = require "os"
_ENV = stdnse.module("dicom", stdnse.seeall)
-----------------------------------------------------------------------
-- 1. CONSTANTS
-----------------------------------------------------------------------
--- Minimum size in bytes of a valid A-ASSOCIATE-RQ PDU.
MIN_SIZE_ASSOC_REQ = 68
--- Maximum PDU size accepted when reading from the network.
MAX_SIZE_PDU = 128000
--- Length in bytes of a DICOM Upper Layer PDU header.
MIN_HEADER_LEN = 6
---
-- DICOM Upper Layer PDU type codes (PS3.8 Table 9-1).
-- @class table
-- @name PDU_CODES
PDU_CODES = {
ASSOCIATE_REQUEST = 0x01,
ASSOCIATE_ACCEPT = 0x02,
ASSOCIATE_REJECT = 0x03,
DATA = 0x04,
RELEASE_REQUEST = 0x05,
RELEASE_RESPONSE = 0x06,
ABORT = 0x07,
}
---
-- Reverse map of PDU_CODES: numeric type code to its name.
-- @class table
-- @name PDU_NAMES
PDU_NAMES = {}
for i, v in pairs(PDU_CODES) do
PDU_NAMES[v] = i
end
---
-- DIMSE Command Field values (PS3.7 Table E.1-1).
-- @class table
-- @name COMMAND_FIELD
COMMAND_FIELD = {
C_STORE_RQ = 0x0001,
C_STORE_RSP = 0x8001,
C_GET_RQ = 0x0010,
C_GET_RSP = 0x8010,
C_FIND_RQ = 0x0020,
C_FIND_RSP = 0x8020,
C_MOVE_RQ = 0x0021,
C_MOVE_RSP = 0x8021,
C_ECHO_RQ = 0x0030,
C_ECHO_RSP = 0x8030,
}
---
-- DIMSE Status codes (PS3.7 Table C.4-1 ff.).
-- @class table
-- @name STATUS
STATUS = {
SUCCESS = 0x0000,
PENDING = 0xFF00,
PENDING_WARN = 0xFF01,
CANCEL = 0xFE00,
WARN_COERCE = 0xB000,
FAIL_REFUSED_MV = 0xA700,
FAIL_REFUSED_SOP = 0xA900,
FAIL_ERROR = 0xC000,
}
---
-- Well-known SOP Class UIDs (storage and Query/Retrieve information models).
-- @class table
-- @name SOP_CLASS
SOP_CLASS = {
VERIFICATION = "1.2.840.10008.1.1",
CT_IMAGE_STORAGE = "1.2.840.10008.5.1.4.1.1.2",
ENHANCED_CT_STORAGE = "1.2.840.10008.5.1.4.1.1.2.1",
MR_IMAGE_STORAGE = "1.2.840.10008.5.1.4.1.1.4",
SECONDARY_CAPTURE = "1.2.840.10008.5.1.4.1.1.7",
PET_IMAGE_STORAGE = "1.2.840.10008.5.1.4.1.1.128",
RT_DOSE_STORAGE = "1.2.840.10008.5.1.4.1.1.481.2",
RT_PLAN_STORAGE = "1.2.840.10008.5.1.4.1.1.481.5",
-- Query/Retrieve Information Models
STUDY_ROOT_QR_FIND = "1.2.840.10008.5.1.4.1.2.2.1",
STUDY_ROOT_QR_MOVE = "1.2.840.10008.5.1.4.1.2.2.2",
STUDY_ROOT_QR_GET = "1.2.840.10008.5.1.4.1.2.2.3",
PATIENT_ROOT_QR_FIND = "1.2.840.10008.5.1.4.1.2.1.1",
PATIENT_ROOT_QR_MOVE = "1.2.840.10008.5.1.4.1.2.1.2",
PATIENT_ROOT_QR_GET = "1.2.840.10008.5.1.4.1.2.1.3",
}
---
-- Transfer Syntax UIDs (PS3.5).
-- @class table
-- @name TRANSFER_SYNTAX
TRANSFER_SYNTAX = {
IMPLICIT_LE = "1.2.840.10008.1.2",
EXPLICIT_LE = "1.2.840.10008.1.2.1",
EXPLICIT_BE = "1.2.840.10008.1.2.2",
DEFLATED_LE = "1.2.840.10008.1.2.1.99",
JPEG_BASELINE = "1.2.840.10008.1.2.4.50",
JPEG_LS = "1.2.840.10008.1.2.4.80",
RLE = "1.2.840.10008.1.2.5",
}
--- DICOM Application Context Name UID (PS3.7).
APP_CTX_UID = "1.2.840.10008.3.1.1.1"
--- Implementation Class UID advertised in A-ASSOCIATE-RQ (DCMTK-compatible).
IMPL_CLASS_UID = "1.2.276.0.7230010.3.0.3.6.7"
--- Implementation Version Name advertised in A-ASSOCIATE-RQ.
IMPL_VERSION = "OFFIS_DCMTK_367"
--- Default maximum PDU length proposed during association.
MAX_PDU_DEFAULT = 16384
---
-- Query/Retrieve levels (0008,0052 QueryRetrieveLevel values).
-- @class table
-- @name QR_LEVEL
QR_LEVEL = {
PATIENT = "PATIENT",
STUDY = "STUDY",
SERIES = "SERIES",
IMAGE = "IMAGE",
}
---
-- VRs that use the long (12-byte) explicit encoding in Explicit VR LE
-- (PS3.5 Table 7.1-1). Membership test: LONG_VRS[vr] is true for these.
-- @class table
-- @name LONG_VRS
LONG_VRS = {
OB = true, OD = true, OF = true, OL = true, OV = true, OW = true,
SQ = true, UC = true, UN = true, UR = true, UT = true,
SV = true, UV = true,
}
--- CommandDataSetType (0000,0800) sentinel: a dataset follows the command.
DATASET_PRESENT = 0x0001
--- CommandDataSetType (0000,0800) sentinel: no dataset follows the command.
DATASET_NOT_PRESENT = 0x0101
-----------------------------------------------------------------------
-- 2. LOW-LEVEL UTILITIES
-----------------------------------------------------------------------
--- Pack a big-endian unsigned integer into n bytes.
function pack_be(val, n)
if n == 1 then return string.pack(">B", val) end
if n == 2 then return string.pack(">I2", val) end
if n == 4 then return string.pack(">I4", val) end
return string.pack(">I" .. n, val)
end
--- Pack a little-endian unsigned integer into n bytes.
function pack_le(val, n)
if n == 1 then return string.pack("= n then return s:sub(1, n) end
return s .. string.rep(" ", n - #s)
end
--- Build a TLV item: type(1) + reserved(1) + length(2, big-endian) + data.
function mk_item(item_type, data)
return string.pack(">B B I2", item_type, 0x00, #data) .. data
end
--- Generate a plausible fake UID.
function generate_fake_uid()
return string.format("1.2.3.4.5.99999.%d.%d",
os.time(), math.random(100000, 999999))
end
--- Read a file from disk. Returns bytes string or nil, err.
function read_file(path)
local f, err = io.open(path, "rb")
if not f then return nil, "Cannot open file: " .. tostring(err) end
local data = f:read("*a")
f:close()
if not data then return nil, "Cannot read file: " .. path end
return data, nil
end
--- Write binary data to a file. Returns true or nil, err.
function write_file(path, data)
local f, err = io.open(path, "wb")
if not f then
return nil, "Cannot open file for writing: " .. tostring(err)
end
f:write(data)
f:close()
return true
end
-----------------------------------------------------------------------
-- 3. PDU HEADER
-----------------------------------------------------------------------
--- Encode a DICOM PDU header (6 bytes).
-- @param pdu_type PDU type byte
-- @param length Length of the PDU body
-- @return status (bool), header_or_err (string)
function pdu_header_encode(pdu_type, length)
if type(pdu_type) ~= "number" then
return false, "PDU Type must be an unsigned integer. Range:0-7"
end
if type(length) ~= "number" then
return false, "Length must be an unsigned integer."
end
local header = string.pack(">B B I4", pdu_type, 0, length)
if #header < MIN_HEADER_LEN then
return false, "Header must be at least 6 bytes. Something went wrong."
end
return true, header
end
-----------------------------------------------------------------------
-- 4. ASSOCIATION PDU BUILDERS
-----------------------------------------------------------------------
--- Build an SCP/SCU Role Selection sub-item (PS3.7 §D.3.3.4, item type 0x54).
-- Used to request role reversal so the SCU can receive C-STORE sub-operations
-- (required for C-GET).
-- @param uid Abstract Syntax (SOP Class) UID the role applies to
-- @param scu SCU role byte (0 or 1)
-- @param scp SCP role byte (0 or 1)
-- @return Encoded role selection sub-item bytes
function mk_role_item(uid, scu, scp)
-- body: UID-length(2) + UID + SCU-role(1) + SCP-role(1)
local body = string.pack(">I2", #uid) .. uid
.. string.pack(">B B", scu and 1 or 0, scp and 1 or 0)
return mk_item(0x54, body)
end
--- Build User Information sub-item for A-ASSOCIATE-RQ.
-- @param max_pdu Max PDU length to advertise
-- @param roles (optional) list of {uid=, scu=bool, scp=bool}
-- role-selection requests to append (for C-GET, request
-- scp=true
-- on the storage SOP classes you are willing to receive).
function mk_user_info(max_pdu, roles)
max_pdu = max_pdu or MAX_PDU_DEFAULT
local max_pdu_item = string.pack(">B B I2 I4", 0x51, 0x00, 0x04, max_pdu)
local inner = max_pdu_item
-- Role selection items must precede the implementation identification items
-- in typical peer implementations; place them here for maximum
-- compatibility.
if roles then
for _, r in ipairs(roles) do
inner = inner .. mk_role_item(r.uid, r.scu ~= false, r.scp == true)
end
end
inner = inner
.. mk_item(0x52, IMPL_CLASS_UID)
.. mk_item(0x55, IMPL_VERSION)
return mk_item(0x50, inner)
end
--- Build a Presentation Context sub-item for A-ASSOCIATE-RQ.
-- @param pctx_id Odd integer 1–255
-- @param sop_class Abstract Syntax UID string
-- @param transfer_uids List of Transfer Syntax UIDs
-- (default: Explicit VR LE + Implicit VR LE)
function mk_pres_ctx(pctx_id, sop_class, transfer_uids)
transfer_uids = transfer_uids or {
TRANSFER_SYNTAX.EXPLICIT_LE,
TRANSFER_SYNTAX.IMPLICIT_LE,
}
local inner = string.pack(">B B B B", pctx_id, 0x00, 0x00, 0x00)
.. mk_item(0x30, sop_class)
for _, ts in ipairs(transfer_uids) do
inner = inner .. mk_item(0x40, ts)
end
return mk_item(0x20, inner)
end
--- Build a complete A-ASSOCIATE-RQ PDU.
-- @param called_ae Called AE Title (max 16 chars)
-- @param calling_ae Calling AE Title (max 16 chars)
-- @param sop_classes List of Abstract Syntax UIDs to propose
-- @param max_pdu Max PDU length to propose
-- @param transfer_uids Optional list of TS UIDs for all presentation contexts
-- @param roles Optional list of {uid=, scu=, scp=} role-selection
-- requests (see mk_user_info). Use for C-GET to request
-- scp=true on the storage SOP classes you will receive.
-- @return Full PDU bytes ready to send
function build_assoc_rq(called_ae, calling_ae, sop_classes, max_pdu,
transfer_uids, roles)
called_ae = pad_str(called_ae or "ANY-SCP", 16)
calling_ae = pad_str(calling_ae or "ECHOSCU", 16)
local reserved_32 = string.rep("\x00", 32)
local body = string.pack(">I2 I2", 0x0001, 0x0000)
.. called_ae .. calling_ae .. reserved_32
body = body .. mk_item(0x10, APP_CTX_UID)
for i, sop in ipairs(sop_classes) do
local pctx_id = (2 * i - 1)
if pctx_id > 255 then pctx_id = 255 end
body = body .. mk_pres_ctx(pctx_id, sop, transfer_uids)
end
body = body .. mk_user_info(max_pdu, roles)
return string.pack(">B B I4", PDU_CODES.ASSOCIATE_REQUEST, 0x00, #body)
.. body
end
--- Parse A-ASSOCIATE-AC to extract accepted presentation contexts.
-- @param data Raw A-ASSOCIATE-AC PDU bytes
-- @return Table { pctxs = {pctx_id -> {accepted, transfer_syntax}}, max_pdu =
-- n }
function parse_assoc_ac(data)
local result = {
pctxs = {},
max_pdu = MAX_PDU_DEFAULT,
roles = {}, -- Accepted SCP/SCU role selections {uid -> {scu,scp}}
-- Fingerprinting fields (populated from A-ASSOCIATE-AC)
called_ae = "", -- Called AE Title (from fixed header)
calling_ae = "", -- Calling AE Title (from fixed header)
impl_class_uid = "", -- Implementation Class UID (User Info 0x52)
impl_version = "", -- Implementation Version Name (User Info 0x55)
protocol_ver = 0, -- Protocol Version (should be 1)
}
if #data < 10 then return result end
-- Fixed header after PDU header(6): protocol version(2) + reserved(2) +
-- called(16) + calling(16) + reserved(32) = 68
if #data >= 74 then
result.protocol_ver = string.unpack(">I2", data, 7)
-- Called AE Title: bytes 11-26 (16 bytes, space-padded)
result.called_ae = data:sub(11, 26):gsub("%s+$", "")
-- Calling AE Title: bytes 27-42 (16 bytes, space-padded)
result.calling_ae = data:sub(27, 42):gsub("%s+$", "")
end
local pos = 75
if pos >= #data then return result end
while pos <= #data - 4 do
local item_type = string.byte(data, pos)
local item_len = string.unpack(">I2", data, pos + 2)
local item_data = data:sub(pos + 4, pos + 3 + item_len)
pos = pos + 4 + item_len
if item_type == 0x21 then
-- Presentation Context (AC)
local pctx_id = string.byte(item_data, 1)
local result_code = string.byte(item_data, 3)
local ts_uid = ""
if #item_data > 4 then
local ts_type = string.byte(item_data, 5)
if ts_type == 0x40 then
local ts_len = string.unpack(">I2", item_data, 7)
ts_uid = item_data:sub(9, 8 + ts_len)
end
end
result.pctxs[pctx_id] = {
accepted = (result_code == 0x00),
transfer_syntax = ts_uid,
}
elseif item_type == 0x50 then
-- User Information sub-items (PS3.7 Annex D.3.3)
local upos = 1
while upos <= #item_data - 4 do
local u_type = string.byte(item_data, upos)
local u_len = string.unpack(">I2", item_data, upos + 2)
local u_val = item_data:sub(upos + 4, upos + 3 + u_len)
if u_type == 0x51 and u_len == 4 then
-- Maximum Length sub-item
result.max_pdu = string.unpack(">I4", item_data, upos + 4)
elseif u_type == 0x52 then
-- Implementation Class UID
result.impl_class_uid = u_val:gsub("%z", ""):gsub("%s+$", "")
elseif u_type == 0x55 then
-- Implementation Version Name
result.impl_version = u_val:gsub("%z", ""):gsub("%s+$", "")
elseif u_type == 0x54 then
-- SCP/SCU Role Selection (accepted): UID-len(2)+UID+SCU(1)+SCP(1)
if u_len >= 4 then
local uid_len = string.unpack(">I2", u_val, 1)
if uid_len + 2 <= #u_val then
local uid = u_val:sub(3, 2 + uid_len)
local scu_b = string.byte(u_val, 3 + uid_len) or 0
local scp_b = string.byte(u_val, 4 + uid_len) or 0
result.roles[uid] = { scu = (scu_b == 1), scp = (scp_b == 1) }
end
end
end
upos = upos + 4 + u_len
end
end
end
return result
end
-----------------------------------------------------------------------
-- 5. P-DATA-TF PDU BUILDER / PARSER
-----------------------------------------------------------------------
--- Chunk data into one or more P-DATA-TF PDUs (single-stream).
-- Use build_pdata_pdus when you need separate command/dataset PDU lists.
-- Use send_dimse() for the preferred combined approach.
-- @param pctx_id Presentation context ID
-- @param data Raw bytes to send
-- @param is_command true for command set, false for dataset
-- @param max_pdu Max PDU length
-- @return List of PDU byte strings
function build_pdata_pdus(pctx_id, data, is_command, max_pdu)
max_pdu = max_pdu or MAX_PDU_DEFAULT
-- Overhead: 6 (PDU header) + 4 (PDV length) + 1 (PC-ID) + 1 (MCH) = 12
local frag_size = max_pdu - 12
if frag_size < 1 then frag_size = 512 end
local pdus = {}
local total = #data
local offset = 1
while offset <= total do
local chunk_end = math.min(offset + frag_size - 1, total)
local chunk = data:sub(offset, chunk_end)
local is_last = (chunk_end >= total)
-- PS3.8 §9.3.5.1: bit 0 = 1 → command, bit 0 = 0 → dataset; bit 1 = last
local mch
if is_command then
mch = is_last and 0x03 or 0x01
else
mch = is_last and 0x02 or 0x00
end
local pdv_len = 2 + #chunk -- PC-ID + MCH + data
local pdv = string.pack(">I4 B B", pdv_len, pctx_id, mch) .. chunk
local pdu = string.pack(">B B I4", PDU_CODES.DATA, 0x00, #pdv) .. pdv
pdus[#pdus + 1] = pdu
offset = chunk_end + 1
end
return pdus
end
--- Extract command bytes and dataset bytes from a single P-DATA-TF PDU.
-- @param data Raw P-DATA-TF PDU bytes
-- @return cmd_bytes, dataset_bytes, cmd_complete, ds_complete, pctx_id
-- cmd_complete: true if the last command PDV had the "last fragment" bit set
-- ds_complete: true if the last dataset PDV had the "last fragment" bit set
-- pctx_id: presentation context ID of the PDV(s) (command PDV
-- preferred),
-- needed to reply on the correct context (e.g. C-STORE-RSP
-- during
-- C-GET sub-operations). nil if no PDV was seen.
function parse_pdata(data)
local cmd_bytes = ""
local dataset_bytes = ""
local cmd_complete = false
local ds_complete = false
local pctx_id = nil
if not data or #data < 6 then
return cmd_bytes, dataset_bytes, cmd_complete, ds_complete, pctx_id
end
local pos = 1
while pos <= #data - 5 do
local pdu_type = string.byte(data, pos)
if pdu_type ~= PDU_CODES.DATA then break end
local pdu_len = string.unpack(">I4", data, pos + 2)
local pdu_end = pos + 5 + pdu_len
local pdu_data = data:sub(pos + 6, pdu_end)
pos = pdu_end + 1
-- Walk PDV items inside this PDU
local ppos = 1
while ppos + 5 <= #pdu_data do
local pdv_len = string.unpack(">I4", pdu_data, ppos)
if pdv_len < 2 then break end
local pdv_pcid = string.byte(pdu_data, ppos + 4)
local pdv_mch = string.byte(pdu_data, ppos + 5)
local pdv_frag = pdu_data:sub(ppos + 6, ppos + 3 + pdv_len)
ppos = ppos + 4 + pdv_len
-- PS3.8 §9.3.5.1:
-- Bit 0 = 1 -> command, Bit 0 = 0 -> dataset
-- Bit 1 = 1 -> last fragment
if pdv_mch and (pdv_mch & 0x01) == 0x01 then
cmd_bytes = cmd_bytes .. pdv_frag
cmd_complete = (pdv_mch & 0x02) == 0x02
pctx_id = pdv_pcid -- command PDV context wins
else
dataset_bytes = dataset_bytes .. pdv_frag
ds_complete = (pdv_mch & 0x02) == 0x02
if pctx_id == nil then pctx_id = pdv_pcid end
end
end
end
return cmd_bytes, dataset_bytes, cmd_complete, ds_complete, pctx_id
end
-----------------------------------------------------------------------
-- 6. DATA ELEMENT ENCODING
-----------------------------------------------------------------------
--- Encode a single DICOM element in Implicit VR Little Endian (PS3.5 §7.1.2).
-- @param group Group number (e.g. 0x0000, 0x0008, 0x0010)
-- @param elem Element number
-- @param vr VR string ("US", "UL", "UI", "CS", "LO", etc.)
-- @param val Value (number for US/UL, string otherwise)
-- @return Encoded element bytes
function implicit_elem(group, elem, vr, val)
local tag_bytes = pack_le(group, 2) .. pack_le(elem, 2)
local val_bytes
if vr == "US" then
val_bytes = pack_le(val, 2)
elseif vr == "UL" then
val_bytes = pack_le(val, 4)
elseif vr == "UI" then
val_bytes = tostring(val)
if #val_bytes % 2 ~= 0 then val_bytes = val_bytes .. "\x00" end
else
-- CS, LO, SH, DA, TM, PN, IS, etc.
val_bytes = tostring(val)
if #val_bytes % 2 ~= 0 then val_bytes = val_bytes .. " " end
end
return tag_bytes .. pack_le(#val_bytes, 4) .. val_bytes
end
--- Encode a single DICOM element in Explicit VR Little Endian (PS3.5 §7.1.1).
-- Uses long format for OB/OD/OF/OL/OV/OW/SQ/UC/UN/UR/UT/SV/UV,
-- short format for all other VRs.
-- @param group Group number
-- @param elem Element number
-- @param vr VR string (2 characters)
-- @param val Value (number for US/UL/SS/SL, string otherwise)
-- @return Encoded element bytes
function explicit_elem(group, elem, vr, val)
local tag_bytes = pack_le(group, 2) .. pack_le(elem, 2)
local val_bytes
if vr == "US" then
val_bytes = pack_le(val, 2)
elseif vr == "UL" then
val_bytes = pack_le(val, 4)
elseif vr == "SS" then
val_bytes = string.pack(" {group, elem, raw, value} }
function parse_command_set(cmd_bytes)
local elements = {}
local pos = 1
while pos <= #cmd_bytes - 7 do
local group = string.unpack(" #cmd_bytes + 1 then break end
local raw = cmd_bytes:sub(pos + 8, pos + 7 + vlen)
pos = pos + 8 + vlen
local key = string.format("%04X,%04X", group, elem)
local value = raw
if group == 0x0000 then
if vlen == 2 then
value = string.unpack(" string table.
-- @param ds_bytes Raw dataset bytes
-- @return Table { "GGGG,EEEE" -> cleaned_string }
function parse_dataset_implicit(ds_bytes)
local elements = {}
local pos = 1
while pos <= #ds_bytes - 7 do
local group = string.unpack(" 0 do
local g2 = string.unpack(" #ds_bytes + 1 then break end
local raw = ds_bytes:sub(pos + 8, pos + 7 + vlen)
pos = pos + 8 + vlen
local key = string.format("%04X,%04X", group, elem)
elements[key] = raw:gsub("%z", ""):gsub("%s+$", "")
end
end
return elements
end
--- Parse Explicit VR LE dataset bytes into tag -> string table.
-- Handles both short-format and long-format VRs per PS3.5 §7.1.1.
-- @param ds_bytes Raw dataset bytes
-- @return Table { "GGGG,EEEE" -> cleaned_string }
function parse_dataset_explicit(ds_bytes)
local elements = {}
local pos = 1
while pos + 3 <= #ds_bytes do
local group = string.unpack(" #ds_bytes then break end
local vr = ds_bytes:sub(pos + 4, pos + 5)
local vlen, data_start
if LONG_VRS[vr] then
-- Long: tag(4) + VR(2) + reserved(2) + length(4) = 12
if pos + 11 > #ds_bytes then break end
vlen = string.unpack(" #ds_bytes then break end
vlen = string.unpack(" 0 do
local g2 = string.unpack(" #ds_bytes then break end
local raw = ds_bytes:sub(data_start, data_start + vlen - 1)
pos = data_start + vlen
local key = string.format("%04X,%04X", group, elem)
elements[key] = raw:gsub("%z", ""):gsub("%s+$", "")
end
::continue::
end
return elements
end
--- Parse a dataset using the appropriate decoder for the transfer syntax.
-- If the TS indicates Implicit VR LE, uses the implicit parser; otherwise
-- uses the Explicit VR parser. Includes a heuristic fallback: if explicit
-- parsing is expected but the first element's VR bytes aren't valid ASCII
-- uppercase, falls back to implicit.
-- @param ds_bytes Raw dataset bytes
-- @param transfer_syntax Transfer syntax UID
-- @return Table { "GGGG,EEEE" -> cleaned_string }
function parse_dataset(ds_bytes, transfer_syntax)
if not ds_bytes or #ds_bytes == 0 then return {} end
if transfer_syntax == TRANSFER_SYNTAX.IMPLICIT_LE then
return parse_dataset_implicit(ds_bytes)
end
-- Heuristic: check if bytes 5-6 look like a valid 2-char VR
if #ds_bytes >= 6 then
local b5 = string.byte(ds_bytes, 5)
local b6 = string.byte(ds_bytes, 6)
if (b5 >= 0x41 and b5 <= 0x5A) and (b6 >= 0x41 and b6 <= 0x5A) then
return parse_dataset_explicit(ds_bytes)
else
stdnse.debug1("DICOM: Expected Explicit VR but VR bytes 0x%02X%02X" ..
" invalid; falling back to Implicit", b5, b6)
return parse_dataset_implicit(ds_bytes)
end
end
return parse_dataset_explicit(ds_bytes)
end
-----------------------------------------------------------------------
-- 8. DIMSE COMMAND SET BUILDERS
-----------------------------------------------------------------------
-- Command sets are ALWAYS encoded in Implicit VR LE (PS3.7 §6.3.1).
--- Build a C-ECHO-RQ command set.
-- @param msg_id Message ID
-- @return Command set bytes (Implicit VR LE)
function build_cecho_rq(msg_id)
msg_id = msg_id or 1
local cmd = ""
cmd = cmd .. implicit_elem(0x0000, 0x0002, "UI", SOP_CLASS.VERIFICATION)
cmd = cmd .. implicit_elem(0x0000, 0x0100, "US", COMMAND_FIELD.C_ECHO_RQ)
cmd = cmd .. implicit_elem(0x0000, 0x0110, "US", msg_id)
cmd = cmd .. implicit_elem(0x0000, 0x0800, "US", DATASET_NOT_PRESENT)
return implicit_elem(0x0000, 0x0000, "UL", #cmd) .. cmd
end
--- Build a C-STORE-RQ command set.
-- @param msg_id Message ID
-- @param sop_class_uid Affected SOP Class UID
-- @param sop_instance_uid Affected SOP Instance UID
-- @param priority Priority (default: LOW = 0x0002)
-- @return Command set bytes (Implicit VR LE)
function build_cstore_rq(msg_id, sop_class_uid, sop_instance_uid, priority)
priority = priority or 0x0002
local cmd = ""
cmd = cmd .. implicit_elem(0x0000, 0x0002, "UI", sop_class_uid)
cmd = cmd .. implicit_elem(0x0000, 0x0100, "US", COMMAND_FIELD.C_STORE_RQ)
cmd = cmd .. implicit_elem(0x0000, 0x0110, "US", msg_id)
cmd = cmd .. implicit_elem(0x0000, 0x0700, "US", priority)
cmd = cmd .. implicit_elem(0x0000, 0x0800, "US", DATASET_PRESENT)
cmd = cmd .. implicit_elem(0x0000, 0x1000, "UI", sop_instance_uid)
return implicit_elem(0x0000, 0x0000, "UL", #cmd) .. cmd
end
--- Build a C-FIND-RQ command set.
-- @param msg_id Message ID
-- @param sop_class_uid Abstract Syntax UID for the Q/R information model
-- @param priority Priority (default: LOW = 0x0002)
-- @return Command set bytes (Implicit VR LE)
function build_cfind_rq(msg_id, sop_class_uid, priority)
priority = priority or 0x0002
local cmd = ""
cmd = cmd .. implicit_elem(0x0000, 0x0002, "UI", sop_class_uid)
cmd = cmd .. implicit_elem(0x0000, 0x0100, "US", COMMAND_FIELD.C_FIND_RQ)
cmd = cmd .. implicit_elem(0x0000, 0x0110, "US", msg_id)
cmd = cmd .. implicit_elem(0x0000, 0x0700, "US", priority)
cmd = cmd .. implicit_elem(0x0000, 0x0800, "US", DATASET_PRESENT)
return implicit_elem(0x0000, 0x0000, "UL", #cmd) .. cmd
end
--- Build a C-GET-RQ command set.
-- @param msg_id Message ID
-- @param sop_class_uid Abstract Syntax UID for the Q/R information model
-- @param priority Priority (default: LOW = 0x0002)
-- @return Command set bytes (Implicit VR LE)
function build_cget_rq(msg_id, sop_class_uid, priority)
priority = priority or 0x0002
local cmd = ""
cmd = cmd .. implicit_elem(0x0000, 0x0002, "UI", sop_class_uid)
cmd = cmd .. implicit_elem(0x0000, 0x0100, "US", COMMAND_FIELD.C_GET_RQ)
cmd = cmd .. implicit_elem(0x0000, 0x0110, "US", msg_id)
cmd = cmd .. implicit_elem(0x0000, 0x0700, "US", priority)
cmd = cmd .. implicit_elem(0x0000, 0x0800, "US", DATASET_PRESENT)
return implicit_elem(0x0000, 0x0000, "UL", #cmd) .. cmd
end
--- Build a C-MOVE-RQ command set.
-- @param msg_id Message ID
-- @param sop_class_uid Abstract Syntax UID for the Q/R information model
-- @param move_dest Move Destination AE Title (max 16 chars,
-- space-padded)
-- @param priority Priority (default: LOW = 0x0002)
-- @return Command set bytes (Implicit VR LE)
function build_cmove_rq(msg_id, sop_class_uid, move_dest, priority)
priority = priority or 0x0002
-- MoveDestination (0000,0600) is AE type: max 16 chars, space-padded, even
-- length
local dest = move_dest or "MOVESCP"
if #dest > 16 then dest = dest:sub(1, 16) end
-- Pad to even length with spaces (AE VR convention)
if #dest % 2 ~= 0 then dest = dest .. " " end
local cmd = ""
cmd = cmd .. implicit_elem(0x0000, 0x0002, "UI", sop_class_uid)
cmd = cmd .. implicit_elem(0x0000, 0x0100, "US", COMMAND_FIELD.C_MOVE_RQ)
cmd = cmd .. implicit_elem(0x0000, 0x0110, "US", msg_id)
cmd = cmd .. implicit_elem(0x0000, 0x0600, "AE", dest)
cmd = cmd .. implicit_elem(0x0000, 0x0700, "US", priority)
cmd = cmd .. implicit_elem(0x0000, 0x0800, "US", DATASET_PRESENT)
return implicit_elem(0x0000, 0x0000, "UL", #cmd) .. cmd
end
--- Build a C-STORE-RSP command set.
-- @param msg_id_rsp Message ID Being Responded To
-- @param sop_class_uid Affected SOP Class UID
-- @param sop_inst_uid Affected SOP Instance UID
-- @param status_code Status code (default: SUCCESS = 0x0000)
-- @return Command set bytes (Implicit VR LE)
function build_cstore_rsp(msg_id_rsp, sop_class_uid, sop_inst_uid, status_code)
status_code = status_code or STATUS.SUCCESS
local cmd = ""
cmd = cmd .. implicit_elem(0x0000, 0x0002, "UI", sop_class_uid)
cmd = cmd .. implicit_elem(0x0000, 0x0100, "US", COMMAND_FIELD.C_STORE_RSP)
cmd = cmd .. implicit_elem(0x0000, 0x0120, "US", msg_id_rsp)
cmd = cmd .. implicit_elem(0x0000, 0x0800, "US", DATASET_NOT_PRESENT)
cmd = cmd .. implicit_elem(0x0000, 0x0900, "US", status_code)
cmd = cmd .. implicit_elem(0x0000, 0x1000, "UI", sop_inst_uid)
return implicit_elem(0x0000, 0x0000, "UL", #cmd) .. cmd
end
--- Build a C-FIND query dataset for a given Q/R level.
-- @param level QR_LEVEL value ("STUDY", "SERIES", "IMAGE")
-- @param query_tags List of {group, elem, vr, value} tag tables
-- @param transfer_syntax Transfer syntax for encoding (default: Explicit VR
-- LE)
-- @return Dataset bytes
function build_cfind_dataset(level, query_tags, transfer_syntax)
-- Prepend QueryRetrieveLevel
local all_tags = {{group=0x0008, elem=0x0052, vr="CS", value=level}}
for _, t in ipairs(query_tags) do
if not (t.group == 0x0008 and t.elem == 0x0052) then
all_tags[#all_tags + 1] = t
end
end
return encode_dataset(all_tags, transfer_syntax)
end
--- Build a C-GET query dataset.
-- @param level QR_LEVEL value
-- @param query_tags List of {group, elem, vr, value} tag tables
-- @param transfer_syntax Transfer syntax for encoding
-- @return Dataset bytes
function build_cget_dataset(level, query_tags, transfer_syntax)
return build_cfind_dataset(level, query_tags, transfer_syntax)
end
-----------------------------------------------------------------------
-- 9. DIMSE RESPONSE PARSERS
-----------------------------------------------------------------------
--- Parse a C-STORE-RSP from P-DATA-TF PDU bytes.
-- @param data Raw PDU bytes
-- @return status_code (int), error_comment (string)
function parse_cstore_rsp(data)
local cmd_bytes = parse_pdata(data)
local elems = parse_command_set(cmd_bytes)
local status = 0xFFFF
local error_comment = ""
if elems["0000,0900"] then status = elems["0000,0900"].value end
if elems["0000,0902"] then
error_comment = tostring(elems["0000,0902"].value):gsub("%z",
""):gsub("%s+$", "")
end
return status, error_comment
end
--- Parse a C-FIND-RSP from P-DATA-TF PDU bytes.
-- @param data Raw PDU bytes
-- @param transfer_syntax Transfer syntax for dataset decoding (default:
-- Explicit VR LE)
-- @return status (int), dataset_elements (table or nil), cmd_elements (table)
function parse_cfind_rsp(data, transfer_syntax)
transfer_syntax = transfer_syntax or TRANSFER_SYNTAX.EXPLICIT_LE
local cmd_bytes, dataset_bytes = parse_pdata(data)
local cmd_elems = parse_command_set(cmd_bytes)
local status = 0xFFFF
if cmd_elems["0000,0900"] then
status = cmd_elems["0000,0900"].value
end
-- CommandDataSetType: 0x0101 = no dataset
local has_dataset = true
if cmd_elems["0000,0800"] then
has_dataset = (cmd_elems["0000,0800"].value ~= DATASET_NOT_PRESENT)
end
local ds_elems = nil
if has_dataset and #dataset_bytes > 0 then
ds_elems = parse_dataset(dataset_bytes, transfer_syntax)
end
return status, ds_elems, cmd_elems
end
-----------------------------------------------------------------------
-- 10. NETWORK LAYER
-----------------------------------------------------------------------
--- Create a new Nmap socket with timeout.
function new_sock(timeout_s)
local s = nmap.new_socket()
s:set_timeout((timeout_s or 10) * 1000)
return s
end
--- Whether DICOM-over-TLS is requested via the global "dicom.tls" script-arg.
-- @return true if dicom.tls=true was supplied on the command line
function tls_enabled()
return stdnse.get_script_args("dicom.tls") == "true"
end
--- Connect a socket. Returns ok, err.
-- @param sock Nmap socket
-- @param host Host object
-- @param port Port object (or number)
-- @param tls true = TLS, false = plain TCP, nil = honor the dicom.tls arg
function tcp_connect(sock, host, port, tls)
if tls == nil then tls = tls_enabled() end
return sock:connect(host, port, tls and "ssl" or "tcp")
end
--- Fetch the peer's TLS certificate from a connected TLS socket, if any.
-- @param sock Connected Nmap socket (TLS)
-- @return certificate table (see nmap sslcert), or nil
function get_cert(sock)
local ok, cert = pcall(function() return sock:get_ssl_certificate() end)
if ok then return cert end
return nil
end
--- Send all bytes on a socket. Returns ok, err.
function tcp_send(sock, data)
if not data or #data == 0 then return true end
return sock:send(data)
end
--- Receive bytes until one complete PDU is assembled.
-- Accepts an optional carry buffer containing leftover bytes from a
-- previous call (when multiple PDUs arrive in a single TCP segment).
-- Returns the extracted PDU **and** any remaining bytes so the caller
-- can feed them back into the next call.
--
-- @param sock Nmap socket
-- @param timeout_s Timeout in seconds (used for error classification)
-- @param carry (optional) leftover bytes from a previous recv_pdu call
-- @return ok (bool), data_or_err (string), remaining (string – leftover bytes,
-- empty on error)
function recv_pdu(sock, timeout_s, carry)
local buf = carry or ""
-- If carry already contains a full PDU, extract it without reading the
-- socket
if #buf >= 6 then
local pdu_len = string.unpack(">I4", buf, 3)
if #buf >= 6 + pdu_len then
local pdu_data = buf:sub(1, 6 + pdu_len)
local remaining = buf:sub(6 + pdu_len + 1)
return true, pdu_data, remaining
end
end
local ok, chunk
for _ = 1, 30 do
ok, chunk = sock:receive()
if not ok then
if chunk and chunk:find("TIMEOUT") then
return false, "TIMEOUT", ""
end
return false, "CONNECTION_RESET", ""
end
buf = buf .. chunk
if #buf >= 6 then
local pdu_len = string.unpack(">I4", buf, 3)
if #buf >= 6 + pdu_len then
local pdu_data = buf:sub(1, 6 + pdu_len)
local remaining = buf:sub(6 + pdu_len + 1)
return true, pdu_data, remaining
end
end
end
if #buf > 0 then return true, buf, "" end
return false, "NO_DATA", ""
end
-----------------------------------------------------------------------
-- 11. DIMSE MESSAGE I/O
-----------------------------------------------------------------------
--- Send a DIMSE message (command set + optional dataset) as P-DATA-TF PDU(s).
--
-- When both the command and dataset fit within a single PDU, they are
-- combined into one P-DATA-TF PDU with two PDV items (matching the
-- behaviour of DCMTK and pynetdicom). For larger payloads, the dataset
-- is fragmented across multiple PDUs.
--
-- Command sets are always Implicit VR LE. The dataset must already be
-- encoded in the negotiated transfer syntax before calling this function.
--
-- @param sock Nmap socket
-- @param pctx_id Accepted presentation context ID
-- @param cmd_bytes Command set bytes (Implicit VR LE)
-- @param ds_bytes Dataset bytes (already encoded in negotiated TS), or nil
-- @param max_pdu Max PDU size (use server's negotiated value)
-- @return ok (bool), err (string or nil)
function send_dimse(sock, pctx_id, cmd_bytes, ds_bytes, max_pdu)
max_pdu = max_pdu or MAX_PDU_DEFAULT
local max_frag = max_pdu - 12
if max_frag < 1 then max_frag = 512 end
-- Build command PDV: MCH 0x03 = command + last fragment
-- PS3.8 §9.3.5.1: Bit 0 = 1 → command, Bit 1 = 1 → last fragment
local cmd_pdv = string.pack(">I4 B B", 2 + #cmd_bytes, pctx_id, 0x03)
.. cmd_bytes
-- Always send command in its own P-DATA-TF PDU.
-- Some DICOM implementations (including certain Orthanc/DCMTK builds)
-- do not correctly handle multi-PDV P-DATA-TF PDUs where both the
-- command and dataset PDV items are combined into one PDU. Sending
-- them in separate PDUs is the most compatible approach and is
-- explicitly allowed by PS3.8 §9.3.5.
local pdu1 = string.pack(">B B I4", PDU_CODES.DATA, 0x00, #cmd_pdv)
.. cmd_pdv
local ok, err = tcp_send(sock, pdu1)
if not ok then return false, err end
if not ds_bytes or #ds_bytes == 0 then
return true -- Command-only (e.g., C-ECHO-RQ)
end
-- Fragment dataset across P-DATA-TF PDU(s)
local offset = 1
while offset <= #ds_bytes do
local chunk_end = math.min(offset + max_frag - 1, #ds_bytes)
local chunk = ds_bytes:sub(offset, chunk_end)
local is_last = (chunk_end >= #ds_bytes)
local mch = is_last and 0x02
or 0x00 -- PS3.8: bit 0=0 → dataset, bit 1=1 → last
local ds_pdv = string.pack(">I4 B B", 2 + #chunk, pctx_id, mch) .. chunk
local pdu = string.pack(">B B I4", PDU_CODES.DATA, 0x00, #ds_pdv) .. ds_pdv
ok, err = tcp_send(sock, pdu)
if not ok then return false, err end
offset = chunk_end + 1
end
return true
end
--- Receive a single DIMSE response (command + optional dataset).
--
-- The server may send the command and dataset in a single P-DATA-TF PDU
-- (with two PDV items) or in separate P-DATA-TF PDUs. This function
-- handles both cases by checking CommandDataSetType (0000,0800) in the
-- command set: if a dataset is expected but not yet received, it reads
-- the next PDU to obtain it.
--
-- Accepts and returns a carry buffer so the caller can chain calls
-- without losing bytes when multiple PDUs arrive in one TCP segment.
--
-- @param sock Nmap socket
-- @param timeout_s Timeout in seconds
-- @param carry (optional) leftover bytes from previous recv_pdu
-- @return pdu_type (int or nil), cmd_elems (table), ds_bytes (string),
-- raw_pdu (string), remaining (string – leftover bytes for next call),
-- pctx_id (int or nil – presentation context the message arrived on;
-- needed to reply on the correct context during C-GET sub-operations)
function recv_dimse(sock, timeout_s, carry)
local remaining = carry or ""
-- Read first PDU (should contain at least the command set)
local ok, data
ok, data, remaining = recv_pdu(sock, timeout_s, remaining)
if not ok then return nil, nil, "", data, remaining, nil end
local pdu_type = string.byte(data, 1)
if pdu_type ~= PDU_CODES.DATA then
return pdu_type, nil, "", data, remaining, nil
end
local cmd_bytes, ds_bytes, cmd_complete, ds_complete,
pctx_id = parse_pdata(data)
local cmd_elems = {}
if #cmd_bytes > 0 then
cmd_elems = parse_command_set(cmd_bytes)
end
-- Check if command indicates a dataset is present
-- CommandDataSetType (0000,0800): 0x0101 = no dataset, anything else =
-- dataset present
local has_dataset = false
if cmd_elems["0000,0800"] then
has_dataset = (cmd_elems["0000,0800"].value ~= DATASET_NOT_PRESENT)
end
if has_dataset then
-- Read additional PDUs until we have the complete dataset.
-- The dataset may span many P-DATA-TF PDUs (common for images).
-- We stop when the last dataset PDV has MCH bit 1 set (last fragment).
local max_reads = 2000 -- safety limit for very large datasets
local reads = 0
while not ds_complete and reads < max_reads do
reads = reads + 1
local ok2, data2
ok2, data2, remaining = recv_pdu(sock, timeout_s, remaining)
if not ok2 then
stdnse.debug2("recv_dimse: dataset reassembly read error: %s",
tostring(data2))
break
end
local pdu_type2 = string.byte(data2, 1)
if pdu_type2 ~= PDU_CODES.DATA then
-- Non-DATA PDU arrived mid-dataset (e.g. A-ABORT).
-- Return what we have; caller gets the unexpected PDU next time via
-- carry.
-- Push data2 back into remaining so the next recv_pdu finds it.
remaining = data2 .. remaining
break
end
local extra_cmd, extra_ds, _, extra_ds_complete = parse_pdata(data2)
if #extra_cmd > 0 then
cmd_bytes = cmd_bytes .. extra_cmd
end
ds_bytes = ds_bytes .. extra_ds
ds_complete = extra_ds_complete
end
end
return pdu_type, cmd_elems, ds_bytes, data, remaining, pctx_id
end
-----------------------------------------------------------------------
-- 12. CONNECTION / ASSOCIATION MANAGEMENT
-----------------------------------------------------------------------
-- ── Legacy API (backward-compatible with built-in dicom.lua) ───────
--- Legacy: start_connection(host, port) — opens TCP socket.
function start_connection(host, port)
local dcm = {}
local status, err
dcm['socket'] = nmap.new_socket()
local proto = tls_enabled() and "ssl" or "tcp"
status, err = dcm['socket']:connect(host, port, proto)
if status == false then
return false, "DICOM: Failed to connect to host: " .. err
end
return true, dcm
end
--- Legacy: send(dcm, data)
function send(dcm, data)
stdnse.debug2("DICOM: Sending DICOM packet (%d)", #data)
if dcm['socket'] then
local status, err = dcm['socket']:send(data)
if status == false then return false, err end
else
return false, "No socket found. Check your DICOM object"
end
return true
end
--- Legacy: receive(dcm)
function receive(dcm)
local status, data = dcm['socket']:receive()
if status == false then return false, data end
stdnse.debug1("DICOM: receive() read %d bytes", #data)
return true, data
end
--- Legacy: associate(host, port, calling_aet, called_aet)
function associate(host, port, calling_aet, called_aet)
local application_context = ""
local presentation_context = ""
local userinfo_context = ""
local status, dcm = start_connection(host, port)
if status == false then return false, dcm end
local application_context_name = "1.2.840.10008.3.1.1.1"
application_context = string.pack(">B B s2", 0x10, 0x0,
application_context_name)
local abstract_syntax_name = "1.2.840.10008.1.1"
local transfer_syntax_name = "1.2.840.10008.1.2"
presentation_context = string.pack(">B B I2 B B B B B B s2 B B s2",
0x20, 0x0, 0x2e,
0x1, 0x0, 0x0, 0x0,
0x30, 0x0, abstract_syntax_name,
0x40, 0x0, transfer_syntax_name)
local implementation_id = IMPL_CLASS_UID
local implementation_version = IMPL_VERSION
userinfo_context = string.pack(">B B I2 B B I2 I4 B B s2 B B s2",
0x50, 0x0, 0x3a,
0x51, 0x0, 0x04, 0x4000,
0x52, 0x0, implementation_id,
0x55, 0x0, implementation_version)
local called_ae_title = called_aet
or stdnse.get_script_args("dicom.called_aet") or "ANY-SCP"
local calling_ae_title = calling_aet
or stdnse.get_script_args("dicom.calling_aet") or "ECHOSCU"
if #called_ae_title > 16 or #calling_ae_title > 16 then
return false,
"Calling/Called Application Entity Title must be less than 16 bytes"
end
called_ae_title = ("%-16s"):format(called_ae_title)
calling_ae_title = ("%-16s"):format(calling_ae_title)
local assoc_request = string.pack(">I2 I2 c16 c16 c32",
0x1, 0x0, called_ae_title, calling_ae_title, "")
.. application_context
.. presentation_context
.. userinfo_context
local status, header = pdu_header_encode(PDU_CODES["ASSOCIATE_REQUEST"],
#assoc_request)
if status == false then return false, header end
assoc_request = header .. assoc_request
if #assoc_request < MIN_SIZE_ASSOC_REQ then
return false, string.format(
"ASSOCIATE request PDU must be at least %d bytes and we tried to send" ..
" %d.",
MIN_SIZE_ASSOC_REQ, #assoc_request)
end
local status, err = send(dcm, assoc_request)
if status == false then
return false, string.format("Couldn't send ASSOCIATE request:%s", err)
end
status, err = receive(dcm)
if status == false then
return false, string.format("Couldn't read ASSOCIATE response:%s", err)
end
local resp_type, _, resp_length = string.unpack(">B B I4", err)
stdnse.debug1("PDU Type:%d Length:%d", resp_type, resp_length)
if resp_type == PDU_CODES["ASSOCIATE_ACCEPT"] then
stdnse.debug1("ASSOCIATE ACCEPT message found!")
return true, dcm
elseif resp_type == PDU_CODES["ASSOCIATE_REJECT"] then
stdnse.debug1("ASSOCIATE REJECT message found!")
return false, "ASSOCIATE REJECT received"
else
return false, "Received unknown response"
end
end
--- Legacy: send_pdata(dicom, data)
function send_pdata(dicom, data)
local status, header = pdu_header_encode(PDU_CODES["DATA"], #data)
if status == false then return false, header end
local err
status, err = send(dicom, header .. data)
if status == false then return false, err end
end
-- ── New API ────────────────────────────────────────────────────────
--- Perform A-ASSOCIATE-RQ and parse A-ASSOCIATE-AC.
-- @param host Nmap host object
-- @param port Nmap port object
-- @param called_ae Called AE Title
-- @param calling_ae Calling AE Title
-- @param sop_classes List of SOP Class UIDs to propose
-- @param max_pdu Max PDU length
-- @param timeout_s Socket timeout in seconds
-- @param transfer_uids Optional list of TS UIDs for presentation contexts
-- @param roles Optional list of {uid=,scu=,scp=} SCP/SCU role
-- selection requests (request scp=true on storage SOP
-- classes for C-GET). Accepted roles are returned in
-- the ac_info table (6th return value) as .roles.
-- @param tls If true, wrap the association in TLS (DICOM-over-TLS);
-- the peer certificate is returned in ac_info.cert.
-- @return ok, sock_or_err, pctx_map, server_max_pdu, elapsed_ms, ac_info
function do_associate(host, port, called_ae, calling_ae, sop_classes, max_pdu,
timeout_s, transfer_uids, roles, tls)
if tls == nil then tls = tls_enabled() end
local sock = new_sock(timeout_s)
local t0 = nmap.clock_ms()
local ok, err = tcp_connect(sock, host, port, tls)
if not ok then
sock:close()
return false, "CONN_REFUSED", nil, nil, nmap.clock_ms() - t0
end
local assoc_rq = build_assoc_rq(called_ae, calling_ae, sop_classes, max_pdu,
transfer_uids, roles)
ok, err = tcp_send(sock, assoc_rq)
if not ok then
sock:close()
return false, "SEND_FAILED", nil, nil, nmap.clock_ms() - t0
end
local resp
ok, resp = recv_pdu(sock, timeout_s)
local elapsed = nmap.clock_ms() - t0
if not ok then
sock:close()
return false, resp, nil, nil, elapsed
end
local pdu_type = string.byte(resp, 1)
if pdu_type == PDU_CODES.ASSOCIATE_ACCEPT then
local ac_info = parse_assoc_ac(resp)
if tls then ac_info.cert = get_cert(sock) end
return true, sock, ac_info.pctxs, ac_info.max_pdu, elapsed, ac_info
elseif pdu_type == PDU_CODES.ASSOCIATE_REJECT then
sock:close()
local result_v, source, reason = 0, 0, 0
if #resp >= 10 then
result_v = string.byte(resp, 8)
source = string.byte(resp, 9)
reason = string.byte(resp, 10)
end
return false,
string.format("REJECT(result=%d,source=%d,reason=%d)", result_v, source,
reason),
nil, nil, elapsed
elseif pdu_type == PDU_CODES.ABORT then
sock:close()
return false, "A-ABORT", nil, nil, elapsed
else
sock:close()
return false,
string.format("UNEXPECTED_PDU(0x%02X)", pdu_type),
nil, nil, elapsed
end
end
--- Send A-RELEASE-RQ and wait for A-RELEASE-RP (best-effort).
function do_release(sock, timeout_s)
local rel_rq = string.pack(">B B I4 I4",
PDU_CODES.RELEASE_REQUEST, 0x00, 0x04, 0x00000000)
sock:set_timeout((timeout_s or 3) * 1000)
tcp_send(sock, rel_rq)
pcall(function() sock:receive() end)
sock:close()
end
--- Find the first accepted presentation context ID from a pctx map.
-- @param pctxs Table from parse_assoc_ac {pctx_id -> {accepted,
-- transfer_syntax}}
-- @return pctx_id (int) or nil
function pick_accepted_pctx(pctxs)
local sorted = {}
for pid, _ in pairs(pctxs) do sorted[#sorted + 1] = pid end
table.sort(sorted)
for _, pid in ipairs(sorted) do
if pctxs[pid].accepted then return pid end
end
return nil
end
--- Get the negotiated transfer syntax for an accepted presentation context.
-- @param pctxs Table from parse_assoc_ac
-- @param pctx_id Presentation context ID
-- @return Transfer syntax UID string, or nil
function get_accepted_ts(pctxs, pctx_id)
if pctxs and pctx_id and pctxs[pctx_id] and pctxs[pctx_id].accepted then
return pctxs[pctx_id].transfer_syntax
end
return nil
end
-----------------------------------------------------------------------
-- 13. FILE META / DATASET EXTRACTION
-----------------------------------------------------------------------
--- Extract the DICOM dataset from a raw .dcm file.
-- Strips 128-byte preamble + "DICM" prefix and File Meta Information.
-- @param filepath Path to .dcm file
-- @return dataset_bytes, sop_class_uid, sop_instance_uid, transfer_syntax_uid,
-- err
function read_dcm_dataset(filepath)
local data, err = read_file(filepath)
if not data then
return nil, nil, nil, nil, err
end
if #data >= 132 and data:sub(129, 132) == "DICM" then
local pos = 133
local sop_class = ""
local sop_instance = ""
local ts_uid = TRANSFER_SYNTAX.EXPLICIT_LE
-- Parse File Meta Information (always Explicit VR LE, group 0002)
while pos <= #data - 8 do
local grp = string.unpack("