C lets you read and write past the end of an array without complaint. Rust refuses. That one design decision is the difference between a crash and a remote code execution vulnerability.
Imagine you're building a feature where users can select an item from a list. In Rust, if you try to pull index 10 out of a 5-element array, the program stops dead in its tracks:
// src/main.rs
use std::io;
fn main() {
let a = [1, 2, 3, 4, 5];
println!("Please enter an array index.");
let mut index = String::new();
io::stdin()
.read_line(&mut index)
.expect("Failed to read line");
let index: usize = index
.trim()
.parse()
.expect("Index entered was not a number");
let element = a[index];
println!("The value of the element at index {index} is: {element}");
}
Feed it 10, and Rust panics instantly:
thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This is a runtime check, not a compile-time one — Rust doesn't know at compile time what index will be, so it verifies bounds every time you index into the array, then deliberately crashes rather than continue.
But what happens in C in this exact situation? And why does C's behavior here account for some of the most damaging security exploits in software history?
1. C: Raw Pointer Math, No Guardrails
C takes the opposite approach: it assumes you know what you're doing, so indexing performs zero bounds checking. a[10] is really just:
address = start_address_of_array + (index * element_size)
The CPU jumps straight to that computed address and reads (or writes) whatever is sitting there — no validation involved.
#include <stdio.h>
int main() {
int a[5] = {1, 2, 3, 4, 5};
// Index 10 is well past the 5-element array
printf("Value: %d\n", a[10]);
return 0;
}
What actually happens is undefined behavior, which in practice means one of:
- Garbage data — it prints whatever bits happen to live in adjacent memory.
- Segmentation fault — if the computed address falls outside memory your process is allowed to touch, the OS kills the process.
-
Silent memory corruption — if you write past the boundary (
a[10] = 99), you silently overwrite whatever variable happens to occupy that memory.
2. Why This Is a Security Problem, Not Just a Bug
Here's a simplified picture of how a C function's local variables can be laid out on the stack:
[ High Memory Address ]
+-----------------------------------+
| Return Address | <-- Tells CPU where to jump next
+-----------------------------------+
| Saved Frame Pointer |
+-----------------------------------+
| admin_password = "Secret" | <-- Neighboring variable
+-----------------------------------+
| a[4] | a[3] | a[2] | a[1] | <-- Your array (5 integers)
+-----------------------------------+
[ Low Memory Address ]
Caveat: this is illustrative, not guaranteed. Real compilers reorder locals, insert padding, and add mitigations like stack canaries and ASLR (address space layout randomization) specifically to make this layout unpredictable. But the underlying principle holds: on the stack, your array and other local variables — including, eventually, the return address — live in contiguous memory with nothing stopping you from walking off the end of one into another.
3. How Attackers Exploit Unchecked Array Access
Unchecked bounds lead to two main vulnerability classes.
A. Out-of-Bounds Read (Information Leak)
If an attacker can influence which index or offset gets read, they can pull adjacent memory out of the process — including secrets that were never meant to leave it, like session tokens or other variables sitting nearby in memory.
B. Out-of-Bounds Write / Buffer Overflow (Arbitrary Code Execution)
If an application lets you write past the end of an array, you can start overwriting things that control program flow.
#include <stdio.h>
#include <string.h>
void secret_admin_panel() {
printf("Access Granted: Welcome Admin!\n");
}
void user_login() {
int is_admin = 0; // Flag: 0 = User, 1 = Admin
char username[8]; // Array sized for 8 characters
printf("Enter username: ");
gets(username); // UNSAFE: no length checking
if (is_admin != 0) {
secret_admin_panel();
} else {
printf("Hello, regular user.\n");
}
}
gets() is used here deliberately as a textbook example — it was actually removed from the C standard in C11 because it's impossible to use safely, but plenty of legacy code and CTF challenges still rely on this exact pattern.
Step by step:
-
usernamegets 8 bytes, andis_adminhappens to sit next to it on the stack. - Input
AAAAAAAAA(9 characters) overflows the buffer by one byte, overwritingis_admin. -
is_adminis no longer0, soif (is_admin != 0)evaluates true — admin access granted. - With a larger payload, an attacker can keep writing past local variables entirely and overwrite the return address. When the function returns, the CPU jumps straight into attacker-controlled memory.
4. Rust's Answer: Panic by Default, Option When You Want Control
Rust's decision to panic on out-of-bounds access is deliberate: fail loudly and immediately rather than silently corrupt memory. But indexing with [] isn't your only option. If you want to handle the invalid case yourself instead of crashing:
let a = [1, 2, 3, 4, 5];
match a.get(10) {
Some(value) => println!("Value: {value}"),
None => println!("Index out of bounds — handled gracefully"),
}
a.get(index) returns Option<&T> — None instead of a panic. This is the idiomatic way to handle user-controlled indices in production Rust code: [] when you're certain the index is valid (and want a loud crash if you're wrong), .get() when the index comes from outside and you want to handle failure explicitly.
Quick Comparison
| Feature | C | Rust |
|---|---|---|
| Out-of-bounds access | Undefined behavior (reads/writes raw memory) | Panics by default; .get() returns None
|
| When is it checked | Never | Every access, at runtime |
| Security risk | Critical — buffer overflows, remote code execution | Low — safe Rust can't produce this class of memory corruption |
| Escape hatch | None (by design) |
.get() / Option
|
Note the "safe Rust" qualifier: inside an unsafe block, Rust lets you opt back into raw pointer access with the same risks C has. The safety guarantee applies to code that doesn't reach for unsafe.
Wrap-Up
When Rust panics on a bad index, it can feel like the language is getting in your way. But that's the point — the crash is the safety mechanism. C's willingness to let you read and write past the end of an array isn't a missing feature; it's a design tradeoff from an era that prioritized raw performance over runtime checks, and it's directly responsible for decades of the worst vulnerabilities in software history.
Have you run into a memory bug like this in production? Let's talk in the comments.
Top comments (0)