This is a write-up of the QRChecker problem from the SECCON 2018 online CTF. The problem consisted of two web pages, one containing the code of a Python program and the other allowing you to upload a file.
As a first step we analyze the Python code
#!/usr/bin/env python3
import sys, io, cgi, os
from PIL import Image
import zbarlight
print("Content-Type: text/html")
print("")
= set()
codes = [500, 250, 100, 50]
sizes print('<html><body>')
print('<form action="' + os.path.basename(__file__) + '" method="post" enctype="multipart/form-data">')
print('<input type="file" name="uploadFile"/>')
print('<input type="submit" value="submit"/>')
print('</form>')
print('<pre>')
try:
= cgi.FieldStorage()
form = form["uploadFile"].file.read(1024 * 256)
data = Image.open(io.BytesIO(data))
imagefor sz in sizes:
= image.resize((sz, sz))
image = zbarlight.scan_codes('qrcode', image)
resultif result == None:
break
if 1 < len(result):
break
0])
codes.add(result[for c in sorted(list(codes)):
print(c.decode())
if 1 < len(codes):
print("SECCON{" + open("flag").read().rstrip() + "}")
except:
pass
print('</pre>')
print('</body></html>')
The other website seems to be generated by the Python code and from the code we can also see what it does with the uploaded file. From
if 1 < len(codes):
print("SECCON{" + open("flag").read().rstrip() + "}")
we see that if we manage to get the length of codes
to be greater
than one it should print the flag. Analyzing the code we see that it
reads the uploaded file as an image. It resizes the image to a set
size and tries to read it as a QR code and puts the result in codes
.
After that it resizes it again and reads it as a QR code putting the results
in codes
again and it does this for the sizes (500, 500)
, (250,
250)
, (100, 100)
and (50, 50)
.
In other words, we need to provide a single image that produces at least two valid QR codes with different results under the resizing sequence 500→250→100→50.
So we want to create a QR code that contains different messages depending on the size.
From the documentation for PILs resize we read that the default resize
algorithm is to use PIL.Image.NEAREST
and from another part of the
documentation we can read what this means.
Pick the nearest pixel from the input image. Ignore all other input pixels.
So each pixel in the 50x50 image should depend on precisely one pixel in the original 500x500 image! We can therefore specify the 50x50 image precisely by changing only these pixels, and since this is only 1% (50⋅50 / 500⋅500 = 1 / 100) of the original image, the QR reader should still be able to read the full image.
The next step is to figure out which of the pixels of the 500x500 image remain after the resizing operations. To do this we create a 500x500 image were the pixels have different values, we resize it to 50x50 and see which values are left.
from PIL import Image
import numpy as np
# Helper functions for converting between Image and np.ndarray.
def imageToMatrix(image):
return np.array(
0), dtype="uint8"
image.getdata(2:])
).reshape(image.getbbox()[
def matrixToImage(matrix):
return Image.fromarray(matrix)
# Generate a 500x500 image using a simple increasing sequence.
# (This will overflow at 256 -> 0, but it doesn't matter.)
= np.arange(500*500, dtype="uint8").reshape(500, 500)
matrix
= [500, 250, 100, 50]
sizes
= matrixToImage(matrix)
image
for sz in sizes:
= image.resize((sz, sz))
image
= imageToMatrix(image)
matrix2
print("Top 10x10 block of original image:")
print(matrix[0:10, 0:10])
print("")
print("Top pixel of resized image")
print(matrix2[0, 0])
print("")
print("Index of the top pixel in original block")
print(np.where(matrix[0:10, 0:10] == matrix2[0, 0]))
Result from running the above code:
Top 10x10 block of original image:
[[ 0 1 2 3 4 5 6 7 8 9]
[244 245 246 247 248 249 250 251 252 253]
[232 233 234 235 236 237 238 239 240 241]
[220 221 222 223 224 225 226 227 228 229]
[208 209 210 211 212 213 214 215 216 217]
[196 197 198 199 200 201 202 203 204 205]
[184 185 186 187 188 189 190 191 192 193]
[172 173 174 175 176 177 178 179 180 181]
[160 161 162 163 164 165 166 167 168 169]
[148 149 150 151 152 153 154 155 156 157]]
Top pixel of resized image
179
Index of the top pixel in original block
(array([7]), array([7]))
We see that the top pixel in the resized image is determined by the
pixel at position (7, 7)
in the original image. Similarly the
pixel at position (i, j)
in the resized image will be given by the
pixel (10i + 7, 10j + 7)
in the original.
Given the two QR codes qr1.png
and qr2.png
we can merge them
together and create the new QR code, qr.png
.
#!/usr/bin/env python3
from PIL import Image
import zbarlight
import numpy as np
import matplotlib.pyplot as plt
import PIL
= 500
size
# Helper functions for converting between Image and np.ndarray.
def imageToMatrix(image):
return np.array(
0), dtype="uint8"
image.getdata(2:])
).reshape(image.getbbox()[
def matrixToImage(matrix):
return Image.fromarray(matrix)
= Image.open("qr1.png")
image1 = Image.open("qr2.png")
image2
= image1.resize((size, size))
image1 = image2.resize((size, size))
image2
= imageToMatrix(image1)
matrix1 = imageToMatrix(image2)
matrix2
= matrix1
matrix
7:size:10, 7:size:10] = matrix2[7:size:10, 7:size:10]
matrix[
"qr.png") matrixToImage(matrix).save(
Now we can check that it works
from PIL import Image
import zbarlight
= set()
codes = [500, 250, 100, 50]
sizes
= Image.open("qr.png")
image
for sz in sizes:
= image.resize((sz, sz))
image
= zbarlight.scan_codes("qrcode", image)
result
if result == None:
break
if 1 < len(result):
break
0])
codes.add(result[
print("Read values:", codes)
if 1 < len(codes):
print("Read more than one value!")
Result:
Read values: {b'foo', b'bar'}
Read more than one value!
Submitting the resulting image gives us the flag.