QR Code with extra payload via steganography

393 views Asked by At

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)
2

There are 2 answers

9
SrPanda On BEST ANSWER

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

1
Victor On

The root of the problem was that the image was being saved as BW, which would round my encoded message to either 0 or 255, effectively throwing away any useful data.

By converting the images to grayscale, this allowed me to preserve the information. Some padding, and other matrix magics were applied as well. The whole solution cleaned up is posted here for future reference

import cv2
import numpy as np
import pyzbar.pyzbar as pyzbar
import qrcode
from PIL import Image


def create_qr_code(public_info, filename):
    qr = qrcode.QRCode(version=1, box_size=10, border=5)
    qr.add_data(public_info)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    img.save(filename)


def encode_secret(qr_code_filename, secret_bytes):
    qr_image = Image.open(qr_code_filename).convert("L")
    qr_array = np.array(qr_image)
    secret_array = np.frombuffer(secret_bytes, dtype=np.uint8)
    secret_size = secret_array.shape[0]
    qr_size = qr_array.shape[0] * qr_array.shape[1]
    if secret_size > qr_size:
        raise ValueError("Secret message is too large for the QR code.")
    elif secret_size < qr_size:
        pad_size = qr_size - secret_size
        secret_array = np.pad(secret_array, (0, pad_size), mode="constant")
    secret_array = secret_array.reshape(qr_array.shape)
    result_array = qr_array ^ secret_array
    result_image = Image.fromarray(result_array, mode="L")
    return result_image


def read_qr_code(filepath):
    image = cv2.imread(filepath)
    qr_codes = pyzbar.decode(image)
    if qr_codes:
        qr_code = qr_codes[0]
        value = qr_code.data.decode("utf-8")
        return value
    return None


def decode_secret(encoded_image_filename):
    encoded_image = Image.open(encoded_image_filename).convert("L")
    public_info = read_qr_code(encoded_image_filename)
    if not public_info:
        raise ValueError("Missing Public info on QR Code")
    qr_code_filename = "pure_qr_code.png"
    create_qr_code(public_info, qr_code_filename)
    qr_image = Image.open(qr_code_filename).convert("L")
    encoded_array = np.array(encoded_image)
    qr_array = np.array(qr_image)
    secret_array = encoded_array ^ qr_array
    secret_bytes = secret_array.flatten().tobytes()
    return secret_bytes


def main():
    public_info = "25.61795.?000001?.907363.02"
    qr_code_filename = "qr_code.png"
    secret_message = b"this is a secret"
    encoded_image_filename = "encoded_image.png"

    create_qr_code(public_info, qr_code_filename)
    encoded_image = encode_secret(qr_code_filename, secret_message)
    encoded_image.save(encoded_image_filename)

    decoded_secret = decode_secret(encoded_image_filename)
    print("Decoded Secret Message:", decoded_secret[: decoded_secret.find(b"\x00")])


if __name__ == "__main__":
    main()