Comment hacker une image médicale au format DICOM ?

08/05/2018

Dans l'informatique, il y a des mythes et légendes, notamment dans le secteur de l'imagerie médicale où on peut entendre :

Il n'est pas possible de modifier une image DICOM !

Le DICOM, c'est-à-dire Digital Imaging and COmmunications in Medicine n'est pas uniquement un format informatique d'images médicales, il est surtout un standard d'interopérabilité et de communication des données d'imagerie médicale.

Pour donner un ordre d'idée, le standard DICOM représente environ 6000 pages découpées en 20 parties.

Malgré le fait que le standard DICOM propose des solutions de sécurité, il s'avère que dans la pratique et la plupart du temps, un fichier DICOM n'est pas protégé contre des falsifications. Même avec un support à écriture unique comme un DVD-ROM, il est possible de modifier l'image et de refaire le DVD à l'identique.

Une méthode comme celle décrite dans cet article pourrait être détournée par une personne ayant accès au réseau informatique d'un hôpital pour réinjecter un fichier DICOM modifié dans le serveur central appelé PACS (Picture Archiving and Communication System).

Heureusement ;-) nous verrons dans de futurs articles :

  • comment vérifier le niveau de sécurité d'un PACS ?
  • comment signer un fichier DICOM pour détecter s'il a été modifié ?
  • comment crypter (chiffrer pour les puristes) un fichier DICOM ?

La structure d'un fichier DICOM

Un fichier DICOM a grosso modo l'organisation d'un fichier XML mais au format binaire, plus adapté aux données numériques comme les pixels.

En revanche, le standard DICOM a de très nombreuses subtilités qui peuvent rapidement freiner l'envie de développer un analyseur de fichier DICOM, et même les SDK (Software Development Kit) professionnels ne couvrent pas la totalité du standard et vous invite à les développer vous même.

Nous avons vu dans un précédent article comment afficher le contenu d'un fichier DICOM. En utilisant cette ligne de commande sur un fichier DICOM cela affiche par exemple :

(0002,0000) UL 212                                      
(0002,0001) OB 01\00                                    
(0002,0002) UI =CTImageStorage                          
(0002,0003) UI [1.2.840.113619.2.30.1.1762373387.1450.921053510.991] 
(0002,0010) UI =LittleEndianImplicit                    
(0002,0012) UI [1.2.840.9999999.6.29.93.1.1]            
(0002,0013) SH [v1.0 Windows]                           
(0002,0016) AE [DIGITAL_JACKET]                         
(0008,0000) UL 416                                      
(0008,0005) CS [ISO_IR 100]                             
(0008,0008) CS [ORIGINAL\PRIMARY\AXIAL]                 
(0008,0016) UI =CTImageStorage                          
...
(0010,0010) PN [Anonymized]                             
...

Contrairement au HTML ou au XML où chaque champ est décrit par un nom de balise , en DICOM un champ est identifié par deux chiffres : son numéro de groupe et son numéro d'élément.

Dans l'exemple ci-dessus, le champ (0010,0010) représente le nom du patient comme indiqué dans la partie 6 du standard DICOM et contient Anonymized.

Afficher le contenu d'un champ DICOM en JavaScript

Historiquement, il existe plusieurs SDK libre et open source éprouvés pour le DICOM dans différents langages, comme le DCMTK en C++ réalisé par un institut de recherche allemand ou dcm4che développé en Java.

Mais nous allons utiliser un SDK plus récent dédié au Web et réalisé en JavaScript : cornerstonejs

Avec ce SDK, il suffit de quelques lignes de code pour afficher le contenu d'un champ d'un fichier DICOM :

const dicomParser = require('dicom-parser');
const fs = require('fs');

let arrayBuffer = fs.readFileSync('./dcm/CT-MONO2-16-ort.dcm').buffer;
let byteArray = new Uint8Array(arrayBuffer);

let dataSet = dicomParser.parseDicom(byteArray);

const patientName = dataSet.uint16('x00100010');

console.log("Nom du patient : " + patientName);

Récupérer les informations d'une image DICOM

Dans notre cas, nous nous intéressons au champ qui contient les pixels de l'image ainsi que les champs qui nous renseignent sur son format.

La gamme des formats d'images proposée par le DICOM est bien plus large que le format JPEG ou le format PNG.

Une image peut provenir :

  • d'un scanner qui est généralement en niveau de gris dont chaque pixel est codé sur 16 Bits
  • d'un échographe qui est une image couleur codé en RGB sur 8 Bits comme une image JPEG
  • ...

Ces informations sont renseignées dans différents champs que l'on peut récupérer :

const dicomParser = require('dicom-parser');
const fs = require('fs');

let arrayBuffer = fs.readFileSync('./dcm/doepierre.dcm').buffer;
let byteArray = new Uint8Array(arrayBuffer);

let dataSet = dicomParser.parseDicom(byteArray);

const bitsAllocated = dataSet.uint16('x00280100');
const rows = dataSet.uint16('x00280010');
const columns = dataSet.uint16('x00280011');

console.log("Hauteur : " + rows);
console.log("Largeur : "+ columns);
console.log("Bits par pixel : " + bitsAllocated);

Ce code affichera les dimensions de l'image (hauteur et largeur) ainsi que le nombre de bits utilisés par pixels :

512 512 16

Modifier les pixels DICOM

Les outils ou les codes sources généralement utilisés en traitement d'image sont inadaptés aux formats des pixels DICOM. Nous allons donc écrire par nous même le code source pour modifier les pixels.

Par exemple, pour remplacer une zone par un carré noir à l'emplacement que l'on souhaite :

const pixelDataElement = dataSet.elements.x7fe00010;

// position du coin supérieur gauche du rectangle
let startx = 128;	
let starty = 200;
// position du coin inférieur droit du rectangle (carré de 50 de large)
let endx = startx + 50;
let endy = starty + 50;

for (let y = starty; y < endy; y++) {
	for (let x = startx; x < endx; x++) {
         // calcule la position du pixel (chaque pixel est encodé sur 2 octets = 16 bits)
		let offset = pixelDataElement.dataOffset + (columns * y + x) * 2; 
		byteArray[offset] = 0;			// 1er octet du pixel
		byteArray[offset + 1] = 0;		// 2ème octet du pixel
	}
}

NB : le code source ci-dessus n'est pas optimisé afin qu'il soit plus lisible.

Injecter une image PNG dans une image DICOM

Nous allons maintenant injecter un lapin d'œuf de Pâques comme ci-dessous dans une image DICOM.

lapinoeufdepaque

Chaque pixel de l'image PNG est encodé sur 8 Bits (1 octet) alors que chaque pixel de notre image DICOM est encodé sur 16 Bits (2 octets), de plus le fichier DICOM précise la manière de transformer les pixels pour qu'ils soient visualisables sur un écran informatique et par l'œil humain (fenêtrage, transformation linéaire, ...).

Pour simplifier l'injection du lapin, une transformation linéaire classique des niveaux de gris est appliquée afin que le lapin œuf de Pâques soit bien visible sur l'écran d'une station de diagnostique d'un radiologue :

// calcule le niveau de gris d'un pixel (moyenne des composantes Rouge Vert Bleu)
let mono = (png.data[idx] +  png.data[idx + 1] +  png.data[idx + 2]) / 3; 

// transforme à une échelle convenable de niveau de gris
mono = mono * width / 255 + rescale;  
byteArray[offset ] = mono & 0xff;	// les 8 premiers bits (1 octet)
byteArray[offset + 1] = mono >> 8;	// les 8 autres bits (1 octet)

Un exemple de code complet correspondrait à :

const dicomParser = require('dicom-parser');
const fs = require('fs');
const PNG = require('pngjs').PNG;

let data = fs.readFileSync('./svg/easteregg.png');
let png = PNG.sync.read(data);

let arrayBuffer = fs.readFileSync('./dcm/doepierre.dcm').buffer;
let byteArray = new Uint8Array(arrayBuffer);

let dataSet = dicomParser.parseDicom(byteArray);

const bitsAllocated = dataSet.uint16('x00280100');
const rows = dataSet.uint16('x00280010');
const columns = dataSet.uint16('x00280011');

console.log(rows + " " + columns + " " + bitsAllocated);

const pixelDataElement = dataSet.elements.x7fe00010;

let startx = 128;
let starty = 200;
let width = 1500;
let rescale = -1024;
let endx = startx + png.width;
let endy = starty + png.height;

let posy = starty;
for (let y = 0; y < png.height; y++) {
	let posx = startx;
	for (let x = 0; x < png.width; x++) {
		let idx = (png.width * y + x) << 2;
		let mono = (png.data[idx] +  png.data[idx + 1] +  png.data[idx + 2]) / 3;
		let opacity = png.data[idx+3];

		if (opacity > 0) {
			mono = mono * width / 255 + rescale; 
			let offset = pixelDataElement.dataOffset + (columns * posy + posx) * 2; 
			byteArray[offset ] = mono & 0xff;	
			byteArray[offset + 1] = mono >> 8;		
		}
		posx++;
	}
	posy++;
}

fs.writeFileSync('./dcm/result.dcm', byteArray);

Démonstration

L'image médicale DICOM originale (ici convertie en JPEG pour l'afficher dans un navigateur) :

image dicom originale

L'image médicale DICOM modifiée (ici convertie en JPEG pour l'afficher dans un navigateur) :

image dicom modifiee

A moins de comparer chaque pixel de l'image originale avec l'image modifiée, il est impossible de savoir si l'image DICOM a été modifiée.

Si vous envoyez cette image DICOM modifiée dans un PACS mal conçu ou mal configuré, il écrasera sans sourciller l'image originale et elle sera intégrée à l'examen du patient en question.

Auteurs: Emmanuel ROECKER, Rym BOUCHAGOUR
Formations, Conseil & Développement e-Santé

Formations qui pourraient vous intéresser

Restez informé avec notre newsletter