Hiding Images in XMP Metadata

In which misreading a @Foone post made me ask the question "What if we put an image in your image?"

5 October 2024

I was avoiding getting out of bed when I came across a Mastodon post by @Foone that got me thinking. She had expressed a very reasonable thought, characteristic of her postsOk, for the most part. You should consider buying her a coffee, her feed is great., to include descriptions in the metadata of image so you wouldn’t have to write and rewrite alt texts every time you upload a file.

A mastodon post by Foone🏳️‍⚧️ (@foone@digipress.club) that reads 'I wonder if anyone has proposed a PNG/EXIF extension to let you embed an image description in an image file. It'd live with the image so as you upload it to different sites, the description stays with it'. It was posted on April 26, 20224 at 22:03. As of May 11, 13:41, it has 23 replies, 50 boosts, and 148 favourites.

A totally reasonable post.

Being half awake, eyes recently unglued, mind acclimatizing to the horrors of consciousness, I misread Foone post and my interest was piqued:

An altered screenshot of a mastodon post by Foone🏳️‍⚧️ (@foone@digipress.club). It is blury, wavy and some parts are illegible. The altered text reads 'I wonder if anyone has proposed a PNG/EXIF extension to let you embed an image (illegible) in an image file. (Illegible)'. The original post was posted on April 26, 20224 at 22:03. As of May 11, 13:41, it has 23 replies, 50 boosts, and 148 favourites.

Artist's rendition of my vision and mental state reading this post in the morning, altering the meaning of the post into something very different.

Naturally, I had to try this.

About Images and Metadata

For those not in the know, image files can carry extra data about the image, such as information about the camera that took it, GPS coordinates, and all manner of sundry bits and bobs.

Most of the time, this is done using an almost 30 year old standard called Exif (“Exchangeable image file format”). It’s derived from the TIFF file format, and mostly uses pointer offsets to refer to the data.

More recently, the Extensible Metadata Platform (XMP) standard was put forward by Adobe. XMP stores metadata using the RDF/XML syntax.

Goals

So I’m going to try to:

  1. Hide an image in within the metadata of an image.
  2. Without reversing/exploiting anythingFor the sole reason that I’m too dumb to do that..
A schematic with three photos. The first of a line of three, increasingly smaller nesting dolls labelled 'Outer' image. An arrow points to the right, originating from the first image, labelled 'Hide Inner in Outer Image Metata'. Below the arrow is an image of six increasingly smaller nesting dolls labelled 'Inner' image. The arrow terminates at a third image, identical to the first image,

Outline of what we're going to try to achieve in this post.

This is already kind of a thing

Both Exif and XMP can store thumbnails, which technically is an image stored in the metadata.

For XMP, you can find some (scarce) details in the official documentation. For Exif, Section 4.4 covers this in the spec (watch out, big PDF ahead).

I thought that this would sort of fail the “hide” criteria. Obviously, we’d expect to find the thumbnails specified in the metadata to be shown in all manner of places where thumbnails are usually shown. From my exceptionally limited testing, that’s not really the case. My operating system’s file manager (Nemo), nor a image metadata viewer I tried (Geeqie) show thumbnails from the XMP metadata.

Doing the thing

Here’s how I used python-xmp-toolkit, a python wrapper of Exempi, to add our “inner” image as the “thumbnail” of the “outer” image. This is a very naughty thing to do, like labelling the salt as sugar or jaywalking. Do so at your own peril.

from libxmp import XMPFiles
from base64 import b64encode
import shutil

# Encode the "inner" image using Base64
with open(inner_image_path, "rb") as file:
  inner_image_encoded =  b64encode(file.read())

# Back-up the old image
shutil.copyfile(outer_image_path, new_image_path)

# Load the existing XMP metadata
xmpfile_new = XMPFiles(file_path=new_image_path, open_forupdate=True)
xmp_new = xmpfile_new.get_xmp()

THUMBNAIL_NS = "http://ns.adobe.com/xap/1.0/g/img/"

# Save the "inner" image in the thumbnail metadata of "outer" image
xmp_new.set_property(THUMBNAIL_NS, 
                      'xmpGImg:image', 
                      inner_image_encoded.decode('utf8'))

xmp_new.set_property(THUMBNAIL_NS, 
                      'xmpGImg:height', 
                      "150")

xmp_new.set_property(THUMBNAIL_NS, 
                      'xmpGImg:width', 
                      "100")

# Write the XMP metadata to the file
xmpfile_new.put_xmp(xmp_new)
xmpfile_new.close_file()

That results in the following image:

A photo of four progressively smaller Matryoska nesting dolls in the 'babushka' style. The tallest is in the back, the smallest is in the front. There is a shallow depth of field, with the first, smallest doll in the foreground. A seam in the foremost doll betrays the fact that there are more dolls to be unconvered.

This photo has another photo hiding in its metadata.

You can then check in on the XMP metadata and see our Base64-encoded binary blob in the xmpGImage:image property.

Metadata of the image.

Now I know your heart is likely still racing, but I’m about to take it up a notch. We just used the thumbnail property, which is meant for images, but we can also just use any arbitrary XMP property meant for text by simply changing the namespace and property:

xmp_new.set_property(consts.XMP_NS_DC, 
                      'format', 
                      inner_image_encoded.decode('utf8'))

To restore the image from the metadata, simply read the XMP property, decode Base64 to bytes, and write to a file:

from base64 import b64decode

xmpfile = XMPFiles(file_path=outer_image_path, open_forupdate=False)
xmp = xmpfile.get_xmp()

THUMBNAIL_NS = "http://ns.adobe.com/xap/1.0/g/img/"

inner_image_encoded = xmp.get_property(THUMBNAIL_NS, 'xmpGImg:image')
# alternatively,
# inner_image_encoded = xmp.get_property(consts.XMP_NS_DC, 'format')

inner_image = b64decode(inner_image_encoded)

with open(new_image_path, 'wb') as file:
    file.write(inner_image)

When you restore the image, it looks like this:

The same photo of Matryoshka dolls as before, except this time, all seven dolls are revealed.

The revealed image, as expected.

Conclusion

I suspect this isn’t particularly useful, but it was fun to explore. I made a quick little CLI tool so you too can pointelessly stuff images inside images, à la Xzibit.

Until next time!

Sharing is Caring

Comments

Leave a comment as a reply to this Mastodon post and it'll show up right here.