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
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
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
Colormap
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:
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', ''))
APRK{c0n6r47z_d4rk_h4xx0r!!}
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:
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.
Example
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.
Exploit
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.
Output:
'p2:APRK{d16_d33p3r_n_d33p3r...}\xf46\xc4\xa1\x85#>\xe8,\xff\xcc\x00\xb2<<\xba\x9e\xd3\x80\x11\xeb\xe8\xd5\x08w-`J\xfd\xd90\x7f\xd8'
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):
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 |.|
0000001d
- 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?
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:
Yeah, we finally got the last flag inside a QRCode! APRK{l00k_3v3rywh3r3!!}
Happy Hacking!