Eine bekannte Form der Steganoanalyse besteht darin, eintönige Bereiche des Bildes nach Variationen abzusuchen. In diffusen Bereichen, in denen jedes Pixel eine ganz andere Farbe hat als seine Nachbarn, sind veränderte Bits schwer auszumachen. Aber die meisten Bilder enthalten auch glatte Farben. Verschwende mal einen Blick auf dieses hier:
Um der einfachen Variations-Analyse zu entkommen, werden wir unsere geheime Nachricht nur in diesen Regionen verstecken, mit angepasster "Bitrate":
Wie in den vorherigen Beispielen brauchen wir eine Trägerbitmap, eine geheime Nachricht, und einen Schlüssel.
Die neue Funktion ist der Regionen Editor, mit dem der Benutzer Regionen und ihre Kapazitäten definieren kann.
Die einfachste Art eine Region zu zeichnen, ist auf die Eckpunkte eines Polygons zu klicken. Also lassen wir den Benutzer aufs Bild klicken und fügen jeden angeklickten Punkt zu einem Polygon hinzu. Eine Region kann mit einem Doppelklick geschlossen werden, danach öffnet der nächste Klick eine Neue:
private void picImage_MouseUp(object sender, MouseEventArgs e){
if (e.Button == MouseButtons.Left){
if (isDoubleClicked){
//auf Doppelklick folgendes MouseUp-Ereignis ignorieren
isDoubleClicked = false;
}
else{
if (!isDrawing){
//neues Polygon anfangen
isDrawing = true;
drawingPoints = new ArrayList();
cleanImage = picImage.Image;
bufferImage = new Bitmap(cleanImage.Width, cleanImage.Height);
}
AddPoint(e.X, e.Y);
}
}
}
Wenn ein Polygon mit einem Doppelklick geschlossen wird, müssen wir sicherstellen, dass es sich nicht mit einer der schon vorhandenen Regionen überschneidet.
Falls es ein anderes Polygon schneidet, vereinen wir die beiden Regionen. Wenn es frei steht, erstellen wir eine neue Region und fügen sie der Liste hinzu.
ctlRegions
ist ein RegionInfoList
, es zeigt Statistik und Eingabefelder für jede Region an.
private void picImage_DoubleClick(object sender, EventArgs e){
if (drawingPoints.Count > 2){
isDrawing = false;
isDoubleClicked = true;
Point[] points = (Point[])drawingPoints.ToArray(typeof(Point));
GraphicsPath path = new GraphicsPath();
path.AddPolygon(points);
if (!UniteWithIntersectedRegions(path, points)){
RegionInfo info = new RegionInfo(path, points, picImage.Image.Size);
drawnRegions.Add(info); //zu den Regionen hinzuügen
ctlRegions.Add(new RegionInfoListItem(info)); //auflisten
}
ReDrawImages(true);
}
}
Wenn eine weitere Region gezeichnet wurde, müssen wir die "Landkarte" und den Statistikblock aktualisieren.
ReDrawImages
zeichnet die Regionen auf das Ausgangsbild und die Karte.
(Die Karte und der Farbverlauf sind nicht wirklich nötig, sondern nur ein netter Effekt.)
/// <summary>Das Bild mit den Regionen in [picImage], /// und nur die Regionen in [picMap] anzeigen</summary> /// <param name="updateSummary">true: anschließend UpdateSummary() aufrufen</param> private void ReDrawImages(bool updateSummary){ //leere Bilder erstellen Image bufferImageNoBackground = new Bitmap(baseImage.Width, baseImage.Height); Image bufferImageWithBackground = new Bitmap(baseImage.Width, baseImage.Height); //Graphics holen Graphics graphicsWithBackground = Graphics.FromImage(bufferImageWithBackground); Graphics graphicsNoBackground = Graphics.FromImage(bufferImageNoBackground); //Hintergrund zeichnen/loeschen graphicsNoBackground.Clear(Color.White); graphicsWithBackground.DrawImage(baseImage, 0, 0, baseImage.Width, baseImage.Height); //alle Regionen zeichnen foreach (RegionInfo info in drawnRegions){ PathGradientBrush brush = new PathGradientBrush(info.Points, WrapMode.Clamp); brush.CenterColor = Color.Transparent; if (info == selectedRegionInfo){ //mark the region that's selected in the list brush.SurroundColors = new Color[1] { Color.Green }; }else{ brush.SurroundColors = new Color[1] { Color.Red }; } //Region zeichnen graphicsWithBackground.DrawPolygon(new Pen(Color.Black, 4), info.Points); graphicsNoBackground.DrawPolygon(new Pen(Color.Black, 4), info.Points); graphicsWithBackground.FillRegion(brush, info.Region); graphicsNoBackground.FillRegion(brush, info.Region); } //aufräumen graphicsWithBackground.Dispose(); graphicsNoBackground.Dispose(); //Bilder anzeigen picImage.Image = bufferImageWithBackground; picMap.Image = bufferImageNoBackground; picImage.Invalidate(); picMap.Invalidate(); //Zahlen und Fehler aktualisieren if (updateSummary) { UpdateSummary(); } }
Die Landkarte muss in den ersten Pixeln des Bildes gespeichert werden, so dass sie vor der Nachricht ausgelesen werden kann. Das heißt, wir müssen einen Header ins Bild einbetten. Später beim Auslesen müssen wir zuerst den Header mit den Regionen lesen, erst dann können wir die Nachricht aus diesen Regionen auslesen. Der Header kann über alle Pixel von 0/0 bis zum ersten Pixel der obersten Region verteilt werden. Wir werden nicht wissen wo die erste Region anfängt bevor die Karte ausgelesen ist, darum muss der Index des ersten Pixels irgendeiner Region mit im Header selbst gespeichert werden. Die Koordinaten des Pixels sind unwichtig, weil wir die Pixel als einen langen Strom behandeln werden, nicht als Zeilen und Spalten. Ein vollständiger Header enthält folgende Informationen:
Int32
Index (nicht Koordinaten!) des ersten Pixels in der ersten RegionInt32
Länge der folgenden RegionsdatenInt32
Länge (Region.GetRegionData().Data.Length
)Int32
Kapazität (Anzahl von Bytes, die in dieser Region versteckt werden)byte
Anzahl verwendeter Bits pro Pixelbyte[]
Region (Region.GetRegionData().Data
)Die Länge des Headers hängt von der Anzahl und Komplexität der Regionen ab. Wird eine neue Region im Regionen Editor hinzugefügt, dann müssen die neue Header-Länge und die eventuell andere Position der obersten Region geprüft werden. Falls nicht genug Pixel zwischen Bildanfang und erster Region übrig sind, kann der Header nicht versteckt werden. In diesem Fall zeigen wir eine Warnung an, und blenden den Weiter-Button ab. Die Regionen, die die tatsächliche Nachricht verstecken sollen, müssen groß genug sein. Also werden wir noch eine Warnung anzeigen, falls die Nachricht nicht in die Regioen hineinpasst:
private void UpdateSummary(){
bool isOkay = true; //noch keine Konflikte
long countPixels = 0; //Anzahl ausgewaehlter Pixel
int capacity = 0; //Gesamtkapazitaet aller Regionen
RegionInfo firstRegion = null; //erste Region - noch nicht gefunden
//erstes Pixel, das in einer Region liegt - noch nicht gefunden
int firstPixelInRegions = baseImage.Width * baseImage.Height;
//Int32 Anfang der ersten Region + Int32 Länge der Regionen + Byte Anzahl Bits pro Pixel
long mapStreamLength = 65;
foreach (RegionInfo info in drawnRegions) {
countPixels += info.CountPixels;
capacity += info.Capacity;
mapStreamLength += 64; //Int32 RegionData-Laenge + Int32 Kapazitaet
mapStreamLength += info.Region.GetRegionData().Data.Length * 8;
//ist diese Region die erste?
if ((int)info.PixelIndices[0] < firstPixelInRegions) {
firstPixelInRegions = (int)info.PixelIndices[0];
firstRegion = info;
}
}
//Pixel in der Region
lblSelectedPixels.Text = countPixels.ToString();
//Prozent des Bildes, die in dieser Region liegen
lblPercent.Text = (100 * countPixels / (baseImage.Width*baseImage.Height)).ToString();
//Kapazitaet
lblCapacity.Text = capacity.ToString();
if (capacity == messageLength) {
SetControlColor(lblCapacity, false);
errors.SetError(lblCapacity, String.Empty);
} else {
SetControlColor(lblCapacity, true);
errors.SetError(lblCapacity, "Overall capacity must be equal to the message's length.");
isOkay = false;
}
//Header-Laenge
lblHeaderSize.Text = mapStreamLength.ToString() + " Bits";
//sind genug Pixel fuer den Header frei?
if (firstRegion != null) {
if (firstPixelInRegions > mapStreamLength) {
lblHeaderSpace.Text = firstPixelInRegions.ToString() + " Pixels";
SetControlColor(lblHeaderSpace, false);
} else {
isOkay = false;
lblHeaderSpace.Text = String.Format(
"{0} Pixels - Please remove the topmost region.", firstPixelInRegions);
SetControlColor(lblHeaderSpace, true);
selectedRegionInfo = firstRegion;
ctlRegions.SelectItem(firstRegion);
ReDrawImages(false);
}
} else {
lblHeaderSpace.Text = "0 - Please define one or more regions";
SetControlColor(lblHeaderSpace, true);
}
btnNext.Enabled = isOkay;
}
private void SetControlColor(Control control, bool isError) {
if (isError) {
control.BackColor = Color.DarkRed;
control.ForeColor = Color.White;
} else {
control.BackColor = SystemColors.Control;
control.ForeColor = SystemColors.ControlText;
}
}
Wenn die Regionen groß genug sind, für genug Kapaziät konfiguriert sind,
und genug Platz für den Header frei lassen, aktiviert UpdateSummary
den Weiter-Button. Jetzt können Landkarte und Nachricht versteckt werden.
Bis jetzt haben wir nichts getan, ausser Eingangsdaten über das Trägerbild anzunehmen. Jetzt geht der interessante Teil los! In den vorhergehenen Artikeln haben wir den Schlüssel verwendet, um Pixel auszuwählen, und direkt die Nachricht eingebettet. Das funktioniert hier nicht mehr. Wir haben bestimmte Regionen, über die die Daten verteilt werden müssen, und der Header sollte gleichmäßig über die verfügbaren Pixel am Bildanfang verteilt werden. Das heißt, die Bytes aus dem Schlüssel können nicht direkt als nächster Offset verwendet werden, aber wir können mit ihnen einen Pseudo-Zufallsgenerator initialisieren. Dieser Zahlengenerator kann dann den nächsten Offset bestimmen:
Wenn die Nachricht später wieder extrahiert wird, brauchen wir nur das System.Ramdom
Objekt mit dem gleichen Startwert (dem Byte aus dem Schlüssel) zu initialisieren, und werden wieder die gleichen Offsets erhalten. Aber wir brauchen zwei Werte um die Intervalle zu berechnen:
Die Länge der Daten, die versteckt/ausgelesen werden sollen, und die Anzahl der übrigen Pixel.
Verstecken wir diese zwei Int32
doch einfach in den ersten 64 Pixeln, so dass wir sie leicht wieder auslesen können.
public unsafe void Hide(Stream messageStream, Stream keyStream){
//sicherstellen, dass das Bild im RGB Format vorliegt
Bitmap image = (Bitmap)carrierFile.Image;
image = PaletteToRGB(image);
int pixelOffset = 0, maxOffset = 0, messageValue = 0;
byte key, messageByte, colorComponent;
Random random;
BitmapData bitmapData = image.LockBits(
new Rectangle(0, 0, image.Width, image.Height),
ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
//zum ersten Pixel gehen
PixelData* pPixel = (PixelData*)bitmapData.Scan0.ToPointer();
PixelData* pFirstPixel;
//erstes Pixel finden, das zu einer Region gehoert
int firstPixelInRegions = image.Width * image.Height;
foreach (RegionInfo info in carrierFile.RegionInfo){
info.PixelIndices.Sort();
if ((int)info.PixelIndices[0] < firstPixelInRegions){
firstPixelInRegions = (int)info.PixelIndices[0];
}
}
//[firstPixelInRegions] verstecken
HideInt32(firstPixelInRegions, ref pPixel);
//"Landkarten-Stream" zusammensetzen
MemoryStream regionData = new MemoryStream();
BinaryWriter regionDataWriter = new BinaryWriter(regionData);
foreach (RegionInfo regionInfo in carrierFile.RegionInfo)
{
byte[] regionBytes = regionInfo.Region.GetRegionData().Data;
regionDataWriter.Write((Int32)regionBytes.Length);
regionDataWriter.Write((Int32)regionInfo.Capacity);
regionDataWriter.Write(regionInfo.CountUsedBitsPerPixel);
regionDataWriter.Write(regionBytes);
}
//zum Anfang des Streams gehen
regionDataWriter.Flush();
regionData.Seek(0, SeekOrigin.Begin);
//Laenge der Landkarte verstecken
HideInt32((Int32)regionData.Length, ref pPixel);
Jetzt wo die Startwerte gespeichert sind, können wir die "Landkarte" mit den Regionen über alle verfügbaren Pixel zwischen dem 65. und der ersten Region verteilen:
pFirstPixel = pPixel; //den schon geschriebenen Header nicht ueberschreiben
int regionByte;
while ((regionByte = regionData.ReadByte()) >= 0){
key = GetKey(keyStream);
random = new Random(key);
for (int regionBitIndex = 0; regionBitIndex < 8; ){
pixelOffset += random.Next(1,
(int)(
(firstPixelInRegions-1 - pixelOffset) / ((regionData.Length - regionData.Position + 1)*8)
)
);
pPixel = pFirstPixel + pixelOffset;
//[regionBit] in einem Bit der Farbkomponente ablegen
//Farbkomponenten weiter drehen
currentColorComponent = (currentColorComponent == 2) ? 0 : (currentColorComponent + 1);
//Rot-, Gruen- oder Blauwert holen
colorComponent = GetColorComponent(pPixel, currentColorComponent);
//Bits in einer Farbkomponente ablegen und diese in die Bitmap zurueckschreiben
CopyBitsToColor(1, (byte)regionByte, ref regionBitIndex, ref colorComponent);
SetColorComponent(pPixel, currentColorComponent, colorComponent);
}
}
Jetzt haben wir die Regionen versteckt, und alles was wir brauchen, um sie wieder auszulesen. Es ist Zeit auf den Punkt zu kommen und die geheime Nachricht zu verstecken.
//mit dem ersten Pixel des Bildes beginnen
pPixel = (PixelData*)bitmapData.Scan0.ToPointer();
pFirstPixel = pPixel;
foreach (RegionInfo regionInfo in carrierFile.RegionInfo){
//zum ersten Pixel der Region gehen
pPixel = (PixelData*)bitmapData.Scan0.ToPointer();
pPixel += (int)regionInfo.PixelIndices[0];
pixelOffset = 0;
for (int n = 0; n < regionInfo.Capacity; n++){
messageValue = messageStream.ReadByte();
if (messageValue < 0) { break; } //Nachricht zuende
messageByte = (byte)messageValue;
key = GetKey(keyStream);
random = new Random(key);
for (int messageBitIndex = 0; messageBitIndex < 8; ){
maxOffset = (int)Math.Floor(
((decimal)(regionInfo.CountPixels - pixelOffset - 1) * regionInfo.CountUsedBitsPerPixel)
/
(decimal)((regionInfo.Capacity - n) * 8)
);
pixelOffset += random.Next(1, maxOffset);
pPixel = pFirstPixel + (int)regionInfo.PixelIndices[pixelOffset];
//[messageBit] in einer Farbkomponente speichern
//Farbkomponenten weiter drehen
currentColorComponent = (currentColorComponent == 2) ? 0 : (currentColorComponent + 1);
//Rot-, Gruen-, oder Blauwert holen
colorComponent = GetColorComponent(pPixel, currentColorComponent);
//Bits in der Farbkomponente ablegen und diese in die Bitmap zurueckschreiben
CopyBitsToColor(
regionInfo.CountUsedBitsPerPixel,
messageByte, ref messageBitIndex,
ref colorComponent);
SetColorComponent(pPixel, currentColorComponent, colorComponent);
}
}
}
image.UnlockBits(bitmapData);
SaveBitmap(image, carrierFile.DestinationFileName);
}
Nun haben wir also ein Bild und wollen eine versteckte Nachricht daraus auslesen. Wir müssen die Bytes in der gleichen Reihenfolge auslesen, in der sie versteckt wurden:
Los jetzt, lesen wir die Regionen!
/// <summary>Den Header aus einem Bild lesen</summary>
/// <remarks>Der Header enthaelt Informationen ueber die Regionen, in denen die Nachricht steht</remarks>
/// <param name="keyStream">Schlüssel-Stream</param>
/// <returns>Die extrahierten Regionen mit allen Metadaten, die gebraucht werden, um die Nachricht zu lesen</returns>
public unsafe RegionInfo[] ExtractRegionData(Stream keyStream) {
byte key, colorComponent;
PixelData* pPixel;
PixelData* pFirstPixel;
int pixelOffset = 0;
Random random;
Bitmap image = (Bitmap)carrierFile.Image;
BitmapData bitmapData = image.LockBits(
new Rectangle(0, 0, image.Width, image.Height),
ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
//zum ersten Pixel gehen
pPixel = (PixelData*)bitmapData.Scan0.ToPointer();
//firstPixelInRegions lesen
int firstPixelInRegions = ExtractInt32(ref pPixel);
//Laenge der Regionen lesen
int regionDataLength = ExtractInt32(ref pPixel);
//Regionen lesen
pFirstPixel = pPixel;
MemoryStream regionData = new MemoryStream();
byte regionByte;
while (regionDataLength > regionData.Length) {
regionByte = 0;
key = GetKey(keyStream);
random = new Random(key);
for (int regionBitIndex = 0; regionBitIndex < 8; regionBitIndex++) {
//zum naechsten Pixel gehen
pixelOffset += random.Next(1,
(int)(
(firstPixelInRegions - 1 - pixelOffset) / ((regionDataLength - regionData.Length) * 8)
)
);
pPixel = pFirstPixel + pixelOffset;
//Farbkomponenten weiter drehen
currentColorComponent = (currentColorComponent == 2) ? 0 : (currentColorComponent + 1);
//get value of Red, Green or Blue
colorComponent = GetColorComponent(pPixel, currentColorComponent);
//ein Bit lesen und zu [regionByte] hinzufuegen
AddBit(regionBitIndex, ref regionByte, 0, colorComponent);
}
//das vollstaendige Byte speichern
regionData.WriteByte(regionByte);
}
image.UnlockBits(bitmapData);
Jetzt haben wir den Landkarten-Stream rekonstruiert. Um etwas Sinnvolles damit anzufangen, müssen wir daraus die Regionen rekonstruieren.
//Regions aus [regionData] lesen
ArrayList regions = new ArrayList();
BinaryReader regionReader = new BinaryReader(regionData);
Region anyRegion = new Region(); //Dummy-Region
RegionData anyRegionData = anyRegion.GetRegionData(); //Dummy-RegionData
Region region; //extrahierte Region
byte[] regionContent; //extrahierter Inhalt
//Header der extrahierten Region
int regionLength, regionCapacity;
byte regionBitsPerPixel;
regionReader.BaseStream.Seek(0, SeekOrigin.Begin);
do {
//Wenn das Program hier abstuerzt,
//ist entweder was Bild beschaedigt,
//oder es enthaelt keine Nachricht,
//oder ein falscher Schluesel wurde verwendet.
regionLength = regionReader.ReadInt32();
regionCapacity = regionReader.ReadInt32();
regionBitsPerPixel = regionReader.ReadByte();
regionContent = regionReader.ReadBytes(regionLength);
anyRegionData.Data = regionContent;
region = new Region(anyRegionData);
regions.Add(new RegionInfo(region, regionCapacity, regionBitsPerPixel, image.Size));
} while (regionData.Position < regionData.Length);
return (RegionInfo[])regions.ToArray(typeof(RegionInfo));
}
Wir sind fast fertig. Jetzt wissen wir, in welche Bereiche die Nachricht eingebettet ist, wie viele Bytes in welchem Bereich versteckt sind, und wie viele Bits aus jedem Pixeln ausgelesen werden müssen.
/// <summary>eine Nachricht auslesen</summary>
/// <param name="messageStream">Leerer Stream, der mit der Nachricht gefuellt wird</param>
/// <param name="keyStream">Schlüssel-Stream</param>
public unsafe void Extract(Stream messageStream, Stream keyStream) {
//Bitmap sperren, zum ersten Pixel gehen, und so weiter
//...
//...
foreach (RegionInfo regionInfo in carrierFile.RegionInfo) {
//zum ersten Pixel der Region gehen
pFirstPixel = (PixelData*)bitmapData.Scan0.ToPointer();
pPixel = pFirstPixel + (int)regionInfo.PixelIndices[0];
pixelOffset = 0;
for (int n = 0; n < regionInfo.Capacity; n++) {
messageByte = 0;
key = GetKey(keyStream);
random = new Random(key);
for (int messageBitIndex = 0; messageBitIndex < 8; ) {
//zum naechsten Pixel gehen
maxOffset = (int)Math.Floor(
((decimal)(regionInfo.CountPixels - pixelOffset - 1) * regionInfo.CountUsedBitsPerPixel)
/
(decimal)((regionInfo.Capacity - n) * 8)
);
pixelOffset += random.Next(1, maxOffset);
pPixel = pFirstPixel + (int)regionInfo.PixelIndices[pixelOffset];
//Farbkomponenten weiter drehen
currentColorComponent = (currentColorComponent == 2) ? 0 : (currentColorComponent + 1);
//Rot-, Gruen-, oder Blaukomponente holen
colorComponent = GetColorComponent(pPixel, currentColorComponent);
for(int carrierBitIndex=0; carrierBitIndex <
regionInfo.CountUsedBitsPerPixel; carrierBitIndex++)
{
AddBit(messageBitIndex, ref messageByte, carrierBitIndex, colorComponent);
messageBitIndex++;
}
}
//vollstaendiges Bytes zur Nachricht hinzufuegen
messageStream.WriteByte(messageByte);
}
}
//aufraeumen
//...
//...
}
Fertig! Jetzt zeigen wir die Nachricht an, und die Regionen, aus denen sie gelesen wurde.
Das ist alles was wir brauchen, um Leuten zu entkommen die versuchen, unsere versteckte Nachricht in unerwarteten Variationen in einfarbigen Bereichen des Trägerbildes zu finden.
Ausserdem gibt es jetzt einen zweiten Kanal für geheime Nachrichten. Wenn Du sicher bist, dass der Empfänger kreativ genug ist sie zu erkennen, kannst du Umrisse und Buchstaben mit dem Regionen Editor zeichnen: