[RFC] Hardening in libc++

We would like to improve security in libc++ by providing optional hardening modes that, when enabled, turn certain cases of undefined behavior into guaranteed program termination (in other words, turn undefined behavior into implementation-defined behavior). See the C++ Buffer Hardening RFC for more context about the overarching effort.

(Note: this RFC presents our vision for hardening in libc++. While certain parts of the RFC have been implemented in the main branch, many aren’t, and we don’t yet officially support hardening in the LLVM 17 release or on main )

Different modes provide different trade-offs between security and performance. Our current design has four modes; sorted in the order of increased security, they are:

  • unchecked — the default mode that doesn’t compromise any runtime performance to check for undefined behavior;
  • hardened — contains a minimal set of low-overhead checks deemed security-critical;
  • debug-lite — extends the hardened mode with additional low-overhead checks that are not security-critical;
  • debug — extends the debug-lite mode with checks that might impose significant overhead (for example, might change the complexity of algorithms).

(If you’re familiar with the safe mode that was added to libc++ in the LLVM 15 release, hardening modes can be seen as an extension of that work)

Each mode on the list is a superset of the previous one (but this might not be true of any new modes potentially added in the future). Of these, hardened and debug-lite modes are intended to be usable in production; for the debug mode, being usable in production is a non-goal (it is intended for testing). The hardened mode aims to be minimalistic and heavily prioritizes performance; we intend to set the bar high for any check to be enabled in the hardened mode, only enabling those checks that prevent memory safety bugs. The debug-lite mode additionally aims to catch common programming errors that aren’t directly exploitable; here, the criteria for a check to be enabled is roughly that the performance overhead is relatively low and the error is caused by user input (purely internal assertions are not enabled in the debug-lite mode). Different projects would make different trade-offs here, which is why we aim to provide two different modes.

(A note on terminology: a hardening mode is a name for any of the modes described above; e.g. the unchecked mode is one of the hardening modes. On the other hand, the hardened mode is the specific mode that’s in-between the unchecked mode and the debug-lite mode)

Categories

(Note: we plan to audit which assertions fall into which categories with our internal security team)

Internally, all the checks in libc++ are going to be put into several broad categories . We haven’t finalized the design of those categories yet, but by and large, the categories are defined by the kind of error they prevent. Hardening modes differ between each other by which categories of checks they enable. The unchecked mode disables all checks, and conversely the debug mode enables all checks.

The current categories are:

  • “valid-input-range” — checks that a range given to a standard library function as input is valid, where “valid” means that the end iterator is reachable from the begin iterator, and both iterators refer to the same container. The range may be expressed as an iterator pair, an iterator and a sentinel, an iterator and a count, or an std::range. This check prevents out-of-bounds accesses and as such is enabled in the hardened mode (and consequently the debug-lite and debug modes as they are supersets);
  • “valid-element-access” — checks that any attempt to access a container element, whether through the container object or through an iterator, is valid and does not go out of bounds or otherwise access a non-existent element. Types like optional and function are considered one-element containers for the purposes of this check. As this check prevents out-of-bounds accesses, it is also enabled in the hardened mode and above.
  • “non-null-argument” — checks that a given pointer argument is not null. On most platforms dereferencing a null pointer at runtime deterministically halts the program (assuming the call, being undefined behavior, is not elided by the compiler), so by default this category is not enabled in the hardened mode; it is enabled in the debug-lite and debug modes. We may consider exploring ways to detect platforms where that’s not the case and making this category a part of the hardened mode on those platforms.
  • “non-overlapping-ranges” — for functions that take several non-overlapping ranges as arguments, checks that the given ranges indeed do not overlap. Enabled in the debug-lite mode and above. This check is not enabled in the hardened mode because failing it may lead to the algorithm producing incorrect results but not to an out-of-bounds access or other kinds of direct security vulnerabilities.
  • “compatible-allocator” — checks any operations that exchange nodes between containers to make sure the containers have compatible allocators. Enabled in the debug-lite mode and above. This check is not enabled in the hardened mode because it does not lead to a direct security vulnerability, even though it can lead to correctness issues.
  • “valid-comparator” — for algorithms that take a comparator, checks that the given comparator satisfies the requirements of the function (e.g. provides strict weak ordering). This can prevent correctness issues where the algorithm would produce incorrect results; however, this check has a significant runtime penalty, thus it is only enabled in the debug mode.
  • “internal” — internal libc++ checks that aim to catch bugs in the libc++ implementation. These are only enabled in the debug mode.

