Portable Network Graphics (PNG) och Steganografi

Friday, September 9, 2022

ObfuskeringpngsteganografiT1001.002

Christoffer Strömblad

Entusiastisk Jedi inom Cybersäkerhet
6 minuter att läsa.

I den här artikeln tänkte jag i tillräcklig omfattning beskriva hur delar av Portable Network Graphics (PNG)-formatet fungerar för att öppna upp möjligheten att “dölja” godtyckliga mängder data i en valfri PNG-bild. Så vad säger du, är du redo att dölja data i en bild?

Koden jag skrev för att göra det här finns också publicerad på Github: png_stego

PNG-headern

Varje PNG-fil, som så många andra strukturerade format, börjar med ett gäng magic bytes, i det här fallet 8 bytes: 89 50 4e 47 0d 0a 1a 0a. Dessa finns alltid med.

Om vi kör en hexdump -C -n 8 bild.png ser vi följande:

00000000  89 50 4e 47 0d 0a 1a 0a                           |.PNG....|

Precis vad vi förväntar oss. Och efter dessa magic bytes kommer den första viktiga delen, eller CHUNK som det heter på PNG-språket. Denna första Chunk heter IHDR. De här Chunkarna är viktiga, för de är helt centrala för PNG-formatet. Varje Chunk beskrivs av följande fyra fält:

  1. Length (4-byte unsigned integer)
  2. Chunk Type (4-byte)
  3. Chunk Data (Blob of appropriate size according to Chunk Type)
  4. CRC (4 byte)

Och det som är spännande här med PNG-formatet är just att det inte finns några versioner osv, utan allting handlar om vilka Chunks som finns definierade enligt specifikationen och i filen. Några, t.ex. IHDR, IDAT samt IEND behöver finnas.

Låt oss nu plocka ut IHDR-Chunken, hexdump -s 8 -C -n 21 bild.png:

00000008  00 00 00 0d 49 48 44 52  00 00 00 dc 00 00 00 b0  |....IHDR...�...�|
00000018  08 06 00 00 00                                    |.....|

Och om vi då följer mönstret enligt ovan ska vi först se en Chunk Length (00 00 00 0d, 4-byte unsigned integer) och en Chunk Type (49 48 44 52, 4-bytes). Och 49 48 44 52 är ju just I H D R.

Därefter kommer 13 bytes av data som är IHDR, och den kommer jag inte gräva i för den bryr vi oss inte om, men däremot bryr vi oss om Chunks som inte är strikt nödvändiga för att visa bilden vilken filen beskriver.

En kedja av chunkar

En PNG-fil är alltså mer eller mindre en lång kedja av Chunks. Det finns ganska få regler för hur Chunks ska länkas, utan det avgörs istället av Chunk-typen. De krav som finns är följande:

  1. IHDR måste komma först (efter magic bytes då förstås)
  2. IEND måste vara sista Chunk.
  3. IDAT måste vara en serie och får inte avbrytas.

There can be multiple IDAT chunks; if so, they must appear consecutively with no other intervening chunks.

Ancillary Chunks

Det finns något som heter Ancillary Chunks, vilket kan beskrivas som icke-helt-nödvändiga Chunks. Om ett program stöter på en Ancillary chunk (bit 5 av första byten i Chunk Type) så kan den välja att ignorera den helt eftersom den inte är nödvändig för filen.

Det som är så fiffigt här är att bit 5 avgör om huruvida en ASCII-tecknet (a-z) blir stort eller litet. Kolla här:

>>> chr(ord('b') ^ 0x20)
'B'

Det vi gör är att ta bokstaven 'b' XOR med 0x20 (vilket binärt är 0010 0000, kom ihåg att bitarna i en byte är numrerade från 7..0).

Detta ger att om en chunk type har en LITEN bokstav först är det en ancillary chunk, och således inte strikt sett nödvändigt för att kunna visa bilden. Och här uppstår vår möjlighet att inkludera lite extra data.

Vad du behöver veta är att i Chunk naming conventions (kap 3.3 i specifikationen) är att om de två första bokstäverna är små spelar det ingen större roll hur de två kvarvarande bokstäverna ser ut.

Jag kan t.ex. skapa en chunk som heter coRS. Strikt sett bör den tredje bokstaven alltid vara stor enligt specifikationen, men de skriver samtidigt att eftersom den ännu saknar mening (den är reserved) spelar det ännu ingen roll om den är stor eller liten.

Steganografi

Och då så slutligen har vi kommit hit, den naiva Steganografi-möjligheten. PNG-filen är alltså strukturerad enligt följande:

  1. Magic bytes
  2. IHDR
  3. IDAT..n
  4. IEND

Om du tittar nedan så ser du följande övriga chunks: gAMA, sRGB, cHRM och så kommer första IDAT-chunken.

00000008  00 00 00 0d 49 48 44 52  00 00 00 dc 00 00 00 b0  |....IHDR...�...�|
00000018  08 06 00 00 00 64 6c 00  a0 00 00 00 04 67 41 4d  |.....dl.�....gAM|
00000028  41 00 00 b1 8f 0b fc 61  05 00 00 00 01 73 52 47  |A..�..�a.....sRG|
00000038  42 00 ae ce 1c e9 00 00  00 20 63 48 52 4d 00 00  |B.��.�... cHRM..|
00000048  7a 26 00 00 80 84 00 00  fa 00 00 00 80 e8 00 00  |z&......�....�..|
00000058  75 30 00 00 ea 60 00 00  3a 98 00 00 17 70 9c ba  |u0..�`..:....p.�|
00000068  51 3c 00 00 20 00 49 44  41 54 78 5e cc bd 07 94  |Q<.. .IDATx^̽..|
00000078  64 e9 75 1e f6 55 78 af  72 ea ea 9c 26 87 9d 99  |d�u.�Ux�r��.&...|

Vår möjlighet uppstår alltså i det att vi helt enkelt kan lägga till en ny typ av Chunk, genom att lägga till den EFTER eller FÖRE IDAT, men inte mitt i, då bryter vi mot specifickationen och bilden kommer inte renderas korrekt.

Det vi alltså gör är följande:

  1. Vi packar upp alla Chunks och sparar dem i en lista (array).
  2. Vi insertar vår nya Chunk på “rätt” plats.
  3. Vi packar ihop allting igen och så har vi fått en ny fil som också innehåller våra egna data.

För den nya Chunken behöver vi också räckna ut en CRC32 (4 byte unsigned integer) vilket vi kan göra med t.ex:

import binascii
crc32sum = binascii.crc32(chunk_type + chunk_data)

Observera att till skillnad från hur x86-CPU:er hanterar data i RAM med Little Endian lagrar PNG-filer integers i Network Byte Order (e.g. Big Endian). Det innebär att när vi packar ihop får Chunk-header måste vi packa med Big Endian in mind. Det ser ut ungefär så här i Python:

data = struct.pack(f">L4s{chunk.length}sL",
                   chunk.length, 
                   chunk.type, 
                   chunk.data, 
                   chunk.crc)

Det kan se lite grekiskt ut men det är egentligen ganska enkelt. Först anger vi HUR datat ska packas, en mall så att säga och därefter sparkar vi in de variabler som ska packas. > betyder big-endian och L är en unsigned 32 bit integer osv. Det ser ut som grekiska, men som sagt, det är bara att kolla vilka symboler som representerar vilken typ.

Du kan läsa mer om struct.pack() här Python Struct .

Nästa steg?

Ja, det är ju att göra ett så där lite mer riktig steganografiskt gömmande av data. Det kan vi göra genom att t.ex. försöka baka in data i flera Least Significant Bytes som inte för ögat förändrar bilden. Det begränsar dock hur mycket data vi har möjlighet att baka in i filen.

Vad tycker du borde vara nästa steg?

ObfuskeringpngsteganografiT1001.002

Christoffer Strömblad

Entusiastisk Jedi inom Cybersäkerhet

DNS över HTTPS (DoH) - Förklarat

Jag har läst The Art of Cyberwarfare