Research: Local Sanitizer Audit — 2026-05-30¶
Summary¶
A local -Db_sanitize=address,undefined build of core/ on master tip bbcaa8d127 was run against the full unit-test suite (49 fast + 12 dnn
- 2 slow = 63 tests, all OK) plus the vmaf CLI binary against the Netflix golden YUVs across 4:2:0 8-bit, 4:2:2 10-bit, and 4:2:0 12-bit inputs and the full feature set.
Two real undefined-behaviour findings surfaced:
| # | Where | Class | Severity |
|---|---|---|---|
| 1 | core/src/feature/cambi.c (CambiState::window_size, CambiState::max_log_contrast) | Misaligned store + silent struct-field overwrite via option-parser type mismatch | Real bug — every --feature cambi invocation; UB; silently clobbers src_window_size byte-pair. |
| 2 | core/src/feature/x86/adm_avx{2,512}.c (DWT2 filter packing) | C left-shift of negative signed int | Real bug — UB on every HBD ADM frame; defends against compiler exploitation. |
Methodology¶
git worktree add -b fix/sanitizer-pass-cleanup /tmp/wt-sanitizer origin/master
cd /tmp/wt-sanitizer
meson setup build-asan core -Denable_cuda=false -Denable_sycl=false \
-Db_sanitize=address,undefined
ninja -C build-asan
# Unit tests
ASAN_OPTIONS=detect_leaks=1:abort_on_error=0:halt_on_error=0:print_stacktrace=1 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 \
meson test -C build-asan
# CLI exercise (Netflix golden fixtures, full feature set)
./build-asan/tools/vmaf \
--reference python/test/resource/yuv/src01_hrc00_576x324.yuv \
--distorted python/test/resource/yuv/src01_hrc01_576x324.yuv \
--width 576 --height 324 --pixel_format 420 --bitdepth 8 \
--feature psnr --feature float_ssim --feature float_ms_ssim \
--feature ciede --feature cambi --feature psnr_hvs --feature vif
# HBD path (10-bit 4:2:2 + 12-bit 4:2:0)
./build-asan/tools/vmaf ... 422p10le.yuv ... --pixel_format 422 --bitdepth 10 \
--feature psnr --feature cambi --feature adm
# Prediction with model (leak check)
./build-asan/tools/vmaf ... --model "path=model/vmaf_v0.6.1.json"
ASan + UBSan + leak-check were enabled simultaneously across every run.
Finding 1 — CAMBI option-parser misalignment + silent corruption¶
Signal:
core/src/opt.c:46:10: runtime error: store to misaligned address
0x7cc8bfde0902 for type 'int', which requires 4 byte alignment
core/src/feature/feature_name.c:104:38: runtime error: load of misaligned
address 0x7cc8bfde0902 for type 'int', which requires 4 byte alignment
Trace lands in vmaf_feature_extractor_context_create → vmaf_fex_ctx_parse_options and in vmaf_feature_name_from_options.
Root cause:
The options table in core/src/feature/cambi.c declares
{ .name = "window_size", .type = VMAF_OPT_TYPE_INT,
.offset = offsetof(CambiState, window_size), ... }
{ .name = "max_log_contrast", .type = VMAF_OPT_TYPE_INT,
.offset = offsetof(CambiState, max_log_contrast), ... }
but the underlying struct fields are uint16_t:
typedef struct CambiState {
...
uint16_t window_size; // offset 212 — 4-byte aligned (lucky)
uint16_t src_window_size; // -- clobbered by 4-byte int write
...
uint16_t vlt_luma;
uint16_t max_log_contrast; // offset 258 — 2-byte aligned, NOT 4-byte
char *heatmaps_path;
} CambiState;
set_option_int in core/src/opt.c writes *(int *)data = value;, which:
- on
window_size: writes 4 bytes starting at a 4-byte-aligned address, overwritingsrc_window_size.init()later setss->src_window_size = s->window_size, so the corruption is masked at runtime — but the silent overwrite is real and an implementation tweak (e.g., reordering init) would expose it. - on
max_log_contrast: writes 4 bytes starting at a 2-byte-aligned address — UBSan's misaligned-access trap fires. The trailing 2 bytes spill into the padding beforeheatmaps_path(a pointer, 8-byte aligned), so the pointer itself survives — but that survival depends on the natural padding the compiler chose, not on any explicit contract.
The mirror is vmaf_feature_name_from_options (core/src/feature/feature_name.c:104): the CLI's feature-name derivation reads *(int *)data from the same offset on every frame.
Fix:
Add shadow int slots window_size_opt and max_log_contrast_opt, point the options at them, and copy them into the existing uint16_t runtime fields at the top of init(). The option-parser bounds (15..127 and 0..5 respectively) guarantee the narrowing is lossless. Inner-loop SIMD/scalar signatures (uint16_t window_size) are unchanged, so no extractor kernel needs to be touched.
Verification:
- Re-ran
--feature cambialone,--feature cambi=window_size=63:max_log_contrast=3, and the full 7-feature CLI command. All clean under UBSan. - The feature-name derivation correctly produces
cambi_mlc_3_ws_63for tuned options (proving the option-parser AND name generator both read the shadow slots correctly). - Full unit-test suite (63 tests) passes; specifically,
test_cambi,test_cambi_simd,test_feature,test_feature_extractor,test_feature_collector,test_predict,test_model,test_model_collection_api. - CAMBI score values bit-exact with master on the Netflix golden YUV.
Finding 2 — AVX2 / AVX-512 ADM signed-shift UB¶
Signal:
Triggered on every HBD ADM frame.
Root cause:
__m512i f23_lo = _mm512_set1_epi32(
filter_lo[2] + (uint32_t)(filter_lo[3] << 16) /* + (1 << 16) */);
The cast on the result of the shift does not save the inner shift from being performed on the original signed int type. C operator precedence: the shift evaluates first on the signed type, then the result is cast to uint32_t. When filter_lo[3] is negative (the 9/7-tap DWT filter has negative taps), the shift is undefined behaviour.
The fix is one character — move the cast inside:
__m512i f23_lo = _mm512_set1_epi32(
filter_lo[2] + ((uint32_t)filter_lo[3] << 16) /* + (1 << 16) */);
Shifting an unsigned operand is fully defined and produces the same bit pattern as the original wrap-on-overflow signed shift on every two's-complement target.
Same pattern appears 4× in adm_avx512.c and 4× in adm_avx2.c.
Verification:
- Re-ran HBD 4:2:2 10-bit and 4:2:0 12-bit CLI invocations exercising
--feature adm. Clean under UBSan. test_integer_adm_simd(which compares AVX2/AVX-512 against scalar) still passes — bit-exact.- Netflix golden 10-bit/12-bit ADM scores unchanged.
Out-of-scope follow-up candidates¶
Surfaced by the same audit but not addressed in this PR (no UBSan trigger today, no demonstrable miscompile risk on shipped targets):
core/src/feature/cambi.c::CambiState::enc_width,enc_height,enc_bitdepth,src_width,src_height— declaredunsigned, targeted byVMAF_OPT_TYPE_INT. Layout-compatible on every ABI we ship for; UBSan does not flag.core/src/feature/float_adm.c::AdmState::adm_adm3_apply_hm— declaredint, targeted byVMAF_OPT_TYPE_BOOL. Safe by accident (priv memory ismemset(0)d andset_option_boolwrites the 1-byte default first), but technically UB.
A future ADR can introduce a stricter option-type schema (VMAF_OPT_TYPE_UINT16, etc.) to discharge the entire class. That would be a much wider refactor and is out of scope here.
Files touched¶
core/src/feature/cambi.c— shadowintslots + init bridgecore/src/feature/x86/adm_avx2.c— cast-inside-shift fixcore/src/feature/x86/adm_avx512.c— cast-inside-shift fixdocs/adr/0869-sanitizer-pass-cleanup.md— new ADRdocs/adr/README.md— index rowchangelog.d/fixed/sanitizer-pass-cleanup.md— fragmentdocs/research/sanitizer-pass-2026-05-30.md— this digestdocs/rebase-notes.md— entrydocs/state.md— state-md update
Reproduction command¶
git worktree add -b sanity-check /tmp/wt-check origin/master
cd /tmp/wt-check
meson setup build-asan core -Denable_cuda=false -Denable_sycl=false \
-Db_sanitize=address,undefined
ninja -C build-asan
ASAN_OPTIONS=halt_on_error=0 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 \
./build-asan/tools/vmaf \
--reference python/test/resource/yuv/src01_hrc00_576x324.yuv \
--distorted python/test/resource/yuv/src01_hrc01_576x324.yuv \
--width 576 --height 324 --pixel_format 420 --bitdepth 8 \
--feature cambi
# master: emits UBSan misaligned-store warning + misaligned-load warning
# this PR: silent (no UBSan errors)