C) Steganographie – Daten in Bildern verstecken

Sie alle kennen vermutlich den Begriff Kryptographie. Bei der Kryptographie wird versucht, einen Text (oder binäre Daten) durch Verschlüsselung unleserlich zu machen. Dies könnte bspw. auf simpelste Art durch die Cäsar-Chiffre realisiert werden. Einen anderen Ansatz verfolgt die Steganographie. Bei der Steganographie werden die Daten nicht durch Verschlüsselung vor unbefugten Augen geschützt, sondern die geheimen Botschaften werden so getarnt bzw. versteckt, dass Dritte gar nicht wissen, dass es sich hierbei um Daten handelt. Dieses Kapitel beschäftigt sich mit dem Verstecken von Daten in Bildern.

Allgemeine Funktionsweise

Viele Leser, die bereits ein wenig Know-How besitzen, werden vermuten, dass die geheimen Daten einfach als Kommentar oder ähnliches im Bild versteckt werden würden. Natürlich bietet eine solche Methode keinen ausreichenden Schutz. Zum Einen fällt auf, wenn ein Bild plötzlich viel größer ist als vorher, und zum Anderen muss ein entsprechender Hacker nur die Bilddatei mit einem Texteditor öffnen – schon sieht er die Botschaft. Deshalb verfolge ich in diesem Kapitel einen anderen Ansatz!

Ein Bild hat Farbkanäle, in den meisten Fällen Rot, Grün und Blau (RGB). Jeder einzelne Pixel bekommt einen Rot-, Grün- und einen Blauwert zwischen 0 und 255 zugewiesen, was 16.777.216 verschiedene Farben pro Pixel ermöglicht. Natürlich besteht ein Bild aus sehr vielen Pixeln (Bildpunkte) – heutzutage sogar meistens aus Millionen und Milliarden unterschiedlichen Punkten. Falls Sie nun den Wert jedes Farbkanals eines Pixels um den Faktor eins erhöhen oder verringern, ändert sich die gesamte Farbe dieses einen Bildpunkts um 1/256 – also um 0,39%. Diese Änderung ist für das menschliche Auge nicht nachvollziehbar und folglich unsichtbar. Sie sehen, worauf ich hinaus möchte? Richtig! Die Nachricht wird im Bild versteckt, indem die Farbkanäle (das jeweils letzte Bit) manipuliert werden.

Detaillierte Funktionsweise

Sehen wir uns dieses Verfahren an einem Beispiel näher an. Angenommen, Sie möchten ein großes ‚A‘ in einem Bild verstecken. Nicht viel, aber zum Besseren Verständnis bestens geeignet. Sie wissen bereits aus dem Kapitel 02.11 ASCII, Unicode und Character, dass das ‚A‘ einer Zahl, dem ASCII-Wert, zugeordnet werden kann. Dies ist die 65. Wir benötigen diese Zahl jedoch in binärer Schreibweise: 01000001.

Falls Sie nicht (mehr) genau wissen, was es mit der binären Schreibweise auf sich hat, lesen Sie bitte kurz auf Wikipedia nach.

Auch wissen Sie, dass jedem Farbkanal ein Wert zwischen 0 und 255 zugewiesen werden kann, was genau einem Byte bzw. acht Bit entspricht. Der erste Pixel eines Bildes könnte z. B. einen Rotwert von 189 (10111101), einen Grünwert von 0 (00000000) und einen Blauwert von 255 (11111111) aufweisen. Der zweite Pixel hat bspw. einen Rotwert von 180 (10110100), einen Grünwert von 32 (00100000) und einen Blauwert von 241 (11110001). Der dritte Bildpunkt hat einen Rotwert von 173 (10101101), einen Grünwert von 34 (00100010) und einen Blauwert von 245 (11110101). Usw. usf..

Wenn wir dieses ‚A‘ (oder jedes andere Zeichen) nun in einem Bild verstecken möchten, überschreiben wir das jeweils letzte Bit in jedem Farbkanal eines jeden Pixels mit dem nächsten Bit im zu versteckenden Zeichen.

Konkrete, manuelle Steganographie

Das 1. Bit unseres ‚A‘ (01000001) ist eine 0. Diese 0 schreiben wir nun an die letzte Stelle des roten Farbkanals des ersten Pixel unseres Bildes (10111101):

Aus 10111101 (189) wird 10111100 (188)

Das 2. Bit unseres ‚A‘ ist eine 1. Diese 1 schreiben wir an die letzte Stelle des grünen Farbkanals des ersten Pixel unseres Bildes (00000000):

Aus 00000000 (0) wird 00000001 (1)

Das 3. Bit unseres Zeichens ist wieder eine 0. Diese schreiben wir abermals an die letzte Stelle des nächsten Farbkanals (blau) des ersten Pixel unseres Bildes (11111111):

Aus 11111111 (255) wird 11111110 (254)

Das 4. Bit von ‚A‘ ist abermals eine 0. Nachdem der erste Pixel keinen Farbkanal mehr hat, gehen wir zum zweiten Pixel unseres Bildes zum ersten (roten) Farbkanal (10110100) und manipulieren diesen. Da dort bereits die letzte Stelle eine 0 ist, muss der Farbkanal nicht weiter bearbeitet werden.

Aus 10110100 (180) wird 10110100 (180)

Dieses Verfahren wenden Sie auch für die letzten vier Bit des Zeichen ‚A‘ an. Ihre binären Farbkanäle sehen anschließend so aus:

Aus 00100000 (32) wird 00100000 (32)
Aus 11110001 (241) wird 11110000 (240)
Aus 10101101 (173) wird 10101100 (172)
Aus 00100010 (34) wird 00100011 (35)

Der letzte Farbkanal des dritten Pixels wird nicht mehr berücksichtigt, da bereits alle Bit unseres Zeichens versteckt wurden. Betrachten Sie nun noch einmal die neuen, manipulierten Farbkanäle nacheinander:

1. Pixel
10111100
00000001
11111110
2. Pixel
10110100
00100000
11110000
3. Pixel
10101100
00100011

Wenn Sie sich jetzt die markierten, letzten Bits eines jeden Farbkanals untereinander ansehen, erkennen Sie die Kombination 01000001, was unserem ‚A‘ entspricht. Das ‚A‘ wurde also erfolgreich im Bild versteckt und wieder ausgelesen. Dabei hat sich die Farbe des 1. Pixels um 0,39% (3/768), die Farbe des 2. Pixels um 0,13% (1/768) und die Farbe des 3. Pixels um 0,26% (2/768) geändert. Insgesamt hat sich die Farbgebung der ersten drei Pixel um 0,26% (6/2304 bzw. 2/768) verändert. Dies ist für das menschliche Auge nicht erkennbar und dennoch konnte eine (wenn auch noch sehr kurze) Nachricht in diesem scheinbar unverändertem Bild versteckt werden.

Ein Pixel wird also immer um maximal 0,39%, minimal um 0% (alle letzten Bit der Farbkanäle stimmen mit dem Soll-Bit überein), und im Durchschnitt um 0,2% verändert. Die Manipulation ist – wie bereits erwähnt – für das menschliche Auge nicht sichtbar.

Ich hoffe, Ihnen ist das Prinzip dieser Vorgehensweise nun klar!?

Farbechtheit

Bevor wir diesen Algorithmus nun in Java implementieren, muss Ihnen klar sein, dass die Bilder, die manipuliert werden sollen, farbecht sein müssen! Das Bild muss also verlustfrei gespeichert werden. Hierzu eignen sich vor allem die Formate png (komprimiert, aber verlustfrei) und bmp (unkomprimiert).

Implementierung in Java

Zuerst benötigen Sie eine kleine Helfer-Klasse in Form einer Enumeration. In dieser Enumeration werden die Farbkanäle gespeichert. Dies ist notwendig, da Sie bei der Manipulation Ihres Bildes mit einer Zahl arbeiten werden, die den kompletten RGB-Wert des aktuellen Pixels repräsentiert. Jedes Element kennt den nächsten Farbkanal (getNext), eine Zahl (shift), um wie viel Stellen ein RGB-Wert verschoben werden muss, um die Farbe des Farbcodes zu erhalten (getShift) und eine Maske (rgbManipulator), die alle Farb-Bit des aktuellen Farbkanals eines bestehenden RGB-Werts via and-Verknüpfung auf 0 setzt (getRGBManipulator).

