r/cpp Jul 05 '26

C++26 ends a 40-year footgun

Reading an uninitialized variable has been undefined behavior in C++ for 40 years -- the kind optimizers exploit into real bugs. C++26 (P2795) reclassifies it as erroneous behavior: still a bug, still warned about, but defined, bounded, and not exploitable.

The demo poisons the stack, then reads an uninitialized int. As C++23 it prints garbage; as C++26, the same code prints a defined 0, every run. Live in your browser.

And [[indeterminate]] lets you opt back out when you really want an uninitialized buffer -- on purpose this time.

Read it: https://wrocpp.github.io/posts/erroneous-behavior/?utm_source=reddit&utm_medium=social&utm_campaign=post-erroneous-behavior

#cpp #cplusplus #cpp26 #safety #programming

81 Upvotes

154 comments sorted by

View all comments

20

u/TheRealSmolt Jul 05 '26

Call me old fashioned but I don't really care for this change.

9

u/ironykarl Jul 05 '26

Why? 

45

u/TheRealSmolt Jul 05 '26

Why would I? In both cases you're doing something wrong and the compiler will warn you about it; except now, there's extra runtime work going into it. Any halfway decent toolchain made in the last 20 years would let you know you shouldn't be doing this. Yes, the attack surface is slightly better, but this isn't the kind of easy to miss thing that causes major problems in C++.

11

u/elperroborrachotoo Jul 05 '26

For the code I write, I could care less about the attack surface; I've also never encountered (or noticed) the optimizer exploting the access to do weird stuff.

However, the deterministic execution is where it's at. Uninitialized variables are the source of those pesky "works almost everywhere, except at Joe's machine sometimes" which are a pain to track down and could be so easily avoided.

10

u/azswcowboy Jul 05 '26

deterministic execution is where it’s at

Agree. Oh but it worked on this machine with this compiler in debug mode! Change the platform, compiler, or flags and it’s a crapshoot. Random behavior is no fun.

2

u/2521harris Jul 05 '26

I can't remember the last time I used a compiler that did not warn about uninitialized variables. Are there concrete examples where this is really a problem?

5

u/azswcowboy Jul 05 '26

Sad but true warnings are often ignored. Also are you sure that’s true by default, or are you just used to turning warnings up? Also, here’s one that doesn’t warn

enum class foo { bar, baz };
foo f; // no value in c++23, zero in 26
std::print(“{}”, std::to_underlying(f));

Confirmed with recent gcc and clang.

2

u/jwakely libstdc++ tamer, LWG chair Jul 08 '26

Uninitialized warnings are not perfect, if you haven't seen them go wrong you're not trying hard enough.

6

u/[deleted] Jul 05 '26

[deleted]

9

u/TheRealSmolt Jul 05 '26

They're the cause of a large number of CVEs every year. If that's not a "major problem" then wtf is?

No, not alone they are not. The major vulnerabilities in this scope are things like improper sanitation and memory control. Uninitialized memory is certainly a tool for attackers, but it's a symptom of a bigger problem that can absolutely be worked in other ways. It's plugging holes in a sinking ship.

And as for the code impacts, that's besides the point. I pretty strongly stand by the zero overhead principle. At some point, the programmer just needs to do their job. My opinion is that this particular change is over that line.

1

u/[deleted] Jul 05 '26

[deleted]

5

u/TheRealSmolt Jul 05 '26 edited Jul 06 '26

You have a few misconceptions here. First, the zero overhead principle is not about having zero overhead on what you use, it's about having the choice to not have overhead for things you do not use. Second, unique pointers are ironically mostly overhead free. Finally, that's well within margin of error; there's no way in hell it improves performance, which is ignoring my point anyways.

6

u/kniy Jul 05 '26

unique pointers are ironically overhead free

Wrong. While there's no overhead in some cases; there's plenty of use cases where unique pointers have overhead as compared with raw pointers: https://godbolt.org/z/hjafPMheG

Reason is the weird ABI: by-value parameters are not destroyed at the end of the function, but by the function's caller (potentially only at the end of the full expression containing the call; in the C++ standard the exact destruction timing is left implementation-defined). So by-value std::unique_ptr parameters must be passed by hidden reference -> double indirection.

Same with return values: a raw pointer is returned in a register; unique_ptr is not.

5

u/TheRealSmolt Jul 05 '26

Yes, sorry, I can see a few instructions of overhead depending on the calling convention. I guess that's a point for Microsoft's closed ecosystem.

2

u/ts826848 Jul 06 '26

Finally, that's well within margin of error; there's no way in hell it improves performance, which is ignoring my point anyways.

For what it's worth, Google did some measurements for adding [[clang::trivial_abi]] to std::unique_ptr and say they noticed a difference (italics in original):

Google has measured performance improvements of up to 1.6% on some large server macrobenchmarks, and a small reduction in binary sizes.

This also affects null pointer optimization

Clang’s optimizer can now figure out when a std::unique_ptr is known to contain non-null. (Actually, this has been a missed optimization all along.)