Additionally, the debug mode randomizes the output of certain algorithms (within the range of possible valid outputs) to prevent users from accidentally relying on unspecified behavior.

To quickly illustrate how the hardening modes relate to each other, here is a table:

Category Hardened Debug-lite Debug
valid-input-range :white_check_mark: :white_check_mark: :white_check_mark:
valid-element-access :white_check_mark: :white_check_mark: :white_check_mark:
non-null-argument :white_check_mark: :white_check_mark:
non-overlapping-ranges :white_check_mark: :white_check_mark:
compatible-allocator :white_check_mark: :white_check_mark:
valid-comparator :white_check_mark:
internal :white_check_mark:

Essentially any bug could, under certain circumstances, indirectly lead to an exploitable security vulnerability (by playing a role in vulnerability chaining). The hardened mode aims to prevent only bugs which directly compromise the memory safety of the program. An out-of-bounds write satisfies the criteria, but a null pointer dereference usually does not, as most platforms deterministically stop programs that try to dereference a null pointer at runtime.

Termination

When an unsuccessful check is triggered, the program is terminated via a call to __builtin_trap ; the intent is to turn undefined behavior into a guaranteed program termination and make it terminate as fast as possible (faster than a call to std::abort , which has other security problems as well). In the future, we will explore ways to provide an additional error message and potentially to allow the behavior to be customized.

Note that we will not be using the existing __libcpp_verbose_abort mechanism because its semantics are essentially to call std::abort . __libcpp_verbose_abort will still be supported and used for cases where we terminate for reasons other than encountering undefined behavior (e.g. when an exception is thrown under -fno-exceptions , and in the future from libc++abi when various runtime operations fail).

ABI considerations

Some checks require storing additional information in standard library classes — for example, to be able to check whether an iterator dereference is valid, the iterator object needs to somehow store a reference to the corresponding container. This requires an ABI break.

In the proposed design, breaking the ABI is orthogonal to setting a hardening mode. The rationale for this design stems from the observation that the ABI configuration is a property of the platform and is set by the vendor whereas the hardening mode is a property of an application and is set by the user (even though vendors can set the default hardening mode). The ABI is a property of the platform because in general every component built on the platform has to be ABI-compatible. If we were to provide e.g. a “hardened-abi-breaking” mode, it would give users an easy way to unintentionally build their application with an ABI that’s incompatible with the rest of the platform, which in almost all circumstances should be avoided. Moreover, since there will be several independent ABI-breaking settings, this would either create a combinatorial explosion of ABI modes or disallow mixing-and-matching different ABI settings (for example, it might make sense to enable bounded iterators for constant-sized containers such as std::array but not for variable-sized containers such as std::vector , but that would be impossible if the only available modes were “hardened-abi-stable” and “hardened-abi-breaking”).

ABI-breaking changes, such as enabling container-aware iterators, are controlled by a separate set of macros that are grouped together with other ABI macros (which are unrelated to hardening). Enabling a hardening mode doesn’t affect the ABI; rather, the hardening mode will enable whichever checks are possible within the current ABI configuration. For example, enabling the hardened mode will always enable the “valid-element-access” checks in std::span::operator[] (because those don’t depend on the ABI configuration), but will only enable “valid-element-access” checks in std::span::iterator::operator* if container-aware iterators for std::span are enabled in the ABI configuration (in this case, the relevant macro is _LIBCPP_ABI_BOUNDED_ITERATORS ).

Enabling hardening

