Skip to content

Nothing in the gate runs a sanitizer build, so the alignment class is ungated #224

Description

@jdatcmd

Found while closing the first #214 finding. The fix landed; the thing that
catches the next one did not exist and still does not.

What happened

The #214 fuzzer was run against an ASAN and UBSAN build of PostgreSQL 18.4 and
reported, at src/columnar_encoding.c:1135:

runtime error: load of misaligned address 0x75064ac5813e for type 'uint32',
which requires 4 byte alignment

Values in a chunk's raw buffer are packed end to end with no alignment padding,
so a four-byte varlena header begins wherever the previous value ended.
VARSIZE_ANY reads it by casting to varattrib_4b and loading a uint32.
Undefined on an unaligned address; works on x86_64; SIGBUS on a
strict-alignment target
, which docs/limitations.md says is supported.

The part that matters for this issue: it was not a crafted-input defect. A
plain INSERT of low-cardinality text reports it six times. It had been on the
ordinary write path the whole time. Three call sites were affected
(encode_dict, and two buffer walks in columnar_reader.c).

Every one of the 92 suites passed on all five majors, before and after, on both
sides of the fix. The full matrix is green either way. Nothing in the gate can
see this class of defect, because nothing in the gate is instrumented.

Why the fuzzer is not the answer on its own

test/fuzz_parquet.sh classifies a sanitizer report as a finding and is in the
matrix, so this looks covered. It is not. The check is only live when the suite
runs against a sanitizer build, and the matrix's five majors are all ordinary
assert builds. On those, the sanitizer check passes vacuously, every time. That
is the same shape as a suite that greps a file for a guard that is not there:
green because nothing was looked at.

What to build

A sixth configuration in the matrix, or a second gate that runs beside it: one
PostgreSQL major built with -fsanitize=address,undefined, running a subset of
the suites. It does not need all 92 and it does not need five majors. It needs
the write path, the read path, the encoders, and the import and decode paths,
which is where hand-rolled buffer walks live.

Everything needed to build it is already worked out

The environment fights back in four specific ways, all now solved. They are
recorded here so nobody rediscovers them:

  • Build in-tree, not VPATH. /root/postgresql-18.4 has a config.status
    and a built postgres in it, so it was configured in place. A VPATH build
    against such a tree fails with subdirectories that "enter" and "leave" without
    building, then a link that cannot find the objects. Extract a fresh tarball.
  • libclang-rt-<N>-dev must match the clang major, or -fsanitize=address
    cannot link at all. gcc works too, via libasan.
  • ASAN_OPTIONS=detect_leaks=0. pg_config is itself instrumented and leaks
    by design at exit, so LeakSanitizer fires on every invocation and poisons the
    values PGXS reads. The extension build fails before it starts.
  • ASAN_OPTIONS=detect_stack_use_after_return=0. Without it, initdb fails
    with FATAL: stack depth limit exceeded during bootstrap, and raising
    max_stack_depth to 32MB does not help, because it is not really running out
    of stack. Clang now defaults that option on, which relocates locals to a heap
    fake stack, and PostgreSQL's check_stack_depth() is pointer arithmetic
    against stack_base_ptr. The arithmetic becomes meaningless and the check
    misfires.
  • UBSAN needs a suppression for function. Every backend start reports
    dynahash.c:1022: call to function string_compare through pointer to incorrect function type. That is a PostgreSQL-wide idiom, not a defect, and unsuppressed
    it buries real reports. A suppressions file works; -fno-sanitize=function
    would too.

The working build is /usr/local/pg18_san in the dev container, and
/root/build-san.sh is the script that produced it.

Todo

  • Decide where it runs: a sixth entry in run_all_versions.sh, or a
    separate test/run_sanitizer.sh invoked on its own cadence. A sanitizer
    build is several times slower, and the matrix is currently six minutes,
    which is worth protecting.
  • Choose the suite subset, favouring paths that walk buffers by hand:
    native_encoding, native_roundtrip, native_writer, differential,
    parquet_import, native_read_parquet, fuzz_parquet, hardening.
  • Script the build so it is reproducible rather than a container artifact,
    with the five gotchas above encoded rather than remembered.
  • Bake the required ASAN_OPTIONS and UBSAN_OPTIONS into the runner, so a
    correct run is the default and not something the operator has to know.
  • Prove the gate by removal: revert the ColumnarVarSizeAnyUnaligned change
    on a branch and confirm the sanitizer gate goes red and the ordinary matrix
    stays green. Until that is shown, the gate is not known to work.
  • Decide whether the sanitizer build blocks a merge or reports. Given it
    found a real defect on its first run, blocking is defensible.

Related

Fix and full account: #214 and the PR that closes it. test/mutate_guard.py
already records that ASAN cannot see overruns inside palloc'd chunks, because
palloc sub-allocates from larger blocks, and that only a USE_VALGRIND build
shows those. So a sanitizer gate is a floor, not a ceiling, and a Valgrind
configuration is the natural follow-on.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions