Part 15by Muhammad

Buffer Overflow on ESP32: Hands-On Demonstration Lab

Buffer Overflow on ESP32: Hands-On Demonstration Lab

Buffer overflows are the most common and historically most damaging vulnerability class in embedded systems. They occur when more data is written to a fixed-size buffer than it can hold, corrupting whatever memory lies beyond the buffer boundary. This lab demonstrates real buffer overflows on an ESP32: you will see crashes, observe memory corruption in hex and watch adjacent variables get overwritten. Then you will implement the safe alternatives and verify the difference. This is Lab 4 of the Embedded Systems Security course series, covering the secure software development practices from Section 4.

How Buffer Overflows Work

When C code copies data into a fixed-size array without checking whether the data fits, the extra bytes overwrite whatever is stored in the adjacent memory addresses. On a stack frame, the adjacent memory typically includes other local variables, the saved frame pointer and the function’s return address. Overwriting the return address is the classic exploit primitive: the attacker controls where execution resumes when the function returns, redirecting it to attacker-controlled code.

On an embedded device without ASLR (Address Space Layout Randomization) — which describes most Cortex-M microcontrollers, including the ESP32 in many configurations — the memory layout is predictable. There is no operating system to randomise stack addresses between runs. This makes the overflow-to-code-execution path significantly more reliable than on a modern desktop OS. Even without exploitation, a buffer overflow that corrupts adjacent variables or causes a crash is a denial-of-service vulnerability. A connected sensor that crashes when it receives a long MQTT payload is unacceptable in a production deployment.

Equipment and Prerequisites

You need an ESP32 DOIT DevKit V1, a USB cable and Arduino IDE. Open Serial Monitor at 115200 baud. Some demonstrations will intentionally crash the ESP32 — this is expected and instructional. Use the RESET button to recover. No external hardware or additional libraries are required.

Complete Lab Code

The lab code provides seven pairs of demonstrations: one vulnerable implementation and one safe implementation for each common overflow scenario. Safe demonstrations run automatically on boot. Vulnerable demonstrations require pressing the corresponding key in Serial Monitor because some will crash the device.

/*
 * Buffer Overflow Demonstration and Prevention
 * Shows vulnerable vs. safe coding practices on real ESP32 hardware.
 * WARNING: Some demos intentionally crash the device.
 *
 * Hardware: ESP32 DOIT DevKit V1
 */

#include <Arduino.h>

// Adjacent variables to demonstrate memory corruption
int secretValue1 = 0xDEADBEEF;
char globalBuffer[10];
int secretValue2 = 0xCAFEBABE;

void printSeparator() {
  Serial.println(" ");
  Serial.println("--------------------------------");
  Serial.println(" ");
}

void waitKey() {
  Serial.println("\nPress any key to continue...");
  while (!Serial.available()) delay(100);
  while (Serial.available()) Serial.read();
}

/* -------- DEMO 1: UNSAFE strcpy -------- */
void demo1_unsafe_strcpy() {
  printSeparator();
  Serial.println("DEMO 1: Unsafe strcpy() — Buffer Overflow");
  printSeparator();

  char smallBuffer[10];
  const char* largeInput = "This is a very long string that will overflow the buffer!!!";
  Serial.println("Buffer size: 10 bytes");
  Serial.println("Input size:  " + String(strlen(largeInput)) + " bytes");
  Serial.println("Calling strcpy() — UNSAFE...");
  delay(1000);

  strcpy(smallBuffer, largeInput);  // VULNERABLE — overflows adjacent memory

  Serial.println("Result: Memory corrupted (crash likely on next operation)");
}

/* -------- DEMO 2: SAFE strncpy -------- */
void demo2_safe_strncpy() {
  printSeparator();
  Serial.println("DEMO 2: Safe strncpy() — Bounds Checking");
  printSeparator();

  char smallBuffer[10];
  const char* largeInput = "This is a very long string that should be truncated safely";
  Serial.println("Buffer size: 10 bytes");
  Serial.println("Input size:  " + String(strlen(largeInput)) + " bytes");

  // SAFE: copy at most sizeof(buffer)-1 bytes, then force null termination
  strncpy(smallBuffer, largeInput, sizeof(smallBuffer) - 1);
  smallBuffer[sizeof(smallBuffer) - 1] = '\0';

  Serial.println("Result: \"" + String(smallBuffer) + "\"");
  Serial.println("Input truncated safely. No overflow. Adjacent memory intact.");
}

/* -------- DEMO 3: UNSAFE sprintf -------- */
void demo3_unsafe_sprintf() {
  printSeparator();
  Serial.println("DEMO 3: Unsafe sprintf() — No Size Limit");
  printSeparator();

  char smallBuffer[20];
  const char* sensor = "Temperature";
  int val1 = 12345, val2 = 67890;

  Serial.println("Buffer size: 20 bytes");
  Serial.println("Calling sprintf() without size check — UNSAFE...");

  // VULNERABLE — format string expansion exceeds buffer
  sprintf(smallBuffer, "Sensor: %s, Val1: %d, Val2: %d", sensor, val1, val2);

  Serial.println("Result: \"" + String(smallBuffer) + "\"");
  Serial.println("Overflow occurred — adjacent memory corrupted.");
}

/* -------- DEMO 4: SAFE snprintf -------- */
void demo4_safe_snprintf() {
  printSeparator();
  Serial.println("DEMO 4: Safe snprintf() — Size Limited");
  printSeparator();

  char smallBuffer[20];
  const char* sensor = "Temperature";
  int val1 = 12345, val2 = 67890;

  Serial.println("Buffer size: 20 bytes");

  // SAFE: snprintf writes at most sizeof(buffer) bytes including null terminator
  int needed = snprintf(smallBuffer, sizeof(smallBuffer),
                        "Sensor: %s, Val1: %d, Val2: %d", sensor, val1, val2);

  Serial.println("Result:         \"" + String(smallBuffer) + "\"");
  Serial.println("Bytes needed:   " + String(needed + 1));
  Serial.println("Bytes available:" + String(sizeof(smallBuffer)));
  Serial.println("Output safely truncated. No overflow.");
}

/* -------- DEMO 5: UNSAFE serial input -------- */
void demo5_unsafe_input() {
  printSeparator();
  Serial.println("DEMO 5: Unsafe Input Handling");
  printSeparator();

  char buffer[10];
  Serial.println("Buffer: 10 bytes. Type more than 10 characters then Enter:");
  while (!Serial.available()) delay(100);
  delay(100);

  int i = 0;
  // UNSAFE: limit is 25, buffer is only 10
  while (Serial.available() && i < 25) {
    char c = Serial.read();
    if (c != '\n' && c != '\r') buffer[i++] = c;
  }
  buffer[i] = '\0';

  Serial.println("Stored: \"" + String(buffer) + "\"");
  Serial.println("Characters written: " + String(i));
  if (i >= 10) Serial.println("BUFFER OVERFLOW OCCURRED — adjacent memory corrupted!");
}

/* -------- DEMO 6: SAFE input -------- */
void demo6_safe_input() {
  printSeparator();
  Serial.println("DEMO 6: Safe Input Handling");
  printSeparator();

  char buffer[10];
  while (Serial.available()) Serial.read();
  Serial.println("Buffer: 10 bytes. Type anything then Enter:");
  while (!Serial.available()) delay(100);
  delay(100);

  int i = 0;
  int maxSize = sizeof(buffer) - 1;
  int discarded = 0;

  while (Serial.available()) {
    char c = Serial.read();
    if (c != '\n' && c != '\r') {
      if (i < maxSize) buffer[i++] = c;
      else discarded++;
    }
  }
  buffer[i] = '\0';

  Serial.println("Stored:    \"" + String(buffer) + "\"");
  Serial.println("Accepted:  " + String(i) + " characters");
  Serial.println("Discarded: " + String(discarded) + " excess characters");
  Serial.println("No overflow. Input safely truncated.");
}