At the platform level, vendors can control the default hardening mode via a CMake variable. At the application level, the hardening mode can be overridden by users via either a compiler flag or a macro.

  • The default hardening mode can be set by vendors via the CMake variable LIBCXX_HARDENING_MODE with possible values of unchecked, hardened, debug_lite and debug.
  • The preferred way to set the hardening mode at the application level is via the compiler flag -flibc++-hardening=<mode> with possible values of unchecked, hardened, debug_lite and debug (same values as the CMake variable).
  • In addition to the compiler flag, the hardening mode can be configured using the macro _LIBCPP_HARDENING_MODEwith possible values:
    • _LIBCPP_HARDENING_MODE_UNCHECKED
    • _LIBCPP_HARDENING_MODE_HARDENED
    • _LIBCPP_HARDENING_MODE_DEBUG_LITE
    • _LIBCPP_HARDENING_MODE_DEBUG

The exact numeric values of these macros are unspecified and deliberately not ordered to prevent users from relying on implementation details.

-flibc++-hardening and _LIBCPP_HARDENING_MODE are mutually exclusive: when compiling with -flibc++-hardening , attempting to define _LIBCPP_HARDENING_MODE will result in an error.

GCC compatibility

The _LIBCPP_HARDENING_MODE macro allows enabling hardening in libc++ when compiling with the GCC compiler where the proposed -flibc++-hardening Clang flag will not be available.

Additionally, GCC has recently introduced the -fhardened flag that enables hardening in libstdc++. We plan to explore making libc++ honor that flag when compiling under GCC (it will likely enable the hardened mode) as well as adding the -fhardened flag to Clang. While the exact semantics of the -fhardened flag will necessarily differ between libc++ and libstdc++, we believe that having some broad compatibility will still be beneficial.

Configuring hardening on a per-TU basis

The hardening mode can be overridden on a per-TU basis by compiling the TU with the -flibc++-hardening flag or the _LIBCPP_HARDENING_MODE macro defined to a different value from the rest of the application. This would allow, for example, disabling checks for performance-critical parts of the code.

Note that the ability to select the hardening mode on a per-TU basis has ODR implications. However, we can use ABI tags to ensure that inline functions have a different mangling based on the hardening mode, thus avoiding ODR violations. This mechanism only covers functions defined inline — the functions compiled inside the dylib will still use the hardening mode that the library was configured with by the vendor, and the value of _LIBCPP_HARDENING_MODE set by the user won’t be respected. However, the vast majority of functions in the standard library are defined inline, so that should not be seen as a significant limitation.

Rollout

We aim to first make hardening modes available in the LLVM 18 release, with no breaking changes. LLVM 19 and 20 will contain breaking changes. Proposed timeline:

  • LLVM 18: first release that supports hardening modes and ways to enable them as described in the RFC.
    • The safe mode (available since the LLVM 15 release) is still supported; the release notes will mention that projects using the safe mode have to transition to use the hardened mode or the debug-lite mode instead (debug-lite is the rough equivalent of the old safe mode).
    • A few checks that used to be in the safe mode might become excluded (internally, safe will be mapped to debug-lite). In LLVM 17, the safe mode contains every check that isn’t explicitly marked as debug-only, but finer-grained categorization might allow trimming it down further.
    • The safe mode will no longer use __libcpp_verbose_abort when a check fails (__builtin_trap will be used instead). Overriding __libcpp_verbose_abort will no longer have an effect on the behavior of the safemode.
    • The meaning of the debug mode will change. The legacy debug mode has been removed in LLVM 17. The new debug mode that is part of hardening will be enabled using the mechanisms explained in the RFC and will function differently (e.g. it won’t require a global database).
  • LLVM 19: the safe mode will be deprecated. The LIBCXX_ENABLE_ASSERTIONS CMake variable and the _LIBCPP_ENABLE_ASSERTIONS macro will be deprecated (with a warning) and users will be given a message to migrate to the hardened mode or the debug-lite mode instead.
  • LLVM 20: the safe mode will be removed along with the associated macros and the CMake variable.

Future work