package de.jbb.steganographie;

public enum Color {

  RED(16, ( 255 << 24 ) | ( 255 << 8 ) | ( 255 << 0 )), 
  GREEN(8, ( 255 << 24 ) | ( 255 << 16 ) | ( 255 << 0 )), 
  BLUE(0, ( 255 << 24 ) | ( 255 << 16 ) | ( 255 << 8 ));

  private int shift = 0;
  private int rgbManipulator;

  private Color(int shift, int rgbManipulator) {
    this.shift = shift;
    this.rgbManipulator = rgbManipulator;
  }

  public Color getNext() {

    if (this == RED) {
      return GREEN;
    }
    else if (this == GREEN) {
      return BLUE;
    }
    else {
      return RED;
    }
  }

  public int getShift() {
    return this.shift;
  }

  public int getRGBManipulator() {
    return this.rgbManipulator;
  }
}

Kümmern wir uns jetzt um die Methode, die einen Text in einem Bild speichert.

public BufferedImage hideMessage(BufferedImage img, String message) {}

Der übermittelten Nachricht hängen wir den ASCII-Wert 0 an, damit wir später beim Auslesen die Länge der Nachricht ermitteln können. Natürlich funktioniert auch jedes andere Zeichen bzw. Zeichenkombination. Es muss nur sichergestellt sein, dass dieses End-Zeichen nirgends im zu versteckenden Text vorkommt.

message += (char)0;

Da wir eine Zeichenkette, wie wir sie momentan vorliegen haben, so nicht weiter verarbeiten können, müssen wir diese in ein byte-Array umwandeln.

byte[] b = message.getBytes();

Bevor es jetzt an den eigentlichen Algorithmus geht, wird noch der erste Farbkanal in unserer Helfer-Klasse ausgewählt.

Color channel = Color.RED;

Da wir jedes einzelne Bit in unserem Bild unterbringen müssen, benötigen wir zwei verschachtelte for-Schleifen, die alle Bytes und alle Bits durchlaufen, wobei x und y den aktuellen Koordinaten im Bild entsprechen:

for (int i = 0, x = 0, y = 0; i < b.length; i++) {
  for (int j = 7; j > -1; j--) {
  }
}

In der Schleife sind nun folgende Operationen nötig:

1.) Auslesen des aktuellen Bits des aktuellen Zeichens, das durch die beiden Schleifen spezifiziert wird:

int bit = ((b[i]  & 0xFF) >> j) & 1;

Dabei wird das aktuelle Byte von signed (mit Vorzeichen zwischen -128 und +127) zu unsigned (ohne Vorzeichen von 0 bis 255) umgewandelt (b[i] & 0xFF), um j-Stellen (entsprechend der inneren for-Schleife) verschoben (>> j), und mit der 1 and-verknüpft (& 1). Sollte die Bit-Manipulation nicht mehr bei Ihnen im Gedächtnis liegen, frischen Sie Ihre Kenntnisse bitte kurz auf.

2.) Auslesen des RGB-Werts des Pixels und extrahieren des Farbwerts des aktuellen Kanals:

int rgb = img.getRGB(x, y);
int color = (rgb >> channel.getShift()) & 0xFF;

3.) Überprüfen, ob das letzte Bit verändert werden muss, falls ja, den RGB-Wert entsprechend manipulieren:

if ((color & 1) != bit) {
  rgb &= channel.getRGBManipulator();
  switch (bit) {
    case 1:
      color = color + 1;
      break;
    default:
      color = color - 1;
  }
  rgb |= color << channel.getShift();
  img.setRGB(x, y, rgb);
}