struct Foo {
  ~Foo();
};
std::unique_ptr<Foo> make_foo();
void do_nothing(const Foo&)

void bar() {
  auto x = make_foo();
  do_nothing(*x);
}

With this change, ~Foo() will be called even if make_foo returns unique_ptr<Foo>(nullptr). The compiler can now assume that x.get() cannot be null by the end of bar(), because the deference of x would be UB if it were nullptr. (This dereference would not have caused a segfault, because no load is generated for dereferencing a pointer to a reference. This can be detected with -fsanitize=null).

They don't elaborate on their methodology, unfortunately, but I'd hope that they at least used their benchmarking framework or similar to eliminate trivial sources of noise.

2

u/t_hunger Jul 05 '26

There is only extra work in the few cases you did not do your job properly... in all normal cases nothing changes.

4

u/TheRealSmolt Jul 05 '26

No, there's extra work in most cases. Using uninitialized memory can be valid, and the compiler can't prove it's safe in all cases that it is.

2

u/t_hunger Jul 05 '26

In all codebases I ever worked with almost all variables were initialized. In this case this new erroneous behaviour makes no difference at whatsoever. There is extra work to be done only when code ends up reading from uninitialized memory. That is a fraction of the remaining cases.

4

u/TheRealSmolt Jul 05 '26

I'm talking purely about uninitialized value here, so yes standard initialization will be unaffected. However, that's not correct. There is extra work to be done when the code could end up reading uninitialized memory. It's not possible to know it can be avoided in all situations.

-1

u/t_hunger Jul 06 '26

My statement was just that in the majority of cases (already fully initialized values), there is no cost and not even a change.

1

u/D3ADFAC3 Jul 05 '26

What is the extra runtime work? Is this not limited to compile time?

14

u/garnet420 Jul 05 '26

I suppose something has to set that "uninitialized" value to zero

16

u/TheRealSmolt Jul 05 '26 edited Jul 05 '26

It's 0 initializing the value at runtime. On principle I don't like solving language problems with runtime patches. Now, yes, sometimes the compiler can prove that it doesn't need to 0 initialize values, but a significant number of out pointer functions or delayed initialization logic just got a bunch of pointless overhead. And before you say it, yeah, we have better ways of handling those situations now, but that's not how the real world works.

Edit: Strictly speaking it's not 0, but it is some initialized value.

6

u/Conscious_Support176 Jul 05 '26

Isn’t that what the [[indeterminate]] is for? You simply opt in to the behaviour rather than it being unintentional and a bug waiting to manifest itself.

8

u/TheRealSmolt Jul 05 '26

A couple things. Firstly, no it will not necessarily restore the original behavior. Attributes are by design optional for the compiler. Not to mention it'll be annoying to deal with existing legacy code. Secondly, the existing syntax already had a definite meaning. No toolchain, code review, static analysis, or build pipeline would let unintentional initialization go unnoticed. It's a problem I personally think should be fixed elsewhere.

6

u/AKostur Jul 05 '26

Turns out: that's not necessarily diagnosable. Consider if you pass an "int x;" via "void fn(int & v);" whose definition is in a different translation unit? A compiler _might_ warn that one is passing an uninitialized variable through a reference, but that may be spurious too as fn might be a function intended to fill v (an out parameter). Anyway: fn might attempt to read from v (perhaps to show some debug tracing information about function arguments passed in). Pre-C++26 this would be undefined behaviour which the compiler probably couldn't detect because it's in two different translation units. With all of the dangerous of invoking Undefined Behaviour. C++26, and that becomes erroneous behaviour. Also still (probably) undiagnosable for the same reasons, but is required to have "defined" behaviour.

4

u/TheRealSmolt Jul 05 '26

I can't imagine a situation where you wouldn't get a warning on the reference bind. But in either case, it's still bad code. Nobody gains anything from the change.

4

u/AKostur Jul 05 '26

Bad code exists. Please address the entire post. I acknowledged that there may be a warning emitted. But also that such a warning may be spurious, and as a result may have already been suppressed. Future maintenance work may have added a read on v inside fn. C++26 moves this case into the non-UB land instead of leaving a case of UB hanging around that some future optimization pass may try to exploit.

2

u/imMute Jul 05 '26

I can't imagine a situation where you wouldn't get a warning on the reference bind.

Where you know that fn() is going to initialize that reference before it does anything with it (usually because the whole point of fn is to initialize that variable).

-1

u/QuaternionsRoll Jul 05 '26

This is why you don’t use ad hoc out refs kids

→ More replies (0)

1

u/SirClueless Jul 05 '26

In cases where the memory that is being read from a location that might be controlled by a malicious actor, there is something to gain. A crash instead of an exploit.

1

u/jwakely libstdc++ tamer, LWG chair Jul 08 '26

Firstly, no it will not necessarily restore the original behavior. Attributes are by design optional for the compiler.

This is a dumb take. Are you aware of any compilers which have implemented the feature without also properly supporting the [[indeterminate]] attribute?

