Aperi'CTF 2019 - Steganography (450 pts).

Aperi’CTF 2019 - Pickit

Challenge details

Event Challenge Category Points Solves
Aperi’CTF 2019 Pickit - Part 1 Steganography 75 7
Aperi’CTF 2019 Pickit - Part 2 Steganography 150 4
Aperi’CTF 2019 Pickit - Part 3 Steganography 100 24
Aperi’CTF 2019 Pickit - Part 4 Steganography 175 20

We’re given a PNG file pickit.png file.

Task description:

You are working in the analysis and imaging office of Pegasus.

Following a massive data leak in a large conglomerate, you were mandated as a forensic expert to analyze an image found on the USB key of one of the suspects.

Your colleague, who is passionate about theoretical physics, has left you a message:

This image seems to me different from the original, perhaps lighter and smaller… This guy seems to like to tease us, if he did hide messages, he probably used different techniques!

Find the hidden messages in this image!

File analysis

Reading the name of the challenge dubbed “Pickit” and since the image file has been categorized as a steganographic challenge, it’s quite obvious that at least one flag is hidden in the image colors.

But first things first, let’s quickly analyze the PNG file:

file ./pickit.png
binwalk ./pickit.png
./pickit.png: PNG image data, 393 x 480, 8-bit colormap, non-interlaced

0             0x0             PNG image, 393 x 480, 8-bit colormap, non-interlaced
830           0x33E           Zlib compressed data, default compression
875           0x36B           Zlib compressed data, default compression


Interesting thing is that we have an indexed PNG image file (8-bit colormap) while the image seems to use only few colors, let’s analyze its colormap using Gimp (Windows > Dockable Dialogs > Colormap):


Okay, there’s definitely no need for any other colors than grayscales in this image, let’s take a closer look at one of the entries on this map:

colormap entry

Yes! The colors seems to contain ASCII decimal values separated by 0x0 (most likely to prevent a trivial use of the strings tool).

If we carefully pick them (that was the goal of the challenge after all =]), we get the following list:

colors = [65, 0, 80, 0, 82, 0, 75, 0, 123, 0, 99, 0, 48, 0, 110, 0, 54, 0, 114, 0, 52, 0, 55, 0, 122, 0, 95, 0, 100, 0, 52, 0, 114, 0, 107, 0, 95, 0, 104, 0, 52, 0, 120, 0, 120, 0, 48, 0, 114, 0, 33, 0, 33, 0, 125, 0, 0]

Now, let’s try to decode it as ASCII string:

print(''.join(map(chr, colors)).replace('\x00', ''))

We got the first flag! APRK{c0n6r47z_d4rk_h4xx0r!!}

LSB on paletted image

The bad way (original challenge)

Now, let’s dig deeper into the image file, the usual steganographic techniques on images are based on encoding messages on the least significant color bits. Check it using the so called Stegsolve by Caesum:

stegsolve lsb

We got the second flag!! APRK{d16_d33p3r_n_d33p3r...}

What is interesting here is that we can get the flag back in any color (red, green or blue) since red = green = blue which correspond to an index of the colormap (range [0-255]).

The good way (fixed challenge)

Here, we were lucky as we found the flag using stegsolve or zsteg!

In fact, most of the tools don’t care if the file uses RGB colors or indexes on a palette, but rather only extracts RGB colors from pixels to analyze them.

The problem is that if we code a message on the LSB bits of the palette index rather than using RGB colors and the palette colors are shifted or unordered, the message will not be decoded using stegsolve.


Let’s pick the first pixel on the upper left corner of the image (position: 0,0):


If the palette colors are ordered and the offset is good, the LSB of the index can potentially match the corresponding color LSB:


But, let’s consider a simple example where the palette colors have been shifted to fit a larger flag (see Colormap):


As the colors were shifted, the LSB parity no longer matches between the color and the index, so the message will not be decoded in zsteg or stegsolve.

Knowing this problem, when we deal with paletted image, we should better use PIL and work with index LSBs rather than color LSBs.

from PIL import Image

def decode_bits(bits, encoding='utf-8', errors='replace'):
    """Create 8-bit groups and convert them to ASCII characters."""
    data = ''.join([chr(int(bits[i:i+8], 2)) for i in range(0, len(bits), 8)])
    return data

def lsb_decode(img):
    """Get the first LSB from image indexes."""
    data = ''.join([str(int(bit)%2) for bit in img.getdata()])  # img.getdata() returns palette indexes list when it's a paletted image.
    return data

img = Image.open('pickit.png')   # Load the image.
decode_bits(lsb_decode(img))[:64]  # Get the first 64 chars from the LSB.



Mmmkay, we better follow the indication and dig deeper n’ deeper! Since it’s stated in the description that there is no reuse of the same technique in this challenge, we must find at least two other techniques that can be used to hide a message in the image file.

zTXT chunk

If we quickly read the PNG file structure specification, we see that there’s couple of chunks that can be used to hide messages using zlib compression algorithm. Let’s analyze chunks using TweakPNG tool (we can run it using Wine):

tweakpng ztxt

Thanks to its automatic decompression feature, we got the third flag! APRK{n1c3_c47ch_c4rry_0n!!}

Non-interlaced image

One last flag to find! If we look again at the description, it’s stated that the original photo seems bigger and darker. The color aspect of the image can be explained as a result of using colormap entries to hide a message, but what about the size?

Let’s take another look to the PNG file structure specification especially the iHDR chunk:

hexdump -C -s 12 -n $((4+4+4+1+1+1+1+1)) ./pickit.png
0000000c  49 48 44 52 00 00 01 89  00 00 01 e0 08 03 00 00  |IHDR............|
0000001c  00                                                |.|
  • Name: \x49\x48\x44\x52: IHDR chunk (4 bytes)
  • Width: \x00\x00\x01\x89: 393px (4 bytes)
  • Height: \x00\x00\x01\xe0: 480px (4 bytes)
  • Bit depth: \x08: 8-bit (1 byte)
  • Color type: \x03: 3 = paletted image (1 byte)
  • Compression method: \x00: 0 = zlib deflate/inflate compression (1 byte)
  • Filter method: \x00: 0 = adaptive filtering (1 byte)
  • Interlace method: \x00: 0 = non-interlaced (1 byte)

Okay, we’ve a non-interlaced image so the pixels are scanned from left to right and the lines from top to bottom, remember when you were receiving images on slow transmission links? Pleasing, isn’t it?

noninterlaced rendering

Since the image is non-interlaced, we can’t extend the width of the image without changing its visual aspect. On the other hand, we can increase its height by using TweakPNG, let’s get 1000px:

extended height

Yeah, we finally got the last flag inside a QRCode! APRK{l00k_3v3rywh3r3!!}

Happy Hacking!