Steganografia con C#

Stamani mi sono messo un po a scrivere codice con C#, giusto per fare qualche prova, ed ho tirato fuori un semplice software, di cui mi limiterò a postare i metodi principali, in grado di scrivere e leggere i dati codificati all'interno delle immagini, ovvero in grado di codificare dati all'interno delle immagini sfruttando la steganografia.

L'unico requisito fondamentale per l'implementazione che ho realizzato è che l'immagine da utilizzare come base deve essere un'immagine a 32 bit: ho fatto questa scelta perché 32 bit significano 4 byte e quindi è molto più semplice effettuare le operazioni matematiche necessarie. Inoltre, un altro requisito, è che l'immagine di uscita sia sempre a 32 bit e non sia di tipo lossless altrimenti, a salvataggio effettuato, il file codificato si corromperebbe! Ovviamente si potrebbero prevedere algoritmi di correzione dei dati ma la cosa diverrebbe troppo complessa da gestire.

Però, prima di iniziare a spiegare come funziona il software consiglio, per chi non avesse idea precisa di cosa sono i pixel, cosa sono i canali di un pixel, cos'è il canale alpha o, in generale, non ha nozioni approfondite a riguardo, di andare a dare una sguardo alle seguenti pagine di wikipedia:

Dunque, il concetto basilare dietro il software è abbastanza semplice:

  • viene caricata l'immagine sorgente;
  • viene aperto il file da codificare;
  • si legge la dimensione del file appena aperta;
  • il file viene codificato nell'immagine sorgente;
  • si salva l'immagine creata.

La scrittura dei dati codificati all'interno dell'immagine sorgente, per l'implementazione che ho effettuato, comporta la modifica, se necessario, del bit meno significativo di ogni canale del pixel. Esempio: un pixel nero, con codice esadecimale del colore #00000000, avrà i 4 canali che compongono il pixel (Alpha Red Green Blue) tutti a zero, dato che ogni canale è un byte, per il nostro presupposto, ci saranno 4 byte con valore 0. Se si prende il bit meno significativo di ogni byte saranno disponibili 4 bit per ogni pixel!
In pratica se in ogni pixel si può codificare mezzo byte, ovvero 4 bit, in due pixel sarà possibile codificare un byte intero.

Fatto questo presupposto, facciamo un esempio pratico: se abbiamo 2 pixel neri la sequenza di bit sarà cosi composta:

00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 

Il dato da codificare, invece, sarà un byte con valore 255, quindi composto dai seguenti bit:

11111111

Se si effettua l'operazione sopra descritta, ovvero si salva un bit del dato da codificare, da sinistra verso destra, nei 2 pixel presi come base, quindi 1 bit per ogni canale del colore, si otterrà una sequenza di questo tipo:

00000001 00000001 00000001 00000001 00000001 00000001 00000001 00000001

A questo punto, se il colore nero che avevo preso in precedenza lo confronto con questo sarà possibile notare una qualche differenza? La risposta, semplicemente, è no ad occhio nudo perché la variazione, se avviene, è praticamente impercettibile!

Nell'esempio di sopra, in realtà, è variata la luminosità dei due pixel presi in considerazione: tutti i canali sono stati incrementati in maniera proporzionale, ovvero di una unità, ed è anche aumentata la trasparenza, dato che è stato incrementato il canale alpha.

Scendendo nel dettaglio, l'operazione di scrittura può essere scomposta nelle seguenti operazioni:

  • Controlla se il numero di pixel presenti nell'immagine sono sufficienti a contenere il file da codificare;
  • Nei primi 16 pixel, quindi i primi 8 byte, inserisce la lunghezza del file
  • Nello spazio rimanente scrive il file

Per leggerlo, fa quasi le stesse operazioni:

  • Dai primi 16 pixel, quindi i primi 8 byte, legge la dimensione del file originale
  • Legge il file componendo un byte con i dati presenti in due pixel

In realtà, usare 8 byte per conservare la dimensione del file è esagerato perché codificare cosi tanti dati dentro un'immagine richiederebbe una quantità di risorse enormi: il .NET restituisce le dimensioni dei file come tipo long, alla fin fine, potrebbe essere più grande di 2gb e tecnicamente la cosa andrebbe gestita correttamente. Ovviamente c'è da considerare che se invece di usare un'immagine si usa un video la situazione cambia drasticamente anche se entrano in gioco tutta una serie di fattori come il tipo di compressione, i frame per secondo, ogni quanto è presente un key frame e via dicendo.

Finita la teoria passiamo alla pratica!

  1. private bool WriteSteganography(string SourceImagePath, string OutputImagePath, string SourceFilePath)
  2. {
  3. // Load image
  4. Bitmap bitmap = new Bitmap(SourceImagePath, false);
  5.  
  6. // Open file
  7. FileStream fileStream = File.OpenRead(SourceFilePath);
  8.  
  9. // Do some calcs
  10. long fileLength = fileStream.Length;
  11. long avaiablePixels = bitmap.Size.Width * bitmap.Size.Height;
  12. long requestedPixels = fileLength * 2 + 16; // If one byte is 8 bits and one pixel
  13. // can save up to 4 bits (four channel images)
  14. // we need 2 pixels for one data's byte
  15. // adding just 16 pixels for a long data
  16. // type to write file length
  17.  
  18. // Check if file can be contained into this image
  19. if (requestedPixels > avaiablePixels)
  20. {
  21. // Operation failed
  22. return false;
  23. }

Il metodo WriteSteganography si occupa di effettuare la creazione dell'immagine finale partendo da un'immagine sorgente e dal file da codificare. Viene verificato se il file può essere scritto nell'immagine oppure se è troppo grande ed i pixel dell'immagine non bastano. Avendo dato come presupposto che l'immagine sorgente avrà sempre 4 canali, e quindi 4 bit per pixel, per calcolare i pixel necessari possiamo semplicemente moltiplicare per 2 la lunghezza del file (2 pixel = 1 byte). Ad i pixel necessari per codificare il file vanno sommati anche quelli necessari a contenere la dimensione in byte del file stesso, ovvero vanno aggiunti altri 16 pixel.

  1. // Acquire bitmap data
  2. BitmapData bitmapData = bitmap.LockBits(new Rectangle(new Point(0, 0), bitmap.Size),
  3. ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);

Per poter operare sull'immagine con una certa velocità è necessario modificare direttamente i dati in memoria per cui usando direttamente il metodo LockBits della classe Bitmap si può acquisire direttamente il puntatore all'immagine in memoria.

Il metodo LockBits prende vari parametri, rispettivamente, l'area interessata, che nel nostro caso e l'intera immagine, la modalità di locking, nel nostro caso solo la scrittura, ed infine il formato che devono avere i dati in memoria, ovvero 32 bit per pixel in formato ARGB.

  1. // Setup some vars
  2. int bitmapWidthBytes = bitmapData.Width * 4;
  3. int bitmapAlignmentBytes = bitmapData.Stride - bitmapWidthBytes;
  4.  
  5. // Setup file buffer and readed data length
  6. byte[] buffer = new byte[8192];
  7. int readLength = 0;
  8.  
  9. // Setup bytes counter;
  10. long bytesCounter = 0;

Prima di iniziare le operazioni è necessario effettuare alcuni calcoli: le immagini, in memoria, sono allineate a 32 bit, significa che mentre una riga in pixel ha una data lunghezza in byte ne ha un'altra!
Dovendo lavorare esclusivamente sui dati dell'immagine è necessario sapere esattamente quanti byte occupa una riga dell'immagine, il valore si ottiene semplicemente moltiplicando la larghezza dell'immagine per 4, ovvero il numero di byte che compongono un singolo pixel. Effettuato il conteggio è necessario sapere anche quanti sono i byte usati per l'allineamento, lo "stride", ovvero la proprietà Stride di bitmapData, contiene la lunghezza in byte di una riga allineata a 4 byte di conseguenza se detraiamo i byte reali necessari per una riga dell'immagine otteniamo quanti byte in più ci sono per ogni riga.
Per fare un altro esempio, se l'immagine fosse stata di tipo RGB a 24 bit e fosse stata larga 513 pixel la lunghezza in byte di una riga sarebbe stata di 1539 mentre la proprietà Stride avrebbe riportato un valore di 1540 perché per allineare la riga a 32 bit serviva aggiungere un altro byte.

Questo conteggio, che sembra insensato, è fondamentale: l'immagine, in memoria, è una lunga sequenza di byte: per l'esempio precedente, dopo aver codificato i dati che rientrano nella prima riga è necessario spostarsi nella seconda ma se non si sarà di quanto si ci deve spostare avanti l'operazione non è fattibile.

Infine, il contatore bytesCounter sarà utilizzato per sapere se è stato raggiunto l'estremo destro dell'immagine durante la codifica o meno.

  1. // Do the job
  2. unsafe
  3. {
  4. // Get bitmap pointer
  5. byte* bitmapPointer = (byte*)bitmapData.Scan0.ToPointer();

Con C# non si può lavorare, normalmente, con i puntatori perché la runtime non è in grado di effettuare i dovuti controlli: per forzare la runtime si usa la parola chiave unsafe, ovvero gli si indica che si sta lavorando con codice potenzialmente non sicuro ma che non può essere controllato.

Il metodo ToPointer() della classe IntPtr restituisce un void* che deve essere castato a byte*: non sono permesse operazioni su void.

  1. // Write file size
  2. for (byte bitIndex = 64; bitIndex >= 1; bitIndex--)
  3. {
  4. // Get the bit
  5. byte bit = (byte)((fileLength >> (bitIndex - 1)) & 0x01);
  6.  
  7. // Update the value
  8. *bitmapPointer = (byte)(bit == 1 ? *bitmapPointer | 0x01 : *bitmapPointer & 0xFE);
  9.  
  10. // If pixel counter is a multilple of width just add stride-width to
  11. // bitmap pointer to move to a new line
  12. bitmapPointer += 1 + (++bytesCounter % bitmapWidthBytes == 0
  13. ? bitmapAlignmentBytes : 0);
  14. }

Questa è la seconda fase del software, viene scritta la dimensione del file da codificare nell'immagine stessa.

Utilizzando un ciclo per acquisire i singoli bit del valore è possibile effettuare l'operazione con una certa semplicità.

Come prima cosa la variabile bit viene valorizzata con il bit che va scritto: si sposta il bit che va letto verso destra, facendolo arrivare verso la posizione meno significativa, e si applica una maschera AND 0x01 per leggere solo quel dato bit cosi da poter ottenere 0 o 1.

Una volta letto il bit, questo va codificato nell'immagine: se il bit che va impostato è 1 allora va si deve applicare una maschera OR 0x01, in modo da impostare il bit meno significativo a 1, altrimenti si deve applicare una maschera AND 0xFE, cosi da prendere tutti i bit tranne quello meno significativo lasciandolo con valore zero.

Per chi dovesse essere messo male con la matematica booleana consiglio di dare un occhio alla pagina Algebra di Boole su wikipedia.

L'ultima riga del codice non fa altro che incrementare il puntatore di N posizioni in avanti ove N corrisponde a 1 se la fine della riga non è stata raggiunta oppure, se è stata raggiunta la fine della riga, corrisponde alla lunghezza dei byte utilizzati per l'allineamento (ricordate il calcolo fatto prima?).

  1. // Start loop
  2. while (fileStream.Length > fileStream.Position)
  3. {
  4. // Pre buffer some data from filestream
  5. readLength = fileStream.Read(buffer, 0, buffer.Length);
  6.  
  7. // Loop buffered data
  8. for (int dataIndex = 0; dataIndex < readLength; dataIndex++)
  9. {
  10. // Loop bits in byte
  11. for (byte bitIndex = 8; bitIndex >= 1; bitIndex--)
  12. {
  13. // Calculate a mask to apply
  14. byte bit = (byte)((buffer[dataIndex] >> (bitIndex - 1)) & 0x01);
  15.  
  16. // Make new value
  17. *bitmapPointer = (byte)(bit == 1 ? *bitmapPointer | 0x01 : *bitmapPointer & 0xFE);
  18.  
  19. // If pixel counter is a multilple of width just add stride-width to
  20. // bitmap pointer to move to a new line
  21. bitmapPointer += 1 + (++bytesCounter % bitmapWidthBytes == 0
  22. ? bitmapAlignmentBytes : 0);
  23. }
  24. }
  25. }
  26. }

Nel ciclo for più interno, quello più importante, vengono effettuate esattamente le stesse operazioni, solo che in questo caso si va ad operare su un singolo byte da codificare e non su un valore di tipo long.

Onde evitare di leggere byte per byte ho preferito implementare un sistema di buffering cosi da ridurre le operazioni di IO (lettura e scrittura): quest'ottimizzazione si fa sentire poco su un piccolo file, ma codificare un file più grandicello senza il buffering richiederebbe notevolmente più tempo!

  1. // Unlock bitmap memory
  2. bitmap.UnlockBits(bitmapData);
  3.  
  4. // Get full path
  5. string fullPath = Path.GetFullPath(OutputImagePath);
  6.  
  7. // Save new image
  8. bitmap.Save(String.Format(@"{0}\{1}.png", Path.GetDirectoryName(fullPath),
  9. Path.GetFileNameWithoutExtension(fullPath)));
  10.  
  11. // Free memory
  12. fileStream.Close();
  13. bitmap.Dispose();
  14.  
  15. // Operation done
  16. return true;
  17. }

Alla fine, viene effettuato l'unlock della memoria, cosi che il sistema operativo possa rilasciare le risorse utilizzate. Effettuato l'unlocking, viene salvata l'immagine nel percorso di destinazione in formato png e viene liberata la memoria occupata dagli oggetti.

Giusto per completezza, la riga

  1. *bitmapPointer = (byte)(bit == 1 ? *bitmapPointer | 0x01 : *bitmapPointer & 0xFE);

poteva anche essere scritta come

  1. *bitmapPointer = (byte)((*bitmapPointer & 0xFE) + bit);

Ho preferito scriverla nella forma estesa per permettere di capire meglio come viene effettuata l'operazione.

La lettura dei dati codificati, invece, è molto più semplice e lavora sugli stessi principi: non scompone i dati in bit ma li ricompone dai bit.

  1. private void ReadSteganography(string SourceImagePath, string OutputFilePath)
  2. {
  3. // Load image
  4. Bitmap bitmap = new Bitmap(SourceImagePath, false);
  5.  
  6. // Open output file
  7. FileStream fileStream = File.OpenWrite(OutputFilePath);
  8.  
  9. // Acquire bitmap data
  10. BitmapData bitmapData = bitmap.LockBits(new Rectangle(new Point(0, 0), bitmap.Size),
  11. ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb);
  12.  
  13. // Setup some vars
  14. int bitmapWidthBytes = bitmapData.Width * 4;
  15. int bitmapAlignmentBytes = bitmapData.Stride - bitmapWidthBytes;
  16. long fileSize = 0;
  17.  
  18. // Setup bytes counter;
  19. long bytesCounter = 0;
  20.  
  21. // Do the job
  22. unsafe
  23. {
  24. // Get bitmap pointer
  25. byte* bitmapPointer = (byte*)bitmapData.Scan0.ToPointer();
  26.  
  27. // Try to read the file size
  28. for (byte bitIndex = 8 * sizeof(long); bitIndex >= 1; bitIndex--)
  29. {
  30. // Acquisisce la dimensione del file
  31. fileSize = (fileSize << 1) + (*bitmapPointer & 0x01);
  32.  
  33. // If pixel counter is a multilple of width just add stride-width to
  34. // bitmap pointer to move to a new line
  35. bitmapPointer += 1 + (++bytesCounter % bitmapWidthBytes == 0
  36. ? bitmapAlignmentBytes : 0);
  37. }
  38.  
  39. // Loop data
  40. for (long dataIndex = 0; dataIndex < fileSize; dataIndex++)
  41. {
  42. // Init byte to write
  43. byte byteToWrite = 0;
  44.  
  45. // Loop bit
  46. for (byte bitIndex = 8; bitIndex >= 1; bitIndex--)
  47. {
  48. // Acquisisce la dimensione del file
  49. byteToWrite = (byte)((byteToWrite << 1) + (*bitmapPointer & 0x01));
  50.  
  51. // If pixel counter is a multilple of width just add stride-width to
  52. // bitmap pointer to move to a new line
  53. bitmapPointer += 1 + (++bytesCounter % bitmapWidthBytes == 0
  54. ? bitmapAlignmentBytes : 0);
  55. }
  56.  
  57. // Write byte
  58. fileStream.WriteByte(byteToWrite);
  59. }
  60. }
  61.  
  62. // Unlock and free bitmap
  63. bitmap.UnlockBits(bitmapData);
  64. bitmap.Dispose();
  65.  
  66. // Close the file and free memory
  67. fileStream.Close();
  68. }