/* -------- DEMO 7: Memory corruption visualisation -------- */
void demo7_memory_corruption() {
  printSeparator();
  Serial.println("DEMO 7: Memory Corruption Visualisation");
  printSeparator();

  // Three adjacent stack variables
  int before = 0xAAAAAAAA;
  char buffer[8];
  int after  = 0xBBBBBBBB;

  Serial.println("Memory layout (stack):");
  Serial.printf("  before  = 0x%08X\n", before);
  Serial.println("  buffer[8] = [8 bytes]");
  Serial.printf("  after   = 0x%08X\n", after);

  Serial.println("\nWriting 24 bytes of 'X' into the 8-byte buffer...");
  memset(buffer, 'X', 24);  // Deliberate overflow

  Serial.println("\nAfter overflow:");
  Serial.printf("  before = 0x%08X  %s\n", before,
    before == 0xAAAAAAAA ? "<- OK" : "<- CORRUPTED!");
  Serial.print("  buffer = ");
  for (int i = 0; i < 8; i++) Serial.printf("%02X ", (uint8_t)buffer[i]);
  Serial.println();
  Serial.printf("  after  = 0x%08X  %s\n", after,
    after == 0xBBBBBBBB ? "<- OK" : "<- CORRUPTED!");
  Serial.println("\nThis is how attackers overwrite return addresses and function pointers.");
}

/* -------- INTEGER OVERFLOW CHECK -------- */
void demo_integer_overflow() {
  printSeparator();
  Serial.println("DEMO: Integer Overflow Detection");
  printSeparator();

  int32_t count = 50000;
  // Safe multiplication with overflow detection
  if (count > 0 && count > (INT32_MAX / 1000)) {
    Serial.println("Overflow detected! count * 1000 would exceed INT32_MAX.");
    Serial.println("Action: reject input or use larger type.");
  } else {
    int32_t size = count * 1000;
    Serial.println("Safe result: " + String(size));
  }
}

/* -------- SECURE CODING SUMMARY -------- */
void printSummary() {
  printSeparator();
  Serial.println("SECURE CODING SUMMARY");
  printSeparator();
  Serial.println("UNSAFE functions — NEVER USE in production:");
  Serial.println("  strcpy()   No size limit — always overflows on long input");
  Serial.println("  strcat()   No size limit");
  Serial.println("  sprintf()  No size limit — format expansion unpredictable");
  Serial.println("  gets()     Removed from C11 — no size limit");
  Serial.println("\nSAFE alternatives — ALWAYS USE:");
  Serial.println("  strncpy(dst, src, sizeof(dst)-1); dst[sizeof(dst)-1]='\0';");
  Serial.println("  strncat(dst, src, sizeof(dst)-strlen(dst)-1);");
  Serial.println("  snprintf(dst, sizeof(dst), fmt, ...);");
  Serial.println("  fgets(buf, sizeof(buf), stdin);");
  Serial.println("\nBEST PRACTICES:");
  Serial.println("  1. Always know your buffer size");
  Serial.println("  2. Always validate input length before copying");
  Serial.println("  3. Always null-terminate strings explicitly");
  Serial.println("  4. Check return values from all allocation functions");
  Serial.println("  5. Set freed pointers to NULL immediately");
  Serial.println("  6. Check for integer overflow before using a value as a buffer size");
  Serial.println("  7. Enable compiler warnings: -Wall -Wextra -fstack-protector-all");
}

void setup() {
  Serial.begin(115200);
  delay(2000);

  Serial.println("\n=== BUFFER OVERFLOW DEMONSTRATION LAB ===");
  Serial.println("Chip: " + String(ESP.getChipModel()));
  Serial.println("Free Heap: " + String(ESP.getFreeHeap()) + " bytes");
  Serial.println("\nSafe demos run automatically.");
  Serial.println("Unsafe demos require key press (may crash device).");
  Serial.println("Press RESET to recover from crash.\n");

  waitKey();

  // Run safe demos automatically
  demo2_safe_strncpy();  waitKey();
  demo4_safe_snprintf(); waitKey();
  demo6_safe_input();    waitKey();
  demo_integer_overflow(); waitKey();
  

  printSummary();

  Serial.println("\nInteractive mode:");
  Serial.println("  1 = Unsafe strcpy (WARNING: may crash)");
  Serial.println("  3 = Unsafe sprintf");
  Serial.println("  5 = Unsafe input");
  Serial.println("  7 = Memory Corruption");
  Serial.println("  S = Print summary again");
}