Falls das letzte Bit nicht mit dem aktuell zu setzenden Bit übereinstimmt, wird zuerst der aktuelle Farbkanal im RGB-Wert auf 0 gesetzt. Anschließend muss die aktuelle Farbe (je nach Bit) um eins inkrementiert (letztes Bit wird auf 1 gesetzt), oder dekrementiert (letztes Bit wird auf 0 gesetzt) werden. Jetzt kann der manipulierte Farbkanal in den RGB-Wert, und der RGB-Wert in das Bild, zurückgeschrieben werden.

Die eigentliche Logik ist nun implementiert. Am Ende dieser inneren Schleife muss lediglich noch alles für den nächsten Schleifendurchlauf vorbereitet werden. Das bedeutet, dass der nächste Farbkanal und Pixel im Bild ausgewählt wird, bzw. null zurückgegeben, falls noch Daten versteckt werden müssen, das Bild aber keine Pixel mehr hat. In letzterem Fall haben Sie natürlich die Möglichkeit, Ihre Daten auch über mehrere Bilder verteilt zu verstecken. Mein Code soll für Sie jedoch lediglich als Leitfaden dienen.

channel = channel.getNext();
if (channel.equals(Color.RED)) {
  x++;
  if (x >= img.getWidth()) {
    x = 0;
    y++;
    if (y >= img.getHeight()) {
      return null;
    }
  }
}

Auf der nächsten Seite finden Sie eine Zusammenfassung, und wie Sie die Werte wieder auslesen können.

