Spot the Difference — SECPlayground Christmas CTF 2023 Writeup
Spot the Difference [Crypto / Stego, Medium 20 Pts., 1 Solve]
Santa’s on a mission and needs your help! Two Christmas images are hiding a crucial message for this year’s festivities. Santa loves a good mystery and hasn’t spilled a single clue about where the hidden flag might be. Format: crypto{…}
Given two similar png images, we start by comparing them using Stegsolve. With Image Combiner, we should notice the differences between pixels in a diagonal line in SUB mode, like below.
We will work further on a Python script from now on because it’s more convenient. We load two images in matrix form and then compare them with an XOR function because it makes more sense than a subtraction.
Once XORed the given images, we will learn that in a diagonal manner, each pixel has a difference of just one bit — the Least Significant one. Therefore, it would make sense now that we cannot visually identify the differences with our eyes. The result can be multiplied by 255 to make it easier to spot the differences.
from PIL import Image
import numpy as np
img_ori = Image.open('bad_santa_original.png').convert('RGB')
arr_ori = np.array(img_ori)
img_new = Image.open('bad_santa.png').convert('RGB')
arr_new = np.array(img_new)
print(arr_ori.shape, arr_new.shape) # (1870, 1870, 3)
diff = (arr_new ^ arr_ori) * 255
diff_img = Image.fromarray(diff, 'RGB')
diff_img.save('diff.png')
We should now clearly see the differences after running the script.
The next step is finding a way to extract the flag from our result. Following pixels in the diagonal line, we see that some of them indicate the difference (R, G, or B dots) while the others don’t (black dots). How about transforming these to 0s and 1s and forming them into a binary string?
FLAG_LEN = 21
# For each pixel in diagonal, return False if there is no difference
# return True otherwise.
result = np.any(diff.diagonal(), axis=0)
# Truncate to keep only useful pixels
result = result[:FLAG_LEN * 8]
# Transform array of booleans to binary string
result = ''.join(list(map(lambda p: str(int(p)), result)))
print(result)
# 100111001000110110000110100011111000101110010000100001001011110111011110110001110101111101000010001101000100010001011111001001000100000101001110010101000100000101111101
We finally have the binary string! It’s time to convert to an ASCII string! Unfortunately, we only got a valid half of the flag :(
What if we try to flip the bits in the first half before decoding it? To do so, we can XOR each bit with ‘1’ or ‘True’. Run the script again to get the real flag!
FLAG_LEN = 21
# For each pixel in diagonal, return False if there is no difference
# return True otherwise.
result = np.any(diff.diagonal(), axis=0)
# Truncate to keep only useful pixels
result = result[:FLAG_LEN * 8]
# Flip the bits in the first half
result[:FLAG_LEN // 2 * 8 - 7] ^= True
# Transform array of booleans to a binary string
result = ''.join(list(map(lambda p: str(int(p)), result)))
print(result)
# 011000110111001001111001011100000111010001101111011110110100001000100001010001110101111101000010001101000100010001011111001001000100000101001110010101000100000101111101
crypto{B!G_B4D_$ANTA}
Solving Script
from PIL import Image
import numpy as np
img_ori = Image.open('bad_santa_original.png').convert('RGB')
arr_ori = np.array(img_ori)
img_new = Image.open('bad_santa.png').convert('RGB')
arr_new = np.array(img_new)
diff = (arr_new ^ arr_ori) * 255
diff_img = Image.fromarray(diff, 'RGB')
diff_img.save('diff.png')
FLAG_LEN = 21
# For each pixel in diagonal, return False if there is no difference
# return True otherwise.
result = np.any(diff.diagonal(), axis=0)
# Truncate to keep only useful pixels
result = result[:FLAG_LEN*8]
# Flip the bits in the first half
result[:FLAG_LEN // 2 * 8 - 7] ^= True
# Transform array of booleans to a binary string
result = ''.join(list(map(lambda p: str(int(p)), result)))
# Decode the flag
flag = ''
for i in range(0, len(result), 8):
flag += chr(int(result[i:i+8], 2))
print('The flag is', flag)
# The flag is crypto{B!G_B4D_$ANTA}