void loop() {
  if (Serial.available()) {
    char cmd = Serial.read();
    switch (cmd) {
      case '1': demo1_unsafe_strcpy(); break;
      case '3': demo3_unsafe_sprintf(); break;
      case '5': demo5_unsafe_input(); break;
      case '7': demo7_memory_corruption(); waitKey();
      case 'S': case 's': printSummary(); break;
    }
  }
  delay(100);
}

The Seven Vulnerable/Safe Demonstration Pairs

Demo 1 (unsafe) and Demo 2 (safe): strcpy vs. strncpy. strcpy(dst, src) copies bytes from src to dst until it encounters a null terminator, with no regard for how large dst is. If src is longer than dst, the excess bytes overwrite whatever is in adjacent memory. The safe version uses strncpy(dst, src, sizeof(dst)-1) and explicitly sets the last byte to null. The sizeof(dst)-1 limit reserves one byte for the null terminator that strncpy may not add if the source is exactly sizeof(dst) bytes or longer.

Demo 3 (unsafe) and Demo 4 (safe): sprintf vs. snprintf. sprintf(buf, fmt, ...) writes formatted output to buf with no size limit. If the expanded format string is longer than the buffer, the overflow corrupts adjacent memory. snprintf(buf, sizeof(buf), fmt, ...) limits output to sizeof(buf) bytes including the null terminator and returns the number of bytes that would have been written, allowing the caller to detect truncation.

Demo 5 (unsafe) and Demo 6 (safe): serial input without and with bounds checking. This demonstrates the real-world scenario of reading user input from UART into a fixed-size buffer. The unsafe version uses a limit of 25 characters for a 10-byte buffer. The safe version tracks characters accepted and characters discarded, accepting at most sizeof(buffer)-1 characters.

Demo 7: Memory corruption visualisation. Three adjacent stack variables — before (32-bit integer), buffer[8] (8-byte array), after (32-bit integer) — are declared in sequence. Writing 24 bytes into the 8-byte buffer overwrites the 4 bytes of after. The demo prints the hex value of after before and after the overflow, showing its value change from 0xBBBBBBBB to 0x58585858 (‘X’ repeated in hex). This is the concrete illustration of how buffer overflows corrupt program state.

Memory Corruption Visualisation

When Demo 7 runs, you will see output similar to:

Memory layout (stack):
  before  = 0xAAAAAAAA
  buffer[8] = [8 bytes]
  after   = 0xBBBBBBBB

Writing 24 bytes of 'X' into the 8-byte buffer...

After overflow:
  before = 0xAAAAAAAA  <- OK
  buffer = 58 58 58 58 58 58 58 58
  after  = 0x58585858  <- CORRUPTED!

This is how attackers overwrite return addresses and function pointers.

The before variable is unaffected because it is at a lower address than the buffer. The after variable is corrupted because it is at a higher address and the overflow writes past the end of the buffer into it. In a real exploit, the target variable would be the function’s saved return address, and 0x58585858 would be replaced with the address of the attacker’s shellcode.

Safe Function Reference

Unsafe FunctionSafe AlternativeKey Difference
strcpy(dst, src)strncpy(dst, src, n-1); dst[n-1]='\0';Limits copy to n-1 bytes; explicit null termination
strcat(dst, src)strncat(dst, src, sizeof(dst)-strlen(dst)-1)Limits append based on remaining space
sprintf(buf, fmt, ...)snprintf(buf, sizeof(buf), fmt, ...)Limits formatted output to buffer size
gets(buf)fgets(buf, sizeof(buf), stdin)Removed from C11; always use fgets
scanf("%s", buf)scanf("%9s", buf) (with width)Width specifier limits input length
memcpy(dst, src, n)memcpy(dst, src, min(n, sizeof(dst)))Bound n to the actual destination size

