Technology4 min read

Attention people who think UB is fine

By · Published by Everything Blog

In short

Some people think undefined behavior in C is fine, but it’s not true. UB can lead to unexpected behavior, crashes, and debugging issues. The code provided demonstrates that dereferencing a null pointer and using std::unreachable() can result in unpredictable outcomes, including crashes and incorrect behavior.

Key points

  • UB can lead to crashes and incorrect behavior.: UB can lead to crashes and incorrect behavior.
  • Dereferencing a null pointer can result in unpredictable outcomes.: Dereferencing a null pointer can result in unpredictable outcomes.

Sources checked by our analysis pipeline: 1

After my post on undefined behavior in C some people insisted that it’s demonstrably fine, we know what the code will do, and at worst we’ll get a segfault.

This is false.

Here’s an example.

C code

#include<utility>

// Let's pretend this is a memory mapped device controlling

// motors and gears and stuff.

volatile int device_command = 0;

void serve_hot_chocolate() {

device_command = 42;

// Oh no, we spilled some hot chocolate, invoking UB.

//

// The definition of `std::unreachable()` is:

// Invokes undefined behavior at a given point.

std::unreachable();

}

void explode() {

device_command = 3;

}

Example assembly output

With GCC 16.2 with -O1

.

"serve_hot_chocolate()":

mov DWORD PTR "device_command"[rip], 42

"explode()":

mov DWORD PTR "device_command"[rip], 3

ret

"device_command":

.zero 4

Ruh roh. Exploding chocolate! The function explode()

will be executed, even

though it was never called!

Another example: dereferencing a null pointer

Some people insisted that dereferencing a null pointer is fine. It’ll just try to access address zero! And these people supposedly know what happens when you try: if nothing is mapped there, you’ll get a segfault.

Again, not true.

Let’s say we change std::unreachable()

to int* ptr = nullptr; *ptr = 99;

in

the previous example. Now we get this:

"serve_hot_chocolate()":

mov DWORD PTR "device_command"[rip], 42

mov DWORD PTR ds:0, 0

ud2

Curious, eh? First it writes zero, not 99, to memory address zero. Then it crashes the program using an invalid instruction. The number 99 never appears in the generated program. But the null pointer write does. Presumably the null pointer write is done in order to improve debugger error messages.

Now let’s check clang with godbolt:

"serve_hot_chocolate()":

mov dword ptr [rip + "device_command"], 42

"explode()":

mov dword ptr [rip + "device_command"], 3

ret

"device_command":

.long 0

Again we get exploding chocolate.

Let’s do some pointer arithmetic on the null pointer

Ok, so what happens if we add ptr += 10

. That’s UB for three reasons:

- using a null pointer for anything other than comparison against other pointers,

- even if an object would be valid at the null pointer (which it’s not), creating a pointer that points outside that object, or if an array, beyond one-past the array, is UB,

- even if that were valid (which it’s not), we then dereference the address.

Let’s try it.

"serve_hot_chocolate":

mov DWORD PTR "device_command"[rip], 42

mov DWORD PTR ds:40, 99

ret

Wow. Now it did what someone naively would expect, taking the code at face value. Lucky.

Why did GCC (and clang, by the way) not detect that *(ptr + 10) = 99

is

equally UB as *ptr = 99

, and do the same thing?

Wrong question. No compiler is obligated to “detect” UB at all. Looks to me like GCC and clang, if we want to anthropomorphize them, saw “oh, null pointer dereference? Well that can’t happen, so I’ll stop emitting instructions for this function, to save on the binary size”. Because of the halting problem, it cannot in the general case know if the code will ever be executed.

So what does UB do?

Anything. I have now demonstrated three things UB can do, when UB was triggered in the same place:

- Run whatever happens to be next in memory (exploding chocolate).

- Store a (for all intents and purposes) random value at address zero (wrote zero, not 99), and then deliberately crash.

- Do what one would naively expect: write the value to the address seen in the code at face value.

And to think that some people don’t believe in nasal demons.

Original source: blog.habets.se

Technology