24 bytes past the end: how FFmpeg's sws_scale corrupted our heap
💥

24 bytes past the end: how FFmpeg's sws_scale corrupted our heap

Tags
C
Linux
Programming
Published
October 14, 2026
Author
yanbc
A heap-corruption crash I'd been chasing turned out to be a years-old swscale bug that only shows up at certain frame widths. Here's how it works, how to reproduce it in under a minute, and the three-line fix.
💥
TL;DR
On x86, FFmpeg's SSSE3 fast path for YUV420P → BGR24 (and the other packed RGB formats) converts 16 pixels per loop iteration but only guards the end of the row in steps of 8. When width % 16 is between 8 and 15, every row is written up to 24 bytes past its end. For the last row, that's past the end of your buffer.
If you allocate the destination tightly, for example av_image_alloc(..., align = 1) as FFmpeg's own scale_video.c example does, those 24 bytes land on the next heap chunk's header. Your program crashes later, somewhere else, inside malloc or free.
I've confirmed it on FFmpeg 4.4.2, Ubuntu 24.04's stock 6.1, the 9.0.2 release and current master. The fix on your side: give the destination buffer some tail padding.

The symptom

We run a C++ service that pulls surveillance video, runs object detection on it, and cuts a short clip around each detected event. Under concurrent load it would occasionally die. The kernel log pointed at glibc's allocator internals: general protection faults and segfaults in unlink_chunk and in the large-bin walk of _int_malloc.
This is the classic signature of heap corruption. Something writes over malloc's bookkeeping, and the crash only happens later, when the allocator next touches that chunk, usually on a different thread in unrelated code. Every backtrace was different and none of them was guilty.
The usual tools came up empty:
  • AddressSanitizer found a handful of genuine bugs (thread-unsafe localtime, a shared static HMAC buffer, a lambda capturing a loop variable by reference), but never this one.
  • Our test videos were 1920 and 960 pixels wide. As you'll see, those widths never trigger it.
What did crack it was MALLOC_CHECK_=3 (glibc's debug allocator) plus a 1352×760 camera from production. The debug allocator aborted with free(): invalid pointer on that video and nowhere else.

The code that looked fine

The clip cutter decodes frames with libavcodec and converts each one to BGR for OpenCV:
// once per video sws_ctx = sws_getContext(w, h, AV_PIX_FMT_YUV420P, w, h, AV_PIX_FMT_BGR24, SWS_BILINEAR, NULL, NULL, NULL); av_image_alloc(buffer, line_size, w, h, AV_PIX_FMT_BGR24, 1); // tightly packed // per frame sws_scale(sws_ctx, frame->data, frame->linesize, 0, h, buffer, line_size); cv::Mat image(h, w, CV_8UC3, buffer[0], line_size[0]);
Nothing here breaks the API contract. The sws_scale docs describe dst only as "the array containing the pointers to the planes of the destination image" and say nothing about padding. align = 1 is a documented, legal value. FFmpeg's own example, doc/examples/scale_video.c, does exactly this:
/* buffer is going to be written to rawvideo file, no alignment */ if ((ret = av_image_alloc(dst_data, dst_linesize, dst_w, dst_h, dst_pix_fmt, 1)) < 0) {

What swscale actually does

Same-size YUV → RGB conversions skip the scaler and go to a dedicated converter. On x86 with SSSE3 that's libswscale/x86/yuv2rgb.c plus the assembly in yuv_2_rgb.asm. The C wrapper works out how many pixels per row to hand to the assembly (abridged, comments mine):
#define YUV2RGB_LOOP(depth) \ h_size = (c->opts.dst_w + 7) & ~7; /* round up to 8 */ \ if (h_size * depth > FFABS(dstStride[0])) \ h_size -= 8; /* ...or down */ \ ... x86_reg index = -h_size / 2;
The guard is there to keep each row inside the caller's stride, and it works in steps of 8 pixels. The SSSE3 loop it feeds doesn't (BGR24 path, abridged):
movu [imageq], m0 ; 3 × 16-byte stores = 16 BGR pixels movu [imageq + 16], m1 movu [imageq + 32], m2 ... add imageq, 8 * depth * time_num ; time_num = 2 for XMM: 16 pixels add indexq, 4 * time_num js .loop0 ; loop until index >= 0
Each iteration converts and stores 16 pixels, and the loop keeps going until the counter crosses zero. So the number of pixels actually written is h_size rounded up to 16.
Take a 1352-pixel-wide frame:
dst_w
1352 (1352 % 16 = 8)
h_size after the guard
1352 (1352 × 3 = 4056 equals the stride, so no trim)
SSSE3 iterations
⌈1352 / 16⌉ = 85
pixels written per row
85 × 16 = 1360
overrun per row
8 pixels × 3 bytes = 24 bytes
For every row but the last, the extra 24 bytes land at the start of the next row, which gets converted (and overwritten) right afterwards. Nobody notices. The last row has no next row. Its 24 bytes go past the end of the buffer.
Widths that are a multiple of 16 (1920, 1280, 960) are safe, which is why our tests never saw it. Widths with width % 16 in 1–7 don't overrun either, because the guard rounds them down to a multiple of 16. But that leaves the last few pixels of each row unconverted, a separate and quieter bug. 1366, for example, leaves 6 pixels per row untouched.

From 24 stray bytes to a crash in free()

Here is what sits right after a tightly packed malloc'd frame on the glibc heap:
your BGR frame (w × h × 3 bytes) next chunk ... ─────────────────────────────────┬────────────┬────────────┬──────────────── ... last row ... │ prev_size │ size │ neighbour's data └────────────┴────────────┴──────────────── ▲ end of buffer ├──────────── 24 bytes ────────────┤ overwritten with BGR pixels by sws_scale
The overrun exactly covers the next chunk's prev_size and size fields plus the first 8 bytes of whatever lives there. The next free() or malloc() that touches that chunk reads pixel data as allocator metadata. With a mid-grey frame every byte is 0x82. That value has the IS_MMAPPED bit set, so glibc tries to treat the neighbour as an mmap'd chunk and aborts with munmap_chunk(): invalid pointer. With real video the bytes are whatever colour the bottom-right corner of the frame happens to be, so the failure varies: a bad unlink, a corrupt bin, a wild pointer.
Why didn't it crash on every frame? A 3 MB frame buffer starts out being served by mmap(), and page rounding gives it a little slack at the end, enough to swallow 24 bytes. But as soon as the program frees one such buffer, glibc's dynamic mmap threshold rises to that size, and from then on buffers that size come from the regular heap, packed right against their neighbours. A long-running server that decodes video gets there almost immediately. Even then it only crashes when an event fires, the camera has an unlucky width, and the corrupted neighbour is something that gets freed or merged. Under load, that happens often enough to hurt.
Why didn't ASan see it? AddressSanitizer instruments code at compile time. The overrun happens inside FFmpeg's hand-written assembly, which ASan never instruments.
Why doesn't FFmpeg's own test suite see it? tests/checkasm/sw_yuv2rgb.c even tests width 1080, which overruns. But it uses a destination stride of MAX_LINE_SIZE * 6 and compares only the first width × bpp bytes of each row, so the overrun lands in slack the test never inspects.

Reproduce it yourself

You need an x86-64 machine with SSSE3 (any x86 CPU from the last 15 years) and the swscale headers. On Ubuntu 24.04:
sudo apt install build-essential pkg-config libswscale-dev libavutil-dev
Save this as sws_overrun.c:
// sws_overrun.c - sws_scale (x86 SSSE3, YUV420P -> BGR24) writes past the end // of a tightly packed destination buffer for some frame widths. // // Build: gcc -O2 sws_overrun.c -o sws_overrun $(pkg-config --cflags --libs libswscale libavutil) // Run: ./sws_overrun measure the overrun for a set of widths // ./sws_overrun crash corrupt the glibc heap the way a real program would // ./sws_overrun padded the same, with one spare row of padding (no crash) // ./sws_overrun accurate measure again with SWS_ACCURATE_RND (no SIMD path) #include <libavutil/imgutils.h> #include <libswscale/swscale.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #define CANARY 256 static int sws_flags = SWS_BILINEAR; // Convert one mid-grey YUV420P frame of w x h into dst (tightly packed BGR24). static void convert(int w, int h, uint8_t *dst) { uint8_t *src[4]; int src_stride[4]; av_image_alloc(src, src_stride, w, h, AV_PIX_FMT_YUV420P, 32); memset(src[0], 0x80, (size_t)src_stride[0] * h); memset(src[1], 0x80, (size_t)src_stride[1] * h / 2); memset(src[2], 0x80, (size_t)src_stride[2] * h / 2); uint8_t *dst_data[4] = {dst, NULL, NULL, NULL}; int dst_stride[4] = {w * 3, 0, 0, 0}; struct SwsContext *ctx = sws_getContext(w, h, AV_PIX_FMT_YUV420P, w, h, AV_PIX_FMT_BGR24, sws_flags, NULL, NULL, NULL); sws_scale(ctx, (const uint8_t *const *)src, src_stride, 0, h, dst_data, dst_stride); sws_freeContext(ctx); av_freep(&src[0]); } static int measure(void) { const int widths[] = {1920, 1280, 960, 1352, 1080, 856, 540, 1366}; const int h = 720; int failures = 0; printf("swscale %d.%d.%d\n", LIBSWSCALE_VERSION_MAJOR, LIBSWSCALE_VERSION_MINOR, LIBSWSCALE_VERSION_MICRO); for (size_t i = 0; i < sizeof(widths) / sizeof(widths[0]); i++) { int w = widths[i]; size_t size = (size_t)w * 3 * h; uint8_t *buf = malloc(size + CANARY); memset(buf + size, 0xAB, CANARY); // canary right after the image convert(w, h, buf); int overrun = 0; for (int j = 0; j < CANARY; j++) if (buf[size + j] != 0xAB) overrun = j + 1; printf("%4dx%d width %% 16 = %2d ", w, h, w % 16); if (overrun) { printf("OVERRUN: %d bytes written past the end\n", overrun); failures++; } else { printf("ok\n"); } free(buf); } return failures ? 2 : 0; } static int crash(int padded) { const int w = 1352, h = 760; size_t size = (size_t)w * 3 * h; // exactly what av_image_alloc(..., align=1) asks for size_t alloc = padded ? size + w * 3 + 64 : size; // A 3 MB request is normally served by mmap(), whose page rounding hides a // small overrun. But once a program frees such a block, glibc raises its // mmap threshold and later ones come from the heap, packed against their // neighbours. Long-running video programs reach this state almost at once. void *volatile warm_up = malloc(alloc); // volatile: stop gcc eliding the pair free(warm_up); uint8_t *bgr = malloc(alloc); char *neighbour = malloc(64); // whatever happens to live next to the frame strcpy(neighbour, "innocent bystander"); printf("frame %p .. %p\nneighbour %p\n", (void *)bgr, (void *)(bgr + size), (void *)neighbour); convert(w, h, bgr); printf("conversion done, freeing the neighbour...\n"); fflush(stdout); free(neighbour); // glibc now reads a chunk header full of grey pixels free(bgr); printf("no crash\n"); return 0; } int main(int argc, char **argv) { if (argc > 1 && strcmp(argv[1], "crash") == 0) return crash(0); if (argc > 1 && strcmp(argv[1], "padded") == 0) return crash(1); if (argc > 1 && strcmp(argv[1], "accurate") == 0) sws_flags |= SWS_ACCURATE_RND; return measure(); }
Build it and measure:
$ gcc -O2 sws_overrun.c -o sws_overrun $(pkg-config --cflags --libs libswscale libavutil) $ ./sws_overrun swscale 7.5.100 1920x720 width % 16 = 0 ok 1280x720 width % 16 = 0 ok 960x720 width % 16 = 0 ok 1352x720 width % 16 = 8 OVERRUN: 24 bytes written past the end 1080x720 width % 16 = 8 OVERRUN: 24 bytes written past the end 856x720 width % 16 = 8 OVERRUN: 24 bytes written past the end 540x720 width % 16 = 12 OVERRUN: 12 bytes written past the end 1366x720 width % 16 = 6 ok
Now watch it take down the heap:
$ ./sws_overrun crash frame 0x55a02c01a340 .. 0x55a02c30ac80 neighbour 0x55a02c30ac90 conversion done, freeing the neighbour... munmap_chunk(): invalid pointer Aborted (core dumped)
The neighbour's user pointer sits exactly 16 bytes past the end of the frame, right behind its own 16-byte chunk header. The 24-byte overrun covers that whole header, plus the first 8 bytes of "innocent bystander".
Building FFmpeg 9.0.2 from source and linking against it (swscale 10.1.102) gives identical output.

Is it really FFmpeg's bug?

I wanted to be sure we weren't simply holding it wrong, so I checked three things:
  1. The API contract: sws_scale's documentation sets no padding requirement on dst, and av_image_alloc documents align as a free parameter.
  1. FFmpeg's own example allocates its sws_scale destination with align = 1.
  1. FFmpeg's intent: the h_size guard exists specifically to keep writes inside the caller's stride. It just computes the wrong bound for the 16-pixel loop. My guess is that it was written for the older 8-pixel MMX loop and never updated when the SSSE3 version arrived.
It is also a known problem. There are at least three open tickets on FFmpeg's tracker:
  • #9254 (2021): sws_scale() writes out of buffer on ssse3 YUV→RGB conversion
  • #9791 (2022): yuv420_bgr32_ssse3 writes out of buffer boundary during sws_scale() call
  • #10852 (2024): sws_scale overflows buffer for some resolutions using ssse3 instructions. yuv420 → bgr24, 8 bytes, "only for some image resolution". Exactly this bug.
As far as I can tell, all three are still open, and the code is unchanged on master. So you can't wait this one out with an FFmpeg upgrade.
In fairness to FFmpeg: nearly everything inside FFmpeg allocates frames with av_frame_get_buffer() or with alignment of 32 or 64, which leaves enough slack to swallow the overrun silently. You only get bitten when you take the documented, perfectly legal option of a tightly packed buffer.

The fix

Pad the destination. One spare row plus a cache line is far more than any SIMD writer needs:
int size = av_image_get_buffer_size(AV_PIX_FMT_BGR24, w, h, 1); uint8_t *data = (uint8_t *)av_malloc(size + w * 3 + 64); // tail padding av_image_fill_arrays(buffer, line_size, data, AV_PIX_FMT_BGR24, w, h, 1);
The layout (line_size, plane pointers) stays exactly the same, so the cv::Mat wrapping it doesn't change. ./sws_overrun padded runs clean.
Or avoid the fast path. Passing SWS_ACCURATE_RND in the sws_getContext flags makes swscale skip this converter entirely (./sws_overrun accurate shows no overrun). It also changes which code path does your conversion, so measure the speed and output before adopting it. I went with padding.
After deploying the padded buffer, the crashes stopped.

Takeaways

  • Pad buffers that SIMD code writes into. Hand-written vector code tends to process whole vectors and trust that a little overrun is harmless. Many libraries quietly assume padded buffers even when their docs don't say so.
  • Test odd sizes. Our test set of 1920 and 960 was a set of lucky numbers. If you process images, include widths that aren't multiples of 16 (or 32, or 64).
  • ASan can't see into assembly. For heap corruption that survives ASan, try MALLOC_CHECK_=3 / libc_malloc_debug.so, MALLOC_PERTURB_, and canary-based reproducers like the one above.
  • Don't run your service as PID 1 in Docker without --init. We lost several crash reports because the kernel ignores signals a PID-1 process hasn't set a handler for, including the SIGABRT that glibc raises when it detects corruption. abort() then falls back to executing hlt, which faults in user mode, and you get a confusing "general protection fault" in libc with no message and no core dump.
Tested on x86-64 with glibc 2.39: FFmpeg 6.1.1 (Ubuntu 24.04 package) and FFmpeg 9.0.2 built from source. Our production container ran FFmpeg 4.4.2 on glibc 2.35. I haven't tested ARM; aarch64 uses separate NEON code.

References