Compiler Protections

The compiler can add runtime checks that detect stack corruption before a crash or exploitation. Add these flags to your PlatformIO build_flags or Arduino IDE compiler.c.extra_flags:

# Add to platformio.ini build_flags or compiler flags:
-Wall                    # Enable all warnings
-Wextra                  # Extra warnings beyond -Wall
-Werror                  # Treat warnings as compilation errors
-fstack-protector-all    # Stack canary on every function
-D_FORTIFY_SOURCE=2      # Runtime bounds checking on string functions

Stack canaries work by placing a random value (the “canary”) between the local variables and the saved return address on the stack frame. Before a function returns, the runtime checks that the canary value is unchanged. If a buffer overflow has overwritten the canary, execution is aborted rather than redirected. This does not prevent buffer overflows but it prevents the most common exploitation technique.

Execution Steps

Upload the code and open Serial Monitor at 115200 baud. Step 1: press any key to begin. The safe demonstrations (Demo 2, 4, 7 and 6) run automatically — observe how each one handles oversized input without crashing. Step 2: press ‘1’ in Serial Monitor to run the unsafe strcpy demo. The device will likely crash and restart. Press RESET if it does not recover automatically. Step 3: press ‘3’ for the unsafe sprintf demo — this may or may not crash depending on memory layout. Step 4: press ‘5’ for the unsafe input demo and type more than 10 characters. Step 5: press ‘S’ to print the complete secure coding summary reference.

Expected Output

=== BUFFER OVERFLOW DEMONSTRATION LAB ===
Chip: ESP32-D0WDQ6
Free Heap: 295432 bytes

DEMO 2: Safe strncpy() — Bounds Checking
Buffer size: 10 bytes
Input size:  58 bytes
Result: "This is a"
Input truncated safely. No overflow. Adjacent memory intact.

DEMO 7: Memory Corruption Visualisation
  before  = 0xAAAAAAAA
  buffer[8] = [8 bytes]
  after   = 0xBBBBBBBB
Writing 24 bytes of 'X' into the 8-byte buffer...
  before = 0xAAAAAAAA  <- OK
  buffer = 58 58 58 58 58 58 58 58
  after  = 0x58585858  <- CORRUPTED!

Discussion Questions

What happens when a buffer overflow occurs? Adjacent memory is corrupted with the overflow data. This may cause an immediate crash if critical pointers or control data are overwritten, or it may cause silent data corruption that manifests as incorrect behaviour later. In a deliberately engineered exploit, the attacker chooses what to overflow with to redirect execution to their code.

Why do unsafe functions still exist in the C standard library? Historical compatibility. strcpy predates the security-conscious design era. Removing them would break decades of existing code. gets() was actually removed in C11 precisely because there is no safe way to use it, but the others remain with documented warnings.

What is the performance cost of safe functions? Negligible. snprintf performs one additional comparison per call to check the remaining buffer space. Modern compilers optimise this effectively. The cost is measurable in nanoseconds on a 240 MHz ESP32. The cost of a buffer overflow exploited in production is measured in something entirely different.

Can buffer overflows be prevented completely? Yes, with consistent use of safe functions, explicit bounds checking on all input handling, and compiler protection flags. Memory-safe languages like Rust eliminate them by construction at the language level. For C/C++ on embedded systems, discipline and tooling (static analysis, fuzzing) are the practical defences.

Key Takeaways

Never use strcpy, strcat, sprintf or gets in production firmware. Use strncpy with explicit null termination, strncat with remaining space calculation, snprintf with sizeof(buffer), and fgets for user input. Always know your buffer size before copying data into it. Always validate input length before processing. Buffer overflows are preventable with discipline — they require the developer to consistently make the right choice at every function call where a buffer is involved. Static analysis tools like cppcheck (covered in Lab 7) can catch unsafe function calls automatically in CI, making it practical to enforce these rules across a codebase.