pythonpython-imaging-libraryqr-codesteganography

QR Code with extra payload via steganography


I'm trying to use the approach described on this paper to "watermark" a QR Code with a special payload.

The whole flow seems to be correct, but I'm having some trouble saving the payload as bytes to be xor'd against the QR Code

import qrcode
from PIL import Image, ImageChops

qr = qrcode.QRCode(
    version=5,
    error_correction=qrcode.constants.ERROR_CORRECT_H,
    box_size=10,
    border=2,
)

msg = "25.61795.?000001?.907363.02"
sct = "secret message test"


def save_qrcode(data, path):
    qr.add_data(data)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    img.save(path)


save_qrcode(msg, "out.png")
save_qrcode(sct, "out2.png")
pure_qr_code = Image.open("out.png")

encoded_data_as_img = Image.new(mode=pure_qr_code.mode, size=pure_qr_code.size)
encoded_data_pre_xor = [ord(e) for e in sct]
print(encoded_data_pre_xor)

# Encoding
encoded_data_as_img.putdata(encoded_data_pre_xor)
encoded_data_as_img.save("out2.png")
encoded_data_as_img = Image.open("out2.png")
result = ImageChops.logical_xor(pure_qr_code, encoded_data_as_img)
result.save("result.png")

# Decoding
result = Image.open("result.png")
result2 = ImageChops.logical_xor(result, pure_qr_code)
result2.save("result2.png")
img_data_as_bytes = Image.open("result2.png").getdata()

encoded_data_after_xor = []
i = 0
while img_data_as_bytes[i]:
    encoded_data_after_xor.append(img_data_as_bytes[i])
    i += 1
print(encoded_data_after_xor)

This gives me the following output:

[115, 101, 99, 114, 101, 116, 32, 109, 101, 115, 115, 97, 103, 101, 32, 116, 101, 115, 116]
[255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]

But trying to diff result2.png and out2.png returns a no-diff. This means the problem happens on the message to image saving

encoded_data_as_img.putdata(encoded_data_pre_xor)

Solution

  • You are doing it wrong, the message should be contained by the QR not the image, if you diff the images from your solution, only the first line of pixels (of the 390x390 image) get changed and that data will be lost if noise is applied (as shown in the figure 12). The other error is that you are assuming that the QR can be damaged equally on all the parts, for example, if the position part is damaged as much as the data can tolerate, the QR will not be detected.

    The paper does not give an example but it simplifies to Damaged_QR = QR ⊕ String assuming that 1 pixel = 1 cell, in python it is a bit more involved as you can't easily xor raw buffers, for that 2 functions are needed to expand the bits of a string to a list of bools and viceversa.

    from PIL import Image, ImageChops
    from pyzbar import pyzbar
    import qrcode
    
    def str_to_bool(text, char_bits=8):
        ret = []
        for c in text:
            for i in range(char_bits):
                ret.append(ord(c) & (1 << i) != 0)
        return ret
    
    def bool_to_str(bool_list, char_bits=8):
        ret = ''
        for ii in range(len(bool_list) // char_bits):
            x = 0
            for i in range(char_bits):
                if bool_list[ii * char_bits + i]:
                    x |= 1 << i
            if x != 0:
                ret += chr(x)
        return ret
    
    def matrix_x_y(matrix, off_y_h=10, off_y_l=10):
        for y in range(off_y_h, len(matrix) - off_y_l):
            for x in range(len(matrix[y])):
                yield x, y
    

    To add the message it's as simple as xoring a segment of the data of the QR, here a char is 8 bits, but it can be compacted to 5 if only upper case text is needed.

    original_data = 'Text' * 10
    original_secret = 'Super Secret !!!'
    
    qr = qrcode.QRCode(
        error_correction=qrcode.constants.ERROR_CORRECT_H,
    )
    qr.add_data(original_data)
    qr.make()
    
    original = qr.make_image()
    original.save('original.png')
    
    cursor, msg = 0, str_to_bool(original_secret)
    for x, y in matrix_x_y(qr.modules):
        qr.modules[y][x] ^= msg[cursor]
        cursor += 1
        if cursor >= len(msg):
            break
    
    modified = qr.make_image()
    modified.save('modified.png')
    

    To get the data back the QR needs to be read and re-created without the "damage" to then xor those two to get the data that can be converted back to a string.

    decoded_data = pyzbar.decode(
        Image.open('modified.png')
    )[0].data.decode('utf-8')
    
    redo = qrcode.QRCode(
        error_correction=qrcode.constants.ERROR_CORRECT_H,
    )
    redo.add_data(decoded_data)
    redo.make()
    
    nums = []
    for x, y in matrix_x_y(qr.modules):
        nums.append(qr.modules[y][x] ^ redo.modules[y][x])
        
    decoded_secret = bool_to_str(nums)
    

    Then just verify that it worked, if QR gets actual corruption the message will get corrupted too and depending on how much error margin is left the QR might not even be readable.

    diff = ImageChops.difference(
        Image.open('original.png'), 
        Image.open('modified.png')
    )
    diff.save('diff.png')
    
    print('Valid QR: {0}'.format(
        original_data == decoded_data
    ))
    
    print('Valid Secret: {0}'.format(
        original_secret == decoded_secret
    ))
    

    To the naked eye there is no difference from the original (the first one) and the other one, if you just want to use this as a water mark there is no need to obfuscate the data. Example out images