12 Replies to “C) Steganographie – Daten in Bildern verstecken”

  1. marcel

    Wieso gibt uns die zugefügte 0 das Ende der Nachricht an?
    Theoretisch hat doch die RGB Farbe(0,0,0) also Schwarz, auch die Binärdarstellung 0000000 also als Byte 0.

  2. Stefan Kiesel

    Hallo Marcel,

    drei Punkte:

    1.) In Java hat ein Farbkanal eines RGB-Wertes typischerweise einen Wert zwischen 0 und 255, also einen von 256 verschiedene Werte insgesamt. Da 256 = 2^8 ist, werden 8 Bit zur Darstellung eines einzelnen Farbkanals benötigt. Bei der RGB-Farbe (0,0,0) benötigt also jeder dieser drei Farbkanäle 8 Bit (da wir hier meistens von einem Integer mit 32 Bit = 4*8 Bit ausgehen gibt es idR noch einen zusätzlichen Alpha-Kanal). Es würde sich also um die Binärdarstellung (bei Annahme eines Alpha-Kanals: 00000000)000000000000000000000000 handeln.

    2.) Ein Zeichen wird nicht in einer einzelnen Farbe (die ja nur 3, bzw. max. 4 Farbkanäle hat) versteckt, sondern in mehreren Farben durch Veränderung des jeweils letzten Bits der Farbkanäle der Farben.

    3.) Die Zeichen werden sequenziell in das Bild geschrieben. Wir gehen davon aus, dass (char)0 in keiner gewöhnlichen Zeichenkette vorkommen wird (was durchaus realistisch ist). Deshalb gibt es keine 00000000-Kette im Bild (alle anderen Zeichen außer (char)0 haben ja eine andere Binärrepräsentation) bevor nicht das Ende der Zeichen durch unser angehängtes (char)0 erreicht ist/signalisiert wurde – unabhängig davon, welche Farben es davor oder danach im Bild gibt bzw. gab.

    Falls noch Fragen bestehen, einfach kommentieren.

    Grüße
    Stefan

  3. Eike Sebastian Böker

    Hallo,

    erst einmal vorab ein großes Lob an deine Arbeit!

    Nun zu meiner Frage:

    Könntest du beschreiben noch etwas genauer auf die Stelle eingehen an dem quasi der Kernalgorithmus abläuft, also die Verschiebung der Bytes und die Manipulation des letzten Bits. Mir ist nicht so ganz klar weswegen die einzelnen ungebrauchten Kanäle mit 1 & verknüpft werden und wie die if-Abfrage funktioniert in der „color“ mit „bit“ verglichen wird.

    Vielen Dank vorab.

    Gruß,
    Eike

  4. Stefan Kiesel

    Hallo Eike,

    wenn ich dich richtig verstanden habe, dann meinst du die Zeilen 25 und 32, oder?

    In der Zeile 25 wird das aktuell zu versteckende Bit ausgelesen. Hierzu wird das aktuelle Byte so weit nach rechts verschoben, bis das aktuell zu bearbeitende Bit an erster Stelle steht. Anschließend wird es mit & 1 verknüpft, damit die anderen Bits, die sich weiter links vom aktuell zu bearbeitenden Bit befinden, auf 0 gesetzt werden. Ein Beispiel:

    10010101 soll versteckt werden, wir befinden uns aktuell an der 2. Stelle, also die 1. 0 von rechts. Das Byte wird zuerst um den Faktor 1 nach rechts verschoben, so dass wir folgendes Byte haben: 1001010. Nun wird dieses Byte mit 1 & verknüpft. Die & Verknüpfung bewirkt, dass alle Bits bis auf Bit ganz rechts gelöscht werden. Das Ergebnis der Verknüpfung ist also 1001010 & 1 = 0 & 1 = 0. Nachdem die restlichen Operationen durchgelaufen sind, befinden wir uns nun an der 3. Stelle, also die 2. 1 von rechts des ursprünglichen Bytes. Das ursprüngliche Byte wird um den Faktor 2 nach rechts verschoben und wir erhalten 100101 als Ergebnis. Dieses Ergebnis wird wiederum mit 1 & verknüpft und wir erhalten wieder das letzte Bit als Ergebnis: 100101 & 1 = 1 & 1 = 1.

    Zeile 32 funktioniert ähnlich wie Zeile 25. Dort wird auf ähnliche Weise einfach überprüft, ob das zu manipulierende Bit im aktuellen Farbkanal sich vom in Zeile 25 ermittelten zu setzenden Bit unterscheidet. Falls beide Bits identisch sind muss gar nichts zur Kodierung der Nachricht getan werden. Sind die Bits jedoch verschieden muss das letzte Bit des Farbkanals invertiert werden.

    Ich hoffe das hat dir weitergeholfen!?

    Grüße
    Stefan

  5. Eike Sebastian Boeke

    Hallo Stefan,

    Dann bin ich ja beruhigt und ich habe alles richtig verstanden.
    Eine kleine Anmerkung haette ich aber noch. Und zwar veraendert sich bei der 1. Anwendung auf ein .png file die Dateigroesse, sie wird kleiner. Wenn nun ein neuerlicher Text in dem schon „bearbeiteten“ Bild versteckt wird veraendert sich die Groesse allerdings nicht. Woran kann das liegen?!

    An dieser Stelle nochmal ein riesen Lob fuer deine Seite und vor allem fuer diese Einsatzbereitschaft und schnelle, sowie kompetente Antwort! Das ist nicht selbstverstaendlich.

    Vielen Dank und mit freundlichem Gruss,
    Eike

  6. Stefan Kiesel

    Hallo Eike,

    das ist zufällig und liegt an der Codierung sowie der Farbgebung des Bildes. Ein PNG Bild bspw. wird beim Speichern komprimiert. Je nach Aufbau und Farben des Bildes kann es unterschiedlich gut komprimiert werden. Es kann deshalb sein, dass das Verstecken eines Wortes im PNG Bild eine höhere Komprimierung erlaubt. Es kann jedoch auch umgekehrt sein (und das sollte meistens der Fall sein): Das versteckte Wort verschlechtert die mögliche Komprimierung und die Datei wird größer. Es kann aber auch sein, dass das PNG vor der Bearbeitung mit einem schlechten Algorithmus komprimiert wurde. Java verwendet einen besseren Algorithmus und erzeugt so generell eine bessere Komprimierung.

    Verwendest du anstatt eines PNGs bspw. ein BMP, wirst du überhaupt keine Veränderung an der Dateigröße feststellen, da ein BMP immer unkomprimiert gespeichert wird und bei identischer Auflösung, DPI Zahl und Farbtiefe seine Größe nicht verändert, auch wenn das Bild verändert wurde.

    Einen Einstieg in das PNG-Format sowie weiterführende Quellen findest du auf Wikipedia.

    Grüße
    Stefan

  7. Eike Sebastian Boeke

    Das erklaert so einiges 😉

    Vielen herzlichen Dank und weiter so!

    Gruss,
    Eike

  8. TB

    Eine Frage zum Verwandeln des Bytes von (möglicherweise signed) zu unsigned:

    Character haben doch immer einen „positiven“ Binärwert? Wieso muss ich mich darum kümmern das signed Byte in ein unsigned Byte zu verwandeln wenn das Byte (wenn man es bitweise ausliest) eh immer positiv ist? Stehe etwas auf dem Schlauch 🙁

  9. The_S

    Hallo TB,

    in Java hat ein byte einen Wert zwischen -128 und 127. Wenn du bspw. die Zahl 128 in ein byte schreiben möchtest, wird wieder „hinten“ angefangen und die byte-Repräsentation dieser Zahl lautet -128. Deshalb kann das byte negativ sein.

    Grüße
    Stefan

  10. TB

    Ja sie könnten theoretisch, aber SIND sie es denn in irgendeinem Fall in dem man ein Char in ein Byte umwandelt? Ich habe bis jetzt kein Char gefunden bei dem das der Fall ist. (http://www.pcguide.com/res/tablesASCII-c.html)

    Hier ein wenig Testcode von mir – vielleicht habe ich auch einfach irgendwas nicht verstanden aber ich sehe nicht wo der Fehler ist:

    public class BitTestMain {
    
    	/**
    	 * @param args
    	 */
    	public static void main(String[] args) {
    
    		final String text = "abc;.+*&(§&AZ=@#";
    		
    		final byte[] textInByte = text.getBytes();
    		
    		for(int pos = 0; pos  -1; shift--){
    			
    				System.out.print((int)((currentByte >> shift) & 1));
    			}
    			
    			System.out.println();
    			
    			System.out.print("Char als Bits " + text.charAt(pos) + " unsigned: ");
    			
    			// ... nochmal ausgeben, diesmal unsigned?!
    			for(int shift = 7; shift > -1; shift--){
    						
    				System.out.print((int)(((currentByte & 0xFF) >> shift) & 1));
    			}
    			
    			System.out.println();
    		}
    	}
    }

    Ausgabe:
    Char als Bits a signed: 01100001
    Char als Bits a unsigned: 01100001
    Char als Bits b signed: 01100010
    Char als Bits b unsigned: 01100010
    Char als Bits c signed: 01100011
    Char als Bits c unsigned: 01100011
    Char als Bits ; signed: 00111011
    Char als Bits ; unsigned: 00111011
    Char als Bits . signed: 00101110
    Char als Bits . unsigned: 00101110
    Char als Bits + signed: 00101011
    Char als Bits + unsigned: 00101011
    Char als Bits * signed: 00101010
    Char als Bits * unsigned: 00101010
    Char als Bits & signed: 00100110
    Char als Bits & unsigned: 00100110
    Char als Bits ( signed: 00101000
    Char als Bits ( unsigned: 00101000
    Char als Bits § signed: 10100111
    Char als Bits § unsigned: 10100111
    Char als Bits & signed: 00100110
    Char als Bits & unsigned: 00100110
    Char als Bits A signed: 01000001
    Char als Bits A unsigned: 01000001
    Char als Bits Z signed: 01011010
    Char als Bits Z unsigned: 01011010
    Char als Bits = signed: 00111101
    Char als Bits = unsigned: 00111101
    Char als Bits @ signed: 01000000
    Char als Bits @ unsigned: 01000000
    Char als Bits # signed: 00100011
    Char als Bits # unsigned: 00100011

  11. The_S

    Um zumindest annähernd fehlerfreie Software zu produzieren, sollte man auch die theoretisch möglichen Fälle abfangen – zumal das hier kein großer Aufwand ist und der Lesbarkeit nicht schadet. Davon abgesehen hat bspw. ein ‚Ä‘ einen Wert von 196 und somit -60 als byte.

    Ein kleiner Tipp: Die char-Codes beim Casten in eine Zahl entsprechen nur bis zur Stelle 127 dem ASCII-Code, anschließend gibt es Abweichungen.

    Grüße
    Stefan

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.