Crack me if you can: What's the response to the Arecibo Message?
Introduction
This CTF-type challenge was created for BSides London last year - ultimately moved from 2020 to 2021, because of The Thing That Happened. Initially, the plan seemed to be that by entering, you got a chance at free entry - however in the revamped 2021 edition, entry was free anyway. The CTF is a simple forensic challenge: given an altered copy of the famous Arecibo image emitted by the unfortunately now-demised Arecibo radio telescope in Puerto Rico, can you decode the hidden message inside it?
Baby Steps
Let’s download the message, check its md5, and check the file size (94,197 bytes).
It’s a PNG image, so we now need to know the specification for png images: they’re effectively compressed zip-type things.
Validatation is by CRC, per chunk, started by IHDR and ended by IEND chunks. From head
and tail
, the IDHR and IEND chunks are there. So that’s nice.
Get some info on the image from pypng
: it’s a 281x844 RGB image. Uncompressed that’s about 700 kB. For reference, the REAL Arecibo image is 79x23 binary (see end for more details). Any colour you see is fictitious.
As a quick way to find the faulty chunk, try and fling them all out with pypng
.
p = png.Reader("ds-chall1.png")
print([c for c in p.chunks()])
Yields:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 1, in <listcomp>
File "/home/bsides/.local/lib/python3.8/site-packages/png.py", line 1410, in chunks
t, v = self.chunk()
File "/home/bsides/.local/lib/python3.8/site-packages/png.py", line 1401, in chunk
raise ChunkError(message)
png.ChunkError: ChunkError: Checksum error in IDAT chunk: 0xA589A7A8 != 0x132DCD56.
Awesome. For reference, the left value is the value reported in the file, the right value is calculated.
Running the above line of python three times with a fresh Reader() object tells me there are 8 mostly 8192-byte IDAT chunks after the corrupt chunk (presumably, also an IDAT), totalling (data only!) 63440 bytes (nb: with the final product, the data totals 94,021 bytes).
So: find the location of the raw bytes 0xa589a7a8 in the file.
checksum = b"\xA5\x89\xA7\xA8"
with open("ds-chall1.png", "rb") as f:
text = f.read()
text.find(checksum) # yields 30645
Some further arithmetic and searching for “IDAT” gets the chunk as starting at byte 22449. Our faulty chunk is indeed 8192 bytes, laid out like so:
|22449-22452|22453-30644| 30645 - 30649 |
| IDAT | DATA | CRC |
Note, the CRC includes the data field and chunk type field (which we know is fine), but not the length field. Incidentally, the actual checksum diff is:
print([bin(i-j) for i, j in zip(checksum, verify)]) #left = reported, right = calculated
#yields '0b10010010', '0b10111000', '-0b10011000', '0b10100100'
i.e. all bytes of the checksum differ in the biggest bit, implying that might be the flipped bit. That said, a brute-force is quick enough, so here it is:
import zlib
def find_flipped_bit(text : bytes, checksum : bytes, start : int, end : int):
"""
Quick and dirty CRC bitflip check.
"""
for i in range(start, end):
for j in (1,2,4,8,16,32,64,128):
try:
test = text[start:i] + bytes([text[i]^j]) + text[i+1:end]
assert zlib.crc32(test) == int.from_bytes(checksum, "big")
print(f"byte {i} is corrupted by {j}")
except AssertionError:
pass
find_flipped_bit(text, checksum, 22449, 30645)
# yields "byte 22453 is corrupted by 128"
So, let’s write it out:
#text refers to the bytes of the original file
with open("response.png", "wb") as f:
f.write(text[:22453] + bytes([text[22453]^128]) + text[22453+1:])
Sucess! It… looks like the Arecibo message. Putting it into glimpse with the “original” shows it to be a bit fuzzier, but the blocks are identical. The large file size (97 kB, about double the coloured original) is suspicious.
Trying something a little different, and making the reported checksum equal to the calculated value, yields no major change either:
with open("response_new_checksum.png", "wb") as f:
f.write(text[:30645] + b"\x13\x2d\xcd\x56" + text[30649:])
Nope. Actually the file is completely unchanged. That’s also suspicious.
Let’s see what binwalk has to say:
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 PNG image, 281 x 844, 8-bit/color RGB, non-interlaced
22453 0x57B5 Zlib compressed data, default compression
22453 is, you may recall, the start of the IDAT chunks. Let’s write out the start of the image (up to 22449).
with open("response.png", "rb") as f, open("header.png", "wb") as g:
text = f.read()
g.write(text[:22449])
And… it’s the original! So, the response has been concatenated. Interestingly, the response is still mostly valid PNG, and the header lacks an IEND chunk (but renders anyway).
With a bit of trial and error (the PNG format seems quite picky about whitespace characters between the file signature and the IHDR chunk):
with open("response.png", "rb") as f, open("footer.png", "wb"), as g:
text = f.read()
g.write(text[:37] + text[22449:])
Yields (as an image):
Happy 0x0Ath Anniversary BSides London!
The flag is "Just a cast away, an island lost at sea, oh"
Some notes on the Arecibo message:
The original, as mentioned earlier, was 1679 bits - not really a grid - sent by frequency-shifting 10 Hz over 2380 MHz at 10 bps, radiated with a transmit power of (according to wikipedia) 450 kW. 1679 bits was chosen because it is semiprime (23x79), and just about the only thing we can assume of another intelligent society is that they understand number theory. 23x79 gives an aspect ratio slightly off that of the received message, incidentally, so it’s been stretched a bit.
A hoax response was etched into a field in Hampshire, UK, indicating a silicon-based lifeform. I spent some time unsuccessfully searching for indicators of that, or the original as a binary string, in the message.
It is extremely unlikely the inhabitants of M13 understand PNG (they didn’t receive one!), and perhaps even more unlikely that they’re Sting fans, so the premise of this challenge is slightly flawed. Nonetheless my thanks to the original provider of this challenge, Dider Stevens.