FAQ

  • Why is the safe mode being replaced with the debug-lite mode?
    • There are several issues with the safe mode:
      • The set of assertions enabled in this mode is not well-curated — it essentially consists of everything except the most heavyweight debug assertions. This could prevent many projects from adopting it. In fact, the safe mode was always meant to be a stepping stone for finer-grained modes like this RFC.
      • While it makes the application safer, the safe mode does not to attempt to prevent all potentially unsafe uses of the standard library, making the name problematic. “Safe” is a very tempting name, and using that name would both fail to deliver its the promises and also be more tempting to use than the hardened mode, which is not what we want to recommend.
  • Why is the mode named debug-lite if it’s intended to be usable in production?
    • It is arguably somewhat counter-intuitive; however, we see the debug-lite mode as a trimmed down, more performant debug mode rather than an extended hardened mode. The key distinction here is that the hardenedmode focuses on security whereas the debug mode focuses on validity. The two debug modes (debug and debug-lite) focus on finding logic issues (of which security issues are a subset of) with different tradeoffs between coverage and performance. The hardened mode, on the other hand, focuses on security-critical issues while heavily prioritizing performance.
  • Both the hardened mode and the debug-lite mode are intended to be usable in production. Which one would you recommend by default?
    • We would recommend to almost every project to use the hardened mode in production (perhaps with opt-outs for performance-critical parts of the code). It is designed to keep the performance penalty minimal and only contains checks which prevent critical security vulnerabilities. The debug-lite mode is intended for projects that are actively seeking to prevent as many general logic issues in production as possible and are okay to trade off some additional performance for that goal.
  • Why doesn’t the RFC provide a way for projects to select individual categories of assertions to enable?
    • This would severely limit our ability to change or extend the categories, as well as make the whole model lower-level and harder to understand — we are going for simplicity over unbounded configurability.
    • We see each mode as representing some fundamental, generally useful concept, not just a collection of largely unrelated checks. We are open to add new modes in the future as long as they represent some well-defined abstraction, are generally useful and sufficiently different from the existing modes.
  • Why aren’t checks for null pointers a part of the hardened mode?
    • Most platforms have a guard virtual memory region starting at address 0, so a stray memory access close to 0 is guaranteed to trap and doesn’t compromise the memory safety. The hardened mode aims to only enable security-critical checks. We might explore adding null pointer checks to the hardened mode on platforms where trapping is not guaranteed if we can determine that at compile time.
  • Making ABI configuration separate from enabling a hardening mode means that, for example, accessing a spanelement through operator[] is always checked if hardening is enabled whereas the same access through an iterator might or might not be checked depending on the ABI configuration by the vendor. Wouldn’t that create confusion for the users?
    • There is some potential for confusion there, but we believe the alternatives are worse. ABI is a property of the platform and should not be changed by the user; moreover, lumping together all possible combinations of different ABI settings (which are independent from each other) and hardening modes would result in a combinatorial explosion. Also, users have the option of using their own copy of libc++ and thus essentially becoming their own vendor. That said, we are open to explore the possibility of shipping multiple ABI configurations of the library in the future.
5 Likes

Hi folks,

It seems like people are pretty quiet about this! Does it means that people are happy with the direction and don’t have much to add? Or did it go under everyone’s radar because of the LLVM Dev Meeting?

We reached out to people on various forums like the Clang -Wunsafe-buffer thread, on Discord and informally in person at the LLVM Dev Meeting. People seemed really interested in this effort and we had good feedback, however nothing has manifested here so far.

What I suggest is that we will keep waiting for feedback for approximately one week and if nothing is manifested here, we will start proceeding as described above, and people can give feedback as we go. After all, the current state of main is some kind of in-between between the old Safe mode and this RFC, and it would be great to get out of that state in time for LLVM 18.

Cheers,

Louis

1 Like

In Chromium we’re already very happy users of what was formerly the safe mode, now debug-lite. (Though I think we still need to double check that we’re not losing any important checks.)

Introducing more levels sounds good to me since it will make this useful for more projects with varying needs for hardening.

The ability to set this per-TU (and avoiding the ODR issues) sounds pretty cool, I can see that being useful.

