Three Sanitizers, Three Kinds of State

CombiSan's central engineering problem begins before its own shadow map: it must reconcile three different definitions of a bad execution. This chapter examines what ASan, MSan, and UBSan each observe, who owns the state behind those observations, and where their compiler and runtime contracts collide. The payoff is a more useful question than “Can these flags be enabled together?”: can one fuzzing run preserve the distinct evidence each detector is meant to provide?

ASan and MSan do not mean the same thing by valid memory

ASan and MSan are often grouped together because both instrument a program and maintain shadow information beside its ordinary memory. That similarity is real but incomplete. They attach different meanings to that information, and those meanings determine which operations must be instrumented, which libraries must be rebuilt, and when a report is trustworthy.

ASan's subject is addressability. A program object has a region that may be accessed and surrounding or later regions that may not. To make those boundaries visible, ASan places redzones around heap, stack, and global objects. A load or store that reaches a redzone becomes an addressability failure. When an object is freed, ASan places it in a quarantine rather than immediately making its memory available for reuse; the corresponding state remains inaccessible long enough to expose many use-after-free accesses. The same model supports checks for out-of-bounds access, invalid or repeated frees, and several forms of use-after-lifetime.

The key fact is that ASan can answer a local question at the access: is this address currently allowed to be touched? The runtime does not need to follow the meaning of the loaded value through every later computation in order to identify a redzone access. Its compiler instrumentation and allocator cooperate around memory boundaries. That makes the model broad and comparatively easy to attach to ordinary builds. The Clang documentation describes ASan as a fast detector with a typical slowdown of about two times.

ASan also draws a firm line after a report. By default, it exits on the first detected error. The reason is not simply convenience. Once a memory corruption has occurred, the program may be in an inconsistent state, so later observations could be confusing or misleading. Exiting also lets the implementation keep its generated code smaller and faster. For a single failing test case, that is a defensible diagnostic contract: stop before the damaged state produces a cascade of ambiguous symptoms.

MSan's subject is initialization, which is a different property from addressability. A byte can be in a valid object and still contain a value that the program never initialized. But even that description is not quite enough. Loading such a value is not always the final defect. The value might be overwritten, ignored, or kept away from any operation that gives it influence. The important cases are uses such as choosing a conditional branch, forming a pointer, passing data through a call, or supplying an argument to a library operation that expects initialized input.

MSan therefore follows initialization state as data moves. Its shadow information is propagated through computations and stores so that a later use can be connected to an earlier uninitialized origin. Origin tracking can add information about where the value was created or passed through memory, which helps debugging but adds more cost. The Clang documentation describes a typical MSan slowdown of about three times and says that origin tracking adds another execution and memory burden.

That dataflow precision changes the build boundary. To know whether a value has acquired a valid initialization state, MSan needs the operations that create, transform, and store that value to participate in the tracking. The documented ideal includes the program's dependent libraries and libc. Interceptors cover common library functions, but incomplete instrumentation can still result in false reports, and inline assembly that writes memory may need special treatment. OSS-Fuzz makes the operational consequence explicit: memory sanitizer builds are supported and recommended, but not enabled by default because uninstrumented system dependencies can produce false positives. A project that opts in is expected to build the libraries it needs with MSan-compatible instrumentation.

This produces a crucial contrast. ASan's central state answers “is this location addressable now?” MSan's state answers “does this value carry uninitialized data into a meaningful use?” The two properties can coexist in one byte. A location may be addressable but uninitialized, addressable and initialized, or unaddressable regardless of what bits it contains. A single generic poison flag would not preserve the transitions: a store should clear an initialization warning while retaining the fact that a redzone is inaccessible, whereas a free should make the region inaccessible without pretending that its old contents were initialized.

Consider a live buffer whose first portion has been filled and whose remaining portion has not. A read from the untouched portion can be addressable but uninitialized; if the value is discarded, it may remain only a candidate, while if it controls a branch it becomes a meaningful UUM report. A read just beyond the buffer is different even if the surrounding bytes happen to contain ordinary values: the address itself is outside the object's allowed region. Freeing the buffer changes the addressability answer for the whole object, but it does not turn its previous contents into initialized data. The same execution can therefore contain two independent stories about one allocation, and a useful combined detector must keep both stories intact.

The dependency boundary follows from this distinction. ASan can often observe an external allocation through allocator interposition and still enforce the surrounding addressability rules. MSan, by contrast, needs to know which operations initialized the returned bytes and how later code propagated that fact. If an external library performs those writes without participating in MSan's tracking, the initialization history is incomplete. Interceptors can model common library behavior, but they do not make arbitrary uninstrumented code transparent. The result is why MSan's compatibility problem affects the coverage a campaign can obtain, not only the effort required to produce a binary.

The difference also explains why stacking the two tools is not just a matter of adding another runtime check. Each sanitizer expects a layout and update discipline for its shadow state. ASan's access checks and allocator state do not supply MSan's propagation history; MSan's dataflow machinery does not automatically represent ASan's redzones and quarantine. If both compiler passes try to transform the same load, store, or allocation independently, they can compete over the same operation and over the representation that describes it. The paper identifies the ASan/MSan shadow layouts as incompatible by construction.

In other words, “memory is valid” has at least two meanings here. It can mean that an address belongs to a live object, or that the value obtained from that address has a defined initialization history. CombiSan's design begins from the decision to encode both meanings together, rather than pretending that one is a special case of the other.

flowchart LR A["ASan"] --> A1["Addressability: redzones and quarantine"] M["MSan"] --> M1["Initialization: state follows a value"] U["UBSan"] --> U1["Checks selected operations"] A1 --> R["Runtime metadata"] M1 --> R U1 --> C["Compiler instrumentation"] R --> C A1 --> S["Shadow-memory layout"] M1 --> S S --> X["ASan/MSan layout conflict"] M1 --> D["Broad dependency instrumentation"] D --> B["MSan deployment boundary"] M1 --> I["Instrumentation of shared operations"] U1 --> I I --> Y["MSan/UBSan propagation conflict"]

UBSan adds an operation-level contract

UBSan sits at a different layer again. It instruments operations that may violate language or runtime rules: signed integer overflow, invalid shifts, null or misaligned pointer use, selected bounds checks, and invalid indirect function calls are among the documented examples. It does not need the same addressability map as ASan or the same value-propagation history as MSan. Its checks ask whether a particular operation has crossed a defined boundary.

That narrower state does not make UBSan irrelevant to memory safety. An invalid pointer operation can expose a serious defect, and undefined behavior can affect security even when it does not immediately look like a buffer overflow. But the ownership model is different. ASan and MSan must maintain evolving memory-related state; UBSan inserts checks around susceptible operations and reports when one of them violates a selected rule. Its runtime cost is generally small, and the Clang documentation says most checks report and continue by default. Other modes can make selected checks exit or trap.

The ability to recover is important, but it is not the same as having no campaign consequence. A fuzzer needs a policy for what counts as a stopping event, what it should retain as a new finding, and what it should allow to recur while the input continues. A target that reaches the same undefined operation for every input can turn a report into a gate at the front of the program. If the integration treats each occurrence as a fresh crash, the fuzzer may repeatedly rediscover the same shallow behavior instead of exploring paths after it. The CombiSan paper describes such a problem in Ghostscript: a recurring function-pointer issue made every input trigger undefined behavior under the tested UBSan setup.

The sharper interoperability problem appears between UBSan and MSan. The paper gives a small example in which MSan detects an uninitialized value when run alone but misses it when UBSan is combined with it. The issue is not that the two detectors have identical goals. It is that they both transform related operations, while UBSan can mark its inserted instrumentation as not requiring sanitization. That status can interrupt the propagation MSan needs to carry the uninitialized state onward. Metadata may be representable in a compatible form, yet the compiler passes can still disagree about which operation is visible to which analysis.

This is the distinction between compatible storage and compatible semantics. Two tools can theoretically occupy related runtime structures and still fail to preserve one another's guarantees if one pass changes the operation before the other pass can observe it. Conversely, a combination can work at the compiler level and still produce incomplete campaign evidence because one runtime exits before another check runs. CombiSan's authors report that its instrumentation operates at an ASan-like granularity—memory accesses and allocations—so it can integrate with UBSan without asking UBSan operations to carry MSan's full propagation machinery.

The result is a three-part interface problem:

  • State ownership: What does the runtime remember—addressability, initialization, or a fact about an operation—and how are those states updated?
  • Instrumentation ownership: When several passes touch one load, store, allocation, or arithmetic operation, which transformation remains visible to the others?
  • Execution ownership: After a report, does the target stop, continue, aggregate the event, or reset before the next input?

These are not abstract compiler concerns. They determine whether a bug class is observed on the same input, whether an external library creates noise or a blind spot, and whether a recurring report prevents deeper fuzzing. OSS-Fuzz's current guide reflects the same boundary from an operations perspective: address and undefined configurations are listed as the default supported sanitizer set, while memory requires an explicit opt-in because its dependency assumptions are harder to satisfy. The default is therefore not a ranking of scientific importance. It is a sign of which combinations can be maintained reliably across projects.

CombiSan's proposal can now be stated more precisely. It is not trying to make ASan, MSan, and UBSan say the same thing. It is trying to give their distinct observations a common execution in which the addressability and initialization states can coexist, UBSan can remain attached to its operation checks, and the fuzzer can make a deliberate decision after the input has finished. Whether that is enough to recover the evidence lost by separate runs is a question about input and campaign behavior, not only about compiler compatibility.

That is why the next step is to follow an input through the system. The relevant failure is not merely that two flags reject the same build. It is that one report, one recurring condition, or one independent mutation stream can determine which later behavior the campaign never gets to see. Understanding those transitions turns sanitizer interoperability from a list of incompatibilities into a testable account of coverage.

References