Home Archive

CTF Write-up: QRChecker (SECCON 2018)

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.

Analyzing the problem

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("")
codes = set()
sizes = [500, 250, 100, 50]
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:
        form = cgi.FieldStorage()
        data = form["uploadFile"].file.read(1024 * 256)
        image= Image.open(io.BytesIO(data))
        for sz in sizes:
                image = image.resize((sz, sz))
                result= zbarlight.scan_codes('qrcode', image)
                if result == None:
                        break
                if 1 < len(result):
                        break
                codes.add(result[0])
        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.

Solving the Problem

So we want to create a QR code that contains different messages depending on the size.

Understanding the Resizing Operation

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(
        image.getdata(0), dtype="uint8"
    ).reshape(image.getbbox()[2:])

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.)
matrix = np.arange(500*500, dtype="uint8").reshape(500, 500)

sizes = [500, 250, 100, 50]

image = matrixToImage(matrix)

for sz in sizes:
    image = image.resize((sz, sz))

matrix2 = imageToMatrix(image)

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.

Embedding a QR Code in a QR Code

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

size = 500

# Helper functions for converting between Image and np.ndarray.
def imageToMatrix(image):
    return np.array(
        image.getdata(0), dtype="uint8"
    ).reshape(image.getbbox()[2:])

def matrixToImage(matrix):
    return Image.fromarray(matrix)


image1 = Image.open("qr1.png")
image2 = Image.open("qr2.png")

image1 = image1.resize((size, size))
image2 = image2.resize((size, size))

matrix1 = imageToMatrix(image1)
matrix2 = imageToMatrix(image2)

matrix = matrix1

matrix[7:size:10, 7:size:10] = matrix2[7:size:10, 7:size:10]

matrixToImage(matrix).save("qr.png")

Now we can check that it works

from PIL import Image
import zbarlight

codes = set()
sizes = [500, 250, 100, 50]

image = Image.open("qr.png")

for sz in sizes:
    image = image.resize((sz, sz))

    result = zbarlight.scan_codes("qrcode", image)

    if result == None:
        break
    if 1 < len(result):
        break
    codes.add(result[0])

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.

Images!

Input image 1
Input image 2
The merged QR codes, animated.