We’re currently overriding __libcpp_verbose_abort. Defining how we crash is useful since it affects how we can then process crash reports from the field. Do I understand correctly that the ability to do this will be removed?

1 Like

We currently use, via config files, -D_LIBCPP_ENABLE_ASSERTIONS for < LLVM 18 and we use -D_LIBCPP_ENABLE_HARDENED_MODE for >= LLVM 18 for Gentoo Hardened.

The proposal here sounds great - even though we’re free to control the level on a distribution level, breaking ABI isn’t really within our gift and not something we’re interested in, so seeing work on trying to do as-best-as-possible within ABI constraints is promising.

In particular, the range here would let us consider enabling some checks unconditionally (in vanilla Gentoo) while reserving some which may impact performance just for Gentoo Hardened.

Supporting -fhardened is also rather welcome.

So, all in all, this sounds great and I’m looking forward to seeing it happening.

Aside: The last-minute change there for LLVM 17 wasn’t great, especially given it wasn’t tested in an RC (see ⚙ D159171 [libc++][hardening] Remove hardening from release notes, undeprecate safe mode), but it is what it is. But it’d be very much appreciated if something like that could be avoided in future. The relevant vendors group wasn’t even CC’d to the patch :frowning:

I believe it’s a good direction and important work.

I don’t think it’s a good name, it should be clear what can be safely used and what shouldn’t be used on production. I saw many suggestions of using ASan as a hardening tool, which is not a good choice. Use of a confusing naming may contribute to more mistakes like that in future, and therefore I suggest naming which does not suggest use in a development environment.

Will it be a public discussion? I already commented on Phabricator and would be happy to jump into future conversations, if possible.

Do you have a list of all of them?

I’m experimenting with two implementations right now and you are right here.
Additional hardened libraries should be covered and it’s not a problem.

I should suggest one soon.


I also didn’t find detailed rationale behind the last minute revert in LLVM17, only vague arguments. Could you share more details about it? In future, I would like to focus my tests of the hardening mode on potential issues.

1 Like

Thank you for the feedback, everyone!

Our initial idea was to not have it in the initial release but potentially add later if there is significant demand for it. Can you explain a bit more how you’re currently using this feature? If this results in a significant downgrade on your side, we can explore keeping this from the start.

I’m not very happy with this name either. Do you have a specific suggestion in mind? Another naming scheme we considered was something like “hardening=(none|fast|strict|debug)”, but that comes with its own downsides.

We will definitely post the tentative classification in this thread so that we can discuss it publicly before finalizing it.

Unfortunately, no. At the moment, the only ABI-breaking category of checks that we have is enabling bounded iterators for fixed-size containers. It seems very likely we will have a separate macro for enabling bounded iterators in resizable containers. I don’t have a concrete plan for the ABI-breaking checks we are going to add beyond that (and in which order), and I wanted to keep the RFC focused on short- and mid-term goals.

Thank you! Looking forward to it.

I’m really sorry about it. The motivation was that as we were merging the patches, we kept getting feedback that led to pretty significant design changes. In particular, we originally envisioned two modes (hardened and debug), but some early feedback (in particular from Chromium, but also other projects) made us realize that there are important use cases for what is currently the safe mode (and is called debug-lite in the RFC). As this was pretty close to the release, I felt that the current design wasn’t yet stable enough to ship, which led to the patch. Once again, really sorry about the disruption it caused.

2 Likes

Thank you for your answer!

Unfortunately, nothing I would be convinced of. As long as it’s clear what is designed for production and what isn’t - I’m ok with it.

I like it more. I lack a word that would describe third level better, but instead of strict, maybe something referring to correctness/integrity/validity to described what is inside may be considered? But none/fast/strict/debug is totally ok, as well as none/basic/extended/debug.

And it solves the problem not mentioned before:

that may lead to some confusion between hardening and hardened for those not following the topic carefully, I don’t think it’s a big problem, but that second naming scheme doesn’t have an issue like that.

1 Like

We’ve set up __libcpp_verbose_abort to call our ImmediateCrash function, which we use for all “check” failures in production builds. That one uses a hand picked instruction sequence to crash in the best way depending on the platform.

It also tries to ensure that multiple crash sequences will not be merged. For example, in:

int f(int x) {
    if (x == 4) {
        __builtin_trap();
    } else if (x == 7) {
        __builtin_trap();
    }
    return 42;
}

Clang will merge the two traps into a single basic block, making it impossible to disambiguate which branch a crash comes from based on the PC.

See the comments in https://source.chromium.org/chromium/chromium/src/+/main:base/immediate_crash.h for details.

1 Like

Thank you, that’s much appreciated. We look forward to using the new proposal!

1 Like

We’ve seen significant binary size benefits (-30% size regression) currently in 17.x by also passing along -D_LIBCPP_AVAILABILITY_HAS_NO_VERBOSE_ABORT to our compile commands. I think defaulting with __builtin_trap is the way to go.

I ran into a similar issue with BoundsSan. https://github.com/llvm/llvm-project/pull/65972 was my attempt to prevent clang from optimizing the checks into a single BB, I wonder if it could be useful here as well :slight_smile:

2 Likes

Quick update on naming: based on the feedback we received so far, our new proposal for the names of hardening modes is none / fast / extensive / debug. Using the compiler flag, this would look like:

  • libc++-hardening-mode=none;
  • libc++-hardening-mode=fast;
  • libc++-hardening-mode=extensive;
  • libc++-hardening-mode=debug.

Similarly, with the CMake variable:

  • LIBCXX_HARDENING_MODE=none;
  • LIBCXX_HARDENING_MODE=fast;
  • LIBCXX_HARDENING_MODE=extensive;
  • LIBCXX_HARDENING_MODE=debug.

And with the macro:

  • _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_NONE;
  • _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST;
  • _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE;
  • _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG.

none is what is called the unchecked mode in the RFC (in fact, we might still sometimes call it “unchecked” in the docs because “none mode” doesn’t sound right), fast corresponds to hardened and extensive to debug-lite. debug remains unchanged.

I have a patch in flight that uses the new naming scheme: [libc++][hardening] Add tests for the hardened mode with ABI breaks. by var-const · Pull Request #71020 · llvm/llvm-project · GitHub

Please let us know what you think!

1 Like

Will these names correspond to multilibs built in the “normal” release distribution via llvm/utils/release/test-release.sh?

The [[clang::nomerge]] attribute exists to block this optimization and improve the fidelity of trap source locations. If this is our main concern, can we apply the [[clang::nomerge]] attribute to the trap call sites in libc++? Depending on how much of a size increase it causes, its usage could be controlled by a configuration macro, if the overhead is worth the maintenance cost of such an option.

2 Likes

These modes do not affect the ABI of the library, so there are no multilibs for these modes!

I’d say the main concern is maintaining control of how the asserts are handled: what instruction sequence is used for crashing, if they get merged or not, what the stack traces look like in our crash reporting, maybe we want to do something different in debug builds, etc.

Losing the __libcpp_verbose_abort customization point would be a regression in that regard.

1 Like

Oh gee, sorry, I suppose I didn’t read closely enough. Thanks for clarifying and that’s great news.

This is a good direction. Thanks for this important work. We have seen lot of OOB read/write vulnerabilities in the past due to container indexing. This work will help prevent those vulnerabilities altogether. Looking forward to seeing these changes!

Just want to add that this only works if the trapping instruction is a function. For builtin llvm ubsantrap’s, they end up being instructions (udf’s or brk’s on arm) and lose the attributes and still get merged. I worked around this by changing the instruction per check emitted in Modify BoundsSan to improve debuggability by oskarwirga · Pull Request #65972 · llvm/llvm-project · GitHub

2 Likes

Thanks a lot for explaining the use cases for overriding the abort mechanism! From this feedback, it’s clear to me that this is something that we need to keep. We are not very happy with the current way overriding works and would like to rethink that.

@hansw2000 (and everyone else who customizes verbose_abort): which way do you currently use to override __libcpp_verbose_abort? Do you do it at compile time or link time?

That’s definitely better. I’m new here, but this looks like a good line of development.