Il codice è praticamente uguale a quello implementato per la codifica dei dati, l'unica reale differenza è la seguente riga:

  1. byteToWrite = (byte)((byteToWrite << 1) + (*bitmapPointer & 0x01));

Il codice deve ricomporre dai bit meno significativi dei pixel il byte originale di conseguenza viene effettuata una semplice operazione: uno shift verso sinistra di 1 a cui va sommata la maschera booleana 0x01 del byte corrente del puntatore.

In parole molto semplici, se si prendono due pixel composti dai byte

01010101
00000100
11101101
01000110
01010000
01010101
11101001
00000100

Lo stato iniziale sarà
byteToWrite = 00 00 00 00

dopo la prima lettura sarà
byteToWrite = 00 00 00 01

dopo la seconda
byteToWrite = 00 00 00 10

dopo la terza
byteToWrite = 00 00 01 01

e via dicendo fino all'ottava dove sarà
byteToWrite = 10 10 01 10

In realtà possono essere applicate molte più ottimizzazioni: si potrebbero trattare i dati come int invece che come byte cosi da poter applicare una maschera che estragga i valori dei bit di ogni byte (ndr. L'int è composto da 4 byte), escludendo ovviamente quelli meno significativi, e a questi sommare una un valore int costruito impostando su 1 o 0 i bit meno significativi di ogni byte. O ancora si potrebbero sfruttare le operazioni vettoriali, le SIMD, utilizzando mono o si potrebbero sfruttare le CUDA e via dicendo.