DICOM: la especificación existe, pero no todos la siguen

Cada objeto DICOM trae decenas de campos de texto pequeños: fechas, horas, edades, nombres de pacientes, cadenas de código, títulos de enrutamiento. El estándar no deja su formato al gusto de cada quien. La Tabla 6.2-1 de la sección 6.2 de PS3.5 define una gramática para cada Value Representation (VR): el repertorio exacto de caracteres, el límite de longitud y, en fechas y horas, el rango numérico. Las columnas “Definition” y “Length of Value” tienen fuerza de shall: son obligatorias.
Las reglas están ahí. Este artículo responde con datos una pregunta más sencilla: ¿quién las está siguiendo en realidad?
Para averiguarlo escaneé un corpus diverso de datos DICOM públicos: 170,730 archivos parseables, unos 68 GB, de 35 fabricantes, 18 modalidades y 50 instituciones. La herramienta revisa únicamente los campos donde un valor malo causa daño real más adelante en la cadena. La versión corta: la mayoría de los archivos están limpios, pero las violaciones que existen se concentran en patrones predecibles y evitables, y todos los toolkits comunes se las pasan al consumidor sin decir una palabra.
Qué revisé, y por qué importa el método
El escáner es dicom_vr_scanner.py (PWNLAB, v2.0), publicado en el repositorio dicom-audit. Valida diez VR de texto (DS, IS, DA, TM, DT, AS, CS, PN, AE, UR) contra la Tabla 6.2-1 de DICOM PS3.5 2026b. A un valor que no pasa la revisión le llamo valor falsificado: texto que parece un valor legítimo, se cuela por un parser que no valida, se entrega al consumidor como si fuera conforme y después se desvía de todo lo que el estándar permite.
Una decisión de diseño define si los resultados significan algo. El escáner lee los bytes crudos de cada elemento tal como están en disco, no el valor después de que la librería lo parseó. No confía en la lógica de conversión que es justamente el objeto de los hallazgos. pydicom se usa solo para recorrer la estructura del archivo. El veredicto sobre cada valor se toma sobre los bytes que el fabricante realmente escribió.
Eso importa por cómo se comportan las librerías. Con el valor por defecto de instalación de pydicom, reading_validation_mode = 1, una fecha malformada o una cadena de código en minúsculas produce un UserWarning no fatal y luego devuelve el valor sin cambios. Llamémosle advierte pero deja pasar. Con reading_validation_mode = 0, que muchos equipos configuran para callar el ruido de advertencias en lecturas masivas, ni siquiera queda la advertencia. dcm4che, DCMTK, GDCM y dcmjs están en el mismo punto. La Tabla 6.2-1 es normativa, y las librerías comunes no hacen cumplir nada de ella en la capa de valores. Le entregan los bytes crudos a lo que vaya a calcular con ellos a continuación.
Así que un valor malo no se detiene en la puerta. Lo atrapa, si acaso, el segundo consumidor de la cadena: el motor de retención, el parser estricto de fechas, el re-serializador de HL7, el asignador de arreglos en memoria. Ahí es donde vive el daño.
Los números
En los 170,730 archivos, el escáner marcó 2,751 valores falsificados en 446 archivos. Alrededor del 0.26 % del corpus.
| Severidad | Hallazgos |
|---|---|
| HIGH | 1,549 |
| MEDIUM | 737 |
| LOW | 465 |
Esos hallazgos se dividen en dos historias muy distintas, sin traslape entre los archivos involucrados. Separarlas es el punto: implican partes diferentes del ecosistema y necesitan arreglos diferentes.
Historia uno: la anonimización está produciendo archivos no conformes sin que nadie lo note
2,665 de los 2,751 hallazgos, el 97 %, son un solo valor: ***.
Aparecen en 405 archivos, exactamente en los campos que tocaría un anonimizador.
| Tag | VR | Archivos |
|---|---|---|
| StudyDate | DA | 405 |
| SeriesDate | DA | 405 |
| ContentDate | DA | 405 |
| PatientBirthDate | DA | 405 |
| PatientAge | AS | 405 |
| AcquisitionDate | DA | 320 |
| AcquisitionDateTime | DT | 320 |
Hasta siete campos de fecha y edad por archivo, todos sobrescritos con ***. Es un pipeline de desidentificación que sustituye fechas y edades por un marcador. *** no es un valor legal para ninguna de estas VR. DA permite únicamente “0”-“9” en un formato fijo de 8 bytes YYYYMMDD. AS permite únicamente nnn seguido de D/W/M/Y. DT permite únicamente dígitos, +, -, . y espacio. Un asterisco está fuera de todos esos repertorios.
Esta es la historia grande, porque la desidentificación no es un caso raro. Es un paso obligatorio antes de que los datos de imagen salgan de un hospital hacia investigación, entrenamiento de IA, un reporte de bug a un proveedor o un dataset público. Si el anonimizador emite ***, cada copia posterior de ese estudio es no conforme por construcción, a escala y para siempre. PS3.15 (los Attribute Confidentiality Profiles) dice qué atributos limpiar y da formas conformes de hacerlo: un valor ficticio válido, un desplazamiento de fechas o eliminar el elemento. Una fecha vacía es legal. *** no lo es.
El modo de falla es el consumidor estricto. Un pipeline que ejecuta datetime.strptime(ds.StudyDate, "%Y%m%d"), o una ingesta en Java que llama LocalDate.parse, no recibe ***. Recibe una excepción no manejada y un worker muerto. El PACS permisivo que escribió el archivo nunca se quejó. La herramienta estricta que lo recibió se cae.
Historia dos: los bugs de formato reales, y de dónde vienen
Quita los marcadores del anonimizador y quedan 86 hallazgos en 41 archivos: 14 HIGH, 12 MEDIUM, 60 LOW. Estos son los bugs de conformidad de verdad. Valores que un productor escribió mal, no el marcador de un scrubber. Cada uno es un error distinto y reproducible con una cláusula detrás, y todos se encontraron en datos con los que toolkits y fabricantes construyen y prueban todos los días.
Fechas con puntos y horas con dos puntos heredadas. Hay archivos con StudyDate = 1994.01.30 y StudyTime = 11:25:01. Es el viejo formato ACR-NEMA 300 YYYY.MM.DD / HH:MM:SS, y DICOM lo nombra directamente: la Nota 1 de DA en la Tabla 6.2-1 dice “Use of this format is not compliant”. El . y el : están fuera de los repertorios de DA y TM. Treinta años después de que el formato quedó obsoleto, se sigue emitiendo, y todo lector que no valida lo sigue aceptando.
NaN donde va una fecha y hora. 28 hallazgos meten la cadena literal NaN en FrameAcquisitionDateTime y FrameReferenceDateTime (VR DT). A algún productor se le fugó un centinela de punto flotante hacia un campo de fecha en la ruta de número a cadena. Un consumidor de DT que verifique rangos o parsee esto obtiene basura. Uno que ordene cuadros por hora de adquisición obtiene un orden indefinido.
Fechas en cero y el año 0000. Hay archivos con 00000000 en StudyDate, SeriesDate, ContentDate, InstanceCreationDate y las fechas de pasos de procedimiento, más un DT de 00000000000000.59000: una fracción de segundo colgada de una marca de tiempo del año cero. 00000000 no es una fecha gregoriana válida. No existe el año 0, ni el mes 0, ni el día 0. Algunos anonimizadores lo usan como marcador de fecha nula, pero la fecha nula correcta es un valor vacío, no ocho ceros. Estos aparecieron en el dataset de QA de un fabricante. Los datos de referencia en los que el ecosistema confía.
Cadenas de código en minúsculas. 25 hallazgos ponen Chest y Brain en BodyPartExamined, un campo CS. CS es solo mayúsculas por repertorio. El término definido es CHEST, no Chest. Vienen de un dataset de anotaciones derivadas de IA: un productor de software moderno, no un escáner antiguo, que es exactamente el tipo de herramienta nueva que hoy está entrando a los pipelines clínicos. Un switch sensible a mayúsculas sobre BodyPartExamined cae en silencio a su rama por defecto con Chest.
Una descripción metida en un campo de código. Un valor CS dice Tomoscintigrafia PET total body. Texto libre, con mayúsculas y minúsculas, muy por encima del límite de 16 bytes de CS, en un campo definido para conceptos controlados. Cualquier consumidor que trate ese campo como llave enumerada, interruptor de enrutamiento o llave de índice ahora tiene una oración de 31 caracteres.
Números que no son números. NumberOfFrames = 1A (VR IS, que solo permite [+-]?dígitos). NumberOfFractionsPlanned = 10.000, un punto decimal en un campo entero, y además es un valor de un plan de radioterapia. InstanceCreationTime = 17146, un TM con un número impar de dígitos que no parsea a nada válido. Y PatientOrientation = L/P, una diagonal dentro de un CS, donde el delimitador multivalor es la barra invertida y / está fuera del repertorio.
La atribución aquí es honesta y más útil que el nombre de un fabricante. Estas violaciones viven en los fixtures de prueba de dcm4che, fo-dicom, dcmjs y pydicom, en datasets de QA de fabricantes y en publicaciones de anotaciones de IA. Son los archivos que los desarrolladores usan para demostrar que su código DICOM funciona. Los toolkits conservan muchos de ellos precisamente porque alguna vez un escáner real produjo algo parecido, y luego lo leyeron de vuelta sin protestar.
Por qué una cadena mala es un riesgo real
Es fácil archivar todo esto como cosmético. No lo es. El peligro nunca está en el valor. Está en el consumidor que confía en el contrato del campo. El escáner asigna severidad según lo que ese tag controla, y ese mapeo es donde el riesgo se vuelve concreto.
Las fechas y horas controlan decisiones, no solo lo que se muestra en pantalla.
- Retención y purga automática. Los archivos históricos borran o migran estudios por
StudyDate,InstanceCreationDateyContentDatecontra un horizonte de retención. Una fecha falsificada muy en el futuro o muy en el pasado puede quedarse para siempre fuera de cualquier ventana de purga, una falla de privacidad y cumplimiento, o provocar el borrado prematuro de un estudio vivo.***y00000000rompen esto por igual. - Emparejamiento de estudios previos y protocolos de colgado. El PACS ordena los estudios previos por fecha y hora para mostrarlos lado a lado. Fechas mal ordenadas o imposibles de parsear muestran el estudio previo equivocado, o esconden el correcto. Eso es un error de seguridad clínica, no uno cosmético.
- Agenda.
ScheduledProcedureStepStartDate/Timeenruta las listas de trabajo de modalidad. Una hora de agenda falsificada enruta mal o descarta una orden. - DoS por parser estricto. Cualquier segundo consumidor que convierta a un tipo nativo (
strptime,LocalDate.parse,DateTime.Parseo los propios conversores.DA/.TM/.DTde pydicom) truena con2400, el mes 13,NaNo***. El primer salto, permisivo, lo dejó pasar. El segundo, estricto, se cae.
Las cadenas numéricas controlan asignación de memoria y matemáticas críticas para la seguridad. Este es el filo. Los campos IS y DS se alimentan directo a int() y float(). Un IS como 9.99e99 (notación científica, que IS prohíbe) se convierte en un entero de 100 dígitos a través de un parser permisivo. Úsalo como NumberOfFrames para dimensionar un buffer y es un DoS por agotamiento de memoria. Un DS de NaN o inf (ambos fuera de la gramática de DS) en DoseGridScaling corrompe cada vóxel de dosis en un mapa de dosis de radioterapia. En DoseCalibrationFactor corrompe el SUV de PET. En WindowCenter o WindowWidth pinta una imagen negra o indefinida. Yo corrí estas rutas. Un factor de escala NaN convierte una rejilla de dosis calculada completa en NaN, y la pantalla de planeación de tratamiento muestra cero o basura. Esa es la diferencia entre una nota al pie de la especificación y un problema de seguridad del paciente.
Las cadenas estructuradas son vectores de inyección. PN (nombre de persona) tiene delimitadores reservados (^, =), topes (hasta 4 acentos circunflejos, hasta 2 signos de igual, 64 caracteres por grupo) y prohíbe la barra invertida y los caracteres de control. Cuando PN se re-serializa, hacia HL7/CDA bajo PS3.20 o hacia JSON/XML bajo PS3.18 (DICOMweb), los delimitadores sin filtrar o un CR/LF contrabandeado se convierten en inyección de segmentos HL7, inyección CDA/XML o inyección de logs (CWE-74). Un CS interpolado sin escapar en una llave de SQL o de índice es la misma historia. Un UR (un campo URI como RetrieveURL) que un consumidor desreferencia puede apuntarse a una dirección interna (SSRF) o a un esquema peligroso. El escaneo encontró las violaciones de formato. La frontera de re-serialización es donde se convierten en incidente.
Qué llevarse de esto
El corpus está sesgado a propósito hacia datos públicos de referencia, fixtures de toolkits de código abierto y conjuntos de investigación desidentificados. Así que 0.26 % es un piso, no una estimación del tráfico de un PACS en producción, y la mayoría de los archivos no traen un Manufacturer al que pueda ponerle nombre. Pero ese sesgo hace el hallazgo más fuerte, no más débil. Las violaciones viven en los datos curados y ampliamente usados con los que el ecosistema prueba, y las librerías de las que todos dependen las retransmiten sin revisar. Si los datos de referencia no son conformes y los lectores no validan, producción no va a estar más limpia.
Dos recomendaciones, una para cada lado de cualquier enlace DICOM.
Si produces DICOM: valida en la capa de valores contra la Tabla 6.2-1 antes de escribir, no solo contra un esquema de qué tags están presentes. Las fechas son ocho dígitos sin separadores. Las horas y fechas-hora tienen rangos numéricos estrictos. CS es solo mayúsculas y 16 bytes. IS es solo dígitos. DS es una cadena decimal sin NaN ni inf. Si desidentificas, sigue PS3.15: usa un valor ficticio conforme, un desplazamiento de fechas consistente o un elemento vacío para una fecha eliminada. Nunca escribas un marcador (***, 00000000 o texto en un campo numérico) que viole la propia gramática del campo. Y nunca dejes que un centinela NaN o inf de punto flotante llegue a un campo de cadena.
Si consumes DICOM: no confíes en el contrato de la VR solo porque una librería devolvió un valor sin lanzar error. Configura reading_validation_mode en su modo más estricto y atiende las advertencias en lugar de silenciarlas. Valida cualquier campo que estés a punto de pasar por int(), float(), strptime, desreferenciar o re-serializar. El valor que cruza tu frontera lo escribió alguien que quizá no revisó, y lo leyó una librería que definitivamente no lo hizo. Trata PN, CS, UR y AE como entrada no confiable en cada frontera de re-serialización y consulta, como cualquier otra cadena externa.
El estándar DICOM hizo su trabajo. La Tabla 6.2-1 es precisa, normativa y estable desde hace décadas. La brecha no está en la especificación. Está en los productores que no emiten conforme a ella y en los consumidores que no la hacen cumplir. Las dos mitades tienen arreglo, y las dos son más baratas de arreglar ahora que después de que un NaN llegue a una rejilla de dosis.
La metodología del escaneo, el conjunto completo de hallazgos y la herramienta dicom_vr_scanner.py están disponibles en el repositorio pwnlabmx/dicom-audit. Las cláusulas de gramática citadas aquí provienen de DICOM PS3.5 2026b, sección 6.2, Tabla 6.2-1.
Enlaces
- Escáner, metodología y conjunto completo de hallazgos: github.com/pwnlabmx/dicom-audit
- dicom_vr_scanner.py, el escáner de valores falsificados v2.0 usado en este escaneo
- VR_SCAN_v2.json, el conjunto completo de hallazgos, y HIGH_FINDINGS.json, el subconjunto de severidad HIGH
- SOURCES.md, cómo se armó el corpus, y VENDOR_AUDIT.md, su desglose por fabricante, modalidad e institución
- DICOM PS3.5, sección 6.2, Tabla 6.2-1, la gramática de Value Representation citada a lo largo del texto
- DICOM PS3.15, Anexo E, Attribute Confidentiality Profiles, la forma conforme de desidentificar
Foto de portada: Brian Smith, tecnólogo en tomografía computarizada del Naval Medical Center Portsmouth, revisa un estudio de aorta torácica en la consola de adquisición. Fotografía de la Marina de los Estados Unidos por Rebecca A. Perron, de dominio público como obra del gobierno federal de los Estados Unidos, vía Wikimedia Commons.