TRT
Sign in

Foundations · Module 3 · Lesson 2 of 10

Out-of-Bounds Access and Undefined Behavior

See what happens in memory when an index leaves its array, learn what undefined behavior means and why compilers may exploit it, and use warnings and sanitizers to catch these bugs.

FreeAbout 8 minutes

By the end you can

  • Explain what happens in memory when an index goes past the end of an array
  • Define undefined behavior and distinguish it from implementation-defined behavior
  • Explain why a program that triggers undefined behavior can change under optimization
  • Choose the flags and tools that detect out-of-bounds access

Builds on Arrays: Contiguous Elements and Indexing.

01 · A question first

Write one element past the end of an array and Python raises an error, but C++ stays silent. The program might crash, might print a wrong value three functions later, or might work perfectly until the day you show it to someone. Why does the language allow this, and how do you catch it?

Make a guess, then step through the trace below to check it.

02 · Watch it run

A write that lands on the wrong variablestep 1 of 5
int a[3] = {1, 2, 3};int guard = 7;a[3] = 0;std::cout << guard;
  1. int 0a[0]: 1
  2. int 1a[1]: 2
  3. int 2a[2]: 3
  4. int 3guard: 7

Here is one possible layout: the three elements of a and, directly after them, the int guard holding 7. The cells are ints this time, not bytes. The language does not promise this layout, as lesson 3 said, but compilers often produce something like it, and it makes the damage visible.

Machine work: a and guard sit next to each other in the stack frame

03 · The idea

Start with the problem

Step 4 of the last lesson read an element that did not exist, and nothing complained. In Python or Java the same mistake raises an exception at once. In C++ the program carries on. Take the smallest possible example, an array of three elements and a write to index 3:

cpp
int a[3] = {1, 2, 3};
a[3] = 0;

What does this do? The honest answer is "anything", and the language has a precise name for that: undefined behavior. This lesson explains what it means, why C++ allows it, and how to find it before it finds you.

How the idea works

Out of bounds. For an array of n elements the valid indexes are 0 to n - 1. An access outside that range is out of bounds. A read returns whatever bytes sit at that address: a neighboring variable, padding, stack data, or, if the address is far away, unmapped memory, which stops the program with a segmentation fault. A write overwrites whatever is there: a neighbor, as in step 2 of the trace, saved registers, or even the return address from lesson 6, which is how stack smashing attacks work.

What undefined behavior means. The standard lists operations for which it places no requirements on the program. Out-of-bounds access is one. Others you have met are signed integer overflow, reading an uninitialized variable, and dividing by zero. Later lessons add dereferencing a null pointer and using memory after it was freed. This is not "an unpredictable result". It means the program is no longer covered by any promise, so every outcome is allowed: a crash, a wrong value, an apparently correct run, or behavior that changes with the compiler and its flags.

Two weaker categories. Do not confuse undefined behavior with two other terms. Implementation-defined behavior is a choice that your compiler must document, such as the size of an int. Unspecified behavior is one of several valid outcomes with no documentation required, such as which of two function arguments is evaluated first. Undefined behavior is different in kind: the program has no valid outcomes at all.

Why the language allows it. C++ follows the rule that you do not pay for what you do not use. A bounds check on every access would cost a comparison and a branch each time, which is unacceptable in code that touches arrays billions of times. So the language trusts you. It also lets the compiler assume that undefined behavior never happens, and optimize on that basis. A well-known case, from lesson 4: because signed overflow is undefined, the compiler may turn x + 1 > x into true for every x, even when x is the largest int.

Symptoms. The best symptom is an immediate crash. The worse ones are silent corruption, a wrong value far from the cause (steps 3 and 4), and the program that works on your machine and in a debug build and fails somewhere else or with -O2.

Finding it. Use several layers:

  • Warnings: -Wall -Wextra catch some constant out-of-range indexes, and GCC's -Warray-bounds does so more often with optimization on.
  • Sanitizers: AddressSanitizer reports the first bad memory access with a line number, and UndefinedBehaviorSanitizer reports things like signed overflow.
  • Checked access: std::vector::at(i) throws an exception on a bad index, and the next lesson covers it. In libstdc++, defining _GLIBCXX_ASSERTIONS adds cheap bounds checks to the ordinary operator[].
bash
g++ -std=c++20 -Wall -Wextra -g -fsanitize=address,undefined main.cpp -o main

A report from the sanitizer has this shape:

text
ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 4 at 0x7ffc2b3a9e5c thread T0
    #0 0x55d1c3 in main main.cpp:3

Why it is correct

The rule is a contract between you and the compiler. The compiler promises that the code it generates behaves as the language specifies, but only for programs that never trigger undefined behavior. For such programs it may reorder, merge and delete code freely, which is where much of C++'s speed comes from. A program that does trigger it is outside the contract, and the compiler is not required to preserve anything. That is why "it worked when I tested it" proves nothing: the test may have been lucky with the layout.

The alternative, checking everything all the time, is available when you want it. It is not the default. Standard library vendors now offer hardened modes that turn some of these cases into controlled failures, and safer container access is a choice you can make everywhere.

What it costs

The bugs. Undefined behavior is expensive to debug, because the symptom is detached from the cause. It is also expensive to leave alone: reports from large software vendors put memory-safety bugs at around 70 percent of their serious security vulnerabilities. The Morris worm of 1988 used a buffer overflow, and the Heartbleed bug of 2014 was an out-of-bounds read.

The checks. A bounds check costs a comparison and a well-predicted branch, often a few percent of run time in ordinary code. AddressSanitizer is heavier, typically about twice as slow and using two to three times the memory, so you use it in testing and in continuous integration, not in the shipped program.

Pitfalls and variations

Trusting a successful run. A program that runs correctly has not proved that it is free of undefined behavior.

Off-by-one loops. The loop bound of lesson 4 is the most common source: i <= n instead of i < n.

Negative and computed indexes. a[-1] is out of bounds, and so is an index computed from input that was never validated. Check 0 <= i && i < n before using a value from outside the program, as in module 1's input lesson.

Testing in one build only. Undefined behavior can behave differently at -O0 and -O2, so run your tests in both, and with sanitizers on.

Strings. Reading past the terminating zero of a string is an out-of-bounds read. Lesson 5 returns to it.

Variations. std::array::at and std::vector::at give checked access. std::span, from C++20, carries a pointer and a size together. Static analyzers such as clang-tidy and cppcheck find some of these bugs without running the program, and fuzzers feed random input to a sanitized build to find the rest.

Where it is used

This knowledge is the foundation of safe systems programming. Teams run sanitizers in their continuous integration, fuzz their parsers, and enable the library's hardened modes in production. The same understanding explains why newer languages that check bounds by default are attractive for some projects, and why C++ projects still depend on discipline and tooling.

The next lesson, "std::array and std::vector", introduces containers that know their own size and can offer checked access, while keeping the contiguous layout you now understand.

04 · Prove it

Question 1 of 4 · answer from memory

`int a[3] = {1, 2, 3};` is followed by `a[3] = 0;`. Which statement is true?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save