C# Quellcode für C# Express (.NET 2.0) - 31.7 Kb

C# Quellcode für SharpDevelop (.NET 1.1) - 27.3 Kb

Worum geht es?

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:

  • Der blaue Himmel in der oberen Mitte sollte keine versteckten Daten enthalten, da dort nahezu keine natürlichen Variationen vorkommen.
  • Die Wolken haben mehr Blauschattierungen, aber alles nicht Blaue wäre leicht zu finden. Wenn überhaupt Daten in den Wolken versteckt werden müssen, sollte nicht mehr als ein Bit pro Pixel verändert werden.
  • Das gleiche trifft auf die Bäme auf der rechten Seite zu: Änderungen an den höheren Bits würden helle Farben ergeben, obwohl hier nur dunkle Pixel erlaubt sind, also ist die Kapazität auf ein bis zwei Bits berschränkt.
  • Die Boote und der Strand auf der linken Seite sind schon besser. Sie enthalten Rot, Gelb, Weiß, Blau, Grün... In dieser Region können wir bis zu 7 Bits pro Pixel ändern, ohne dass es auffallen wird.

Um der einfachen Variations-Analyse zu entkommen, werden wir unsere geheime Nachricht nur in diesen Regionen verstecken, mit angepasster "Bitrate":

Regionen bearbeiten und speichern

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:

  1. Int32 Index (nicht Koordinaten!) des ersten Pixels in der ersten Region
  2. Int32 Länge der folgenden Regionsdaten
  3. Für jede Region:
    1. Int32 Länge (Region.GetRegionData().Data.Length)
    2. Int32 Kapazität (Anzahl von Bytes, die in dieser Region versteckt werden)
    3. byte Anzahl verwendeter Bits pro Pixel
    4. byte[] 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.

Die Nachricht verstecken

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);
}

Die Daten wieder auslesen

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:

  1. Länge der Regionen
  2. Index des ersten Pixels in der obersten Region
  3. mit Hilfe dieser Werte die Region lesen
  4. aus den Regionen die Nachricht lesen

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: