Skip to content

<optional> ASan annotations cause false-positive use-after-poison crashes in valid user code at clang-cl -O2 #6291

@rvandermeulen

Description

@rvandermeulen

<optional> ASan annotations cause false-positive use-after-poison crashes in valid user code at clang-cl -O2

Description

In MSVC 14.51 (VS 2026 18.6), <optional> was instrumented with __asan_poison_memory_region / __asan_unpoison_memory_region calls (gated by _INSERT_OPTIONAL_ANNOTATION, auto-defined under __SANITIZE_ADDRESS__ or clang's __has_feature(address_sanitizer)). When a non-trivially-destructible optional<T> is disengaged, its _Value storage is poisoned for sizeof(T) bytes; when it becomes engaged the storage is unpoisoned (_Optional_destruct_base at <optional> lines 124, 134, 164, 186).

This produces false-positive use-after-poison reports on perfectly valid C++ at clang-cl -O2 -fsanitize=address. We reproduce this every Windows ASan CI run when building Firefox against VS 2026 18.6.

The contract issue

The annotation creates an invariant: "do not read _Value while _Has_value == false." But optional::operator->() and operator*() do not enforce that invariant in release builds (<optional> lines 427-440 and 228-258):

_NODISCARD constexpr const _Ty* operator->() const noexcept {
#if _MSVC_STL_HARDENING_OPTIONAL || _ITERATOR_DEBUG_LEVEL != 0
    _STL_VERIFY(this->_Has_value, "operator->() called on empty optional");
#endif
    return _STD addressof(this->_Value);
}

In a typical ASan build neither _MSVC_STL_HARDENING_OPTIONAL nor _ITERATOR_DEBUG_LEVEL != 0 holds, so the check is compiled out. The accessor unconditionally returns &_Value.

When a user writes idiomatic guarded access:

if (opt) {
    return opt->some_member;
}

at -O2, the optimizer appears to produce code that reads from _Value storage of a disengaged optional despite the source-level has_value() guard. The read hits the poisoned shadow and ASan fires. The crash does not reproduce at /Od (see "Optimization sensitivity" below).

Concrete failure

Chromium's base::win::SecurityDescriptor (used in Firefox via the in-tree Chromium sandbox copy) holds four optional<> members:

absl::optional<Sid> owner_;
absl::optional<Sid> group_;
absl::optional<AccessControlList> dacl_;
absl::optional<AccessControlList> sacl_;

sandbox::HardenTokenIntegrityLevelPolicy calls SecurityDescriptor::FromHandle(token, kKernel, LABEL_SECURITY_INFORMATION). Only sacl_ gets engaged. The other three stay disengaged, so their _Value storage is poisoned by _Optional_destruct_base's default constructor.

Subsequent SecurityDescriptor::WriteToHandle -> SetSecurityDescriptor reads all four members:

DWORD error = set_sd(..., UnwrapSid(sd.owner()), UnwrapSid(sd.group()),
                          UnwrapAcl(sd.dacl()), UnwrapAcl(sd.sacl()));

where the UnwrapSid / UnwrapAcl helpers check engagement first:

PACL UnwrapAcl(const absl::optional<AccessControlList>& acl) {
  if (!acl) return nullptr;
  return acl->get();
}

This is correct C++ - we never call acl->get() on an empty optional. But with optimization enabled, ASan fires:

==NNNN==ERROR: AddressSanitizer: use-after-poison on address 0x... at pc 0x...
READ of size 8 at 0x... thread T0
    #0 base::win::SetSecurityDescriptor security_descriptor.cc:166
    #1 SecurityDescriptor::WriteToHandle ...
    #2 sandbox::HardenTokenIntegrityLevelPolicy ...
Shadow bytes around the buggy address:
...
=>0x...: f7 f7 f7 00 f7 f7 f7 00 f7 f7 f7 00 [f7] 00 ...

The repeating f7 f7 f7 00 is exactly four optional<> storages: three disengaged (poisoned) + one engaged, in the order owner/group/dacl/sacl. The [f7] is offset 64 of the SecurityDescriptor, corresponding to the start of dacl_._Value (the first disengaged optional whose storage was read).

Workaround

-D_DISABLE_OPTIONAL_ANNOTATION cleanly disables the feature (via the #undefs in __msvc_sanitizer_annotate_container.hpp). However, because per-subtree scoping is not viable due to the #pragma detect_mismatch("annotate_optional", ...) enforcement in the annotation header (partial application triggers lld-link: error: /failifmismatch: mismatch detected for 'annotate_optional'), the workaround has to be applied to every TU in the binary, losing optional ASan coverage everywhere just to suppress a bug whose footprint is narrow.

Optimization sensitivity

The crash is optimization-sensitive. Building the same code at /Od while leaving the optional annotations active eliminates the use-after-poison. At -O2 the crash reproduces immediately.

This is consistent with the optimizer reordering or speculatively executing loads from disengaged optional<T>::_Value storage past the has_value() guard that protects them in the source.

Possible fixes (Microsoft side)

  1. Add _Has_value checks to operator->() / operator*() unconditionally. Cost is one predictable branch per dereference (always taken in valid code); the optimizer can use this as a barrier preventing speculative reads through the accessor. This is the most natural fix.
  2. Drop the annotations entirely.
  3. Use a barrier intrinsic in the accessors to constrain the optimizer.

Related

microsoft/STL#6276 is a similar but distinct bug in <vector> / <string> annotations - the same "STL annotation creates an invariant the accessors don't enforce" family.

Environment

  • VS 2026 18.6.0 / MSVC 14.51.36231
  • clang-cl 20 (Mozilla's prebuilt)
  • x64 Windows ASan build, -fsanitize=address -O2 -Oy-
  • _MSVC_STL_HARDENING=1

Standalone reduction

We attempted to reduce to a minimal .cpp that triggers the same f7 pattern but could not - the bug appears to depend on the optimizer's specific inlining / scheduling decisions in a larger binary. The Firefox CI failure is reliable (~100% on Windows ASan tests) and we can provide build artifacts and full ASan logs on request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ASanAddress SanitizercompilerCompiler work involvedexternalThis issue is unrelated to the STL

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions