Sitemap
suunj

suunj

Building an LLVM-Based Sanitizer for Uninitialized Pointer Detection

4 min readFeb 17, 2026

--

Dereferencing an uninitialized pointer is one of the most common sources of undefined behavior in C. The compiler might warn you with -Wuninitialized, but static analysis can't catch every case — especially when initialization depends on runtime conditions. Tools like Valgrind catch it at runtime but with 10-20x slowdown. MemorySanitizer (MSan) tracks uninitialized values broadly, but it's heavy and not pointer-specific.

I wanted something lightweight and focused: a sanitizer that only tracks stack pointer variables, catches uninitialized dereferences at runtime, and tells you exactly which variable, file, and line caused the problem.

So I built UninitPtrSanitizer — an LLVM instrumentation pass with a minimal shadow memory runtime.

GitHub: https://github.com/suunj/UninitPtrSanitizer

The Idea

The core insight is simple: if you only care about pointer-type local variables, you can track their initialization state with a single byte per variable. No need for bit-level shadow memory like MSan.

The approach:

  1. At compile time, the LLVM pass finds every alloca of pointer type and assigns it a unique slot ID
  2. A shadow array (one byte per slot) tracks whether each pointer has been initialized
  3. Every store to the pointer marks it as INIT
  4. Every load followed by a dereference checks the shadow — if UNINIT, report and abort

LLVM Pass Implementation

The pass is registered as a new-PM plugin via llvmGetPassPluginInfo(). It scans every function in the module looking for AllocaInst where the allocated type is a pointer.

For each pointer alloca, it inserts three kinds of instrumentation:

After alloca — mark as uninitialized:

alloca i32* %p
→ __upsan_alloc(slot_id) // shadow[slot_id] = UNINIT

Before store — mark as initialized:

store i32* %val, i32** %p
→ __upsan_init(slot_id) // shadow[slot_id] = INIT

Before load+dereference — check:

%v = load i32*, i32** %p
%x = load i32, i32* %v // dereference!
→ __upsan_check(slot_id, file, line, func, var_name)

The key challenge is determining whether a loaded pointer is actually dereferenced. Not every load of a pointer variable leads to a dereference — the pointer might just be passed around or compared. The pass checks if the loaded value is used as a pointer operand in a subsequent load, store, GEP, or call instruction.

Get suunj’s stories in your inbox

Join Medium for free to get updates from this writer.

Source location is extracted from LLVM’s DebugLoc metadata (requires compiling with -g), so the error report includes file, line, function name, and variable name.

Runtime: Shadow Array

The runtime is deliberately minimal. The shadow state is a flat uint8_t array allocated once at program startup:

void __upsan_init_runtime(uint32_t max_slots) {
g_shadow = calloc(max_slots, sizeof(uint8_t)); // all zeros = UNINIT
}

The check is a single array lookup:

void __upsan_check(uint32_t id, ...) {
if (g_shadow[id] == 0) { // UNINIT
// report error with file, line, function, variable name
abort();
}
}

This is why the overhead is minimal — no hash maps, no tree traversals, just an indexed array access per dereference.

What It Catches

Direct uninitialized dereference:

int *p;          // uninitialized
int val = *p; // BUG — caught at runtime
================================================================
UninitPtrSanitizer: use of uninitialized pointer
================================================================
Variable: p
Location:
test_uninit_deref.c:12 in main()
Backtrace:
#0 build/test_uninit_deref(main+0x52) [0x56278a136212]
================================================================

Conditional initialization path:

int *p;
if (argc > 1)
p = &val; // only initialized in this branch
process(p); // BUG when argc == 1 — caught

This is the case that -Wuninitialized often misses at higher optimization levels.

Current Limitations

The scope is deliberately limited to stack pointer allocas. This means:

  • Struct member pointers — node.next is not a direct alloca, so it's not tracked
  • Heap pointer arrays — malloc'd pointer buffers are out of scope
  • Global pointers — not an alloca

These are potential extensions. The struct member case could be handled by tracking stores to specific GEP offsets, but that significantly increases complexity.

Build and Try It

Requirements: LLVM/Clang 15+ (tested with 18), Linux.

git clone https://github.com/suunj/UninitPtrSanitizer.git
cd UninitPtrSanitizer
make CC=clang-18 CXX=clang++-18 LLVM_CONFIG=llvm-config-18
make CC=clang-18 CXX=clang++-18 LLVM_CONFIG=llvm-config-18 test

To use on your own code:

clang-18 -g -O0 \
-fpass-plugin=build/UPSanPass.so \
-o my_program my_program.c \
-Lbuild -lupsan_rt -lpthread -rdynamic

What I Learned

Building a sanitizer from scratch taught me more about LLVM IR than any tutorial could. A few things that stood out:

  • The new pass manager plugin API (llvmGetPassPluginInfo) is clean but version-sensitive — loading a pass built against LLVM 18 into clang 21 causes a segfault with no useful error message. Always match versions.
  • At -O0, clang generates straightforward alloca/store/load patterns that map well to source-level variables. At higher optimization levels, mem2reg promotes allocas to SSA registers, which would require a different approach (tracking phi nodes).
  • DebugLoc metadata is essential for useful error reporting. Without -g, you get <unknown>:0 — technically correct but useless.

The full source is on GitHub. Issues and contributions welcome.

--

--