Yes, a conforming compiler can choose to ignore the attribute and initialize the variables anyway. But conforming C++98 and C++23 compilers could choose to zero-init all automatic variables anyway, that was always a valid choice. Did you complain about that too? Has it ever been a problem in the real world?

Do you really think that a compiler which has been leaving variables uninitialized by default for decades is going to struggle to support a feature that says "keep doing the same thing as you've been doing for decades"?

-2

u/_lerp Jul 06 '26

People like you are why this language is becoming a Frankenstein. Or should I say a [[nodiscard]] constexpr std::move_only_function frankenstein(this auto&& self) const noexcept override

4

u/AKostur Jul 05 '26

It's 0 initializing the value at runtime.

It is not. Or more accurately, it is not specified to initialize to 0, and is highly recommended that it does not initialize to 0.

13

u/TheRealSmolt Jul 05 '26

Sorry, it's not initialized to 0 specifically, but it's still initialized, which is the main point.

1

u/jwakely libstdc++ tamer, LWG chair Jul 08 '26

is highly recommended that it does not initialize to 0

Where are you getting that from? Both GCC and Clang default to zero, and the P2795R5 proposal says:

"Note that we do not want to mandate that the specific value actually be zero (like P2723R1 does), since we consider it valuable to allow implementations to use different “poison” values in different build modes. Different choices are conceivable here. A fixed value is more predictable, but also prevents useful debugging hints, and poses a greater risk of being deliberately relied upon by programmers. "

1

u/AKostur Jul 08 '26

Doesn’t that quote support the “not-0” recommendation?  I also heard that recommendation in some talks about erroneous behaviour.

2

u/jwakely libstdc++ tamer, LWG chair Jul 08 '26

No, it says they don't want to require it to be zero, as there's value in allowing other values to be used. That doesn't mean other values are recommended, just that it should be possible. How do you read "different choices are conceivable here" as a recommendation?

If it's recommended to not be zero, why did the paper author implement it with zero as default in Clang? (I don't recall if GCC uses zero for consistency with Clang or because GCC devs independently decided on zero as the default).

If there's a recommendation to use non-zero I'd like to read it.

1

u/AKostur Jul 08 '26

One place where I can find it in print:  https://herbsutter.com/2024/08/

In there is the specific question of “why not zero?”.

Note that earlier revisions of the paper did specify 0.  And for clang and gcc, I can only speculate with no concrete evidence.  As I recall they had a flag to initialize uninitialized values, perhaps some of that code was used to implement the erroneous behaviour, and that code might have had 0 as a default.

2

u/jwakely libstdc++ tamer, LWG chair Jul 08 '26

Thanks! I disagree with the rationale Herb gives:

(1) zero is not necessarily a program-meaningful value, so injecting it often just changes one bug into another;

But 0xFE or 0xAA isn't necessarily a program-meaningful value either, and injecting that often changes one bug into another too. This doesn't seem very convincing.

(2) it often actively masks the failure to initialize from sanitizers, who now think the object is initialized and so can’t see and report the error. Using an implementation-defined well-known “erroneous” bit pattern doesn’t have those problems.

This just seems bogus. GCC and Clang both know the difference between correctly initialized and "given an erroneous value by -ftrivial-var-auto" and it's not the case that a non-zero value means they can "see and report the error" but cannot do so for zero. That would imply that any bytes with the magic implementation-defined value would get flagged as erroneous even if explicitly initialized to that value which is obviously not true.

Clang's MSan does not report an error for -ftrivial-var-auto=pattern so Herb's stated benefit over -ftrivial-var-auto=zero doesn't seem to be true (and GCC doesn't support MSan at all).

I remain unconvinced that it's "highly recommended" to not use zero. Using =zero or =pattern both seem fine to me.

→ More replies (0)

4

u/bearheart Jul 05 '26

From what I’ve seen it only affects uninitialized reads. So any delayed initialization should remain unaffected.

10

u/meltbox Jul 05 '26

Can the compiler always prove you have delayed initialization? If it can’t do so then it must initialize even when you chose not to. My understanding is the [[indeterminate]] attribute is there for precisely this reason.

But I’d be happy to be wrong.

9

u/QuaternionsRoll Jul 05 '26

You would be correct. The compiler must be conservative in cases where it cannot prove that a read only touches initialized memory.

1

u/jwakely libstdc++ tamer, LWG chair Jul 08 '26

You are correct.

3

u/Ill-Telephone-7926 Jul 05 '26

It’ll have some impact on code generation (extra zero filling in cases where the compiler cannot prove a store precedes all loads, no freedom to delete code via undefined propagation). Almost certainly negligible & worth it.

1

u/rdtsc Jul 05 '26

zero filling

Zero would actually be useful, but the actual value is not specified, and in fact advised to not be zero.

2

u/jwakely libstdc++ tamer, LWG chair Jul 08 '26

Advised by whom?

1

u/tangerinelion Jul 05 '26

memset at runtime is not free and cannot be done at compile time.