Undefined Behaviour é o contrato silencioso mais perigoso da programação em C: quando o teu código viola uma regra da norma ISO C — um integer overflow signed, um null pointer dereference, um acesso fora dos limites de um array — o compilador tem permissão para assumir que isso nunca acontece e optimizar o programa em conformidade. O resultado não é um crash previsível. É um programa que parece funcionar em debug, quebra silenciosamente em release, e em contextos de segurança abre vectores de exploração que um atacante pode usar para executar código arbitrário. Décadas de CVEs críticos — no kernel Linux, no OpenSSL, em browsers — têm UB na sua origem.
A norma ISO C define o comportamento de cada construção da linguagem. Para algumas construções — as mais perigosas ou difíceis de implementar portavelmente — a norma declara explicitamente que o comportamento é "undefined": o compilador pode fazer literalmente qualquer coisa, incluindo optimizações que eliminam código de segurança que o programador escreveu.
// A norma ISO C distingue três categorias:
// 1. Well-defined behaviour — resultado sempre previsível
int x = 5 + 3; // sempre 8
unsigned int u = UINT_MAX + 1; // sempre 0 — overflow unsigned é well-defined (wrap-around)
// 2. Implementation-defined behaviour — previsível numa plataforma, mas pode variar
int x = -1 >> 1; // right shift de valor negativo — resultado depende do compilador/CPU
// na maioria: -1 (arithmetic shift) mas não é garantido pela norma
// 3. Undefined behaviour — a norma não impõe NENHUM resultado
// O compilador pode: crashar, retornar lixo, executar código arbitrário,
// ou — o mais perigoso — eliminar código que o programador considera essencial
int x = INT_MAX + 1; // UB — signed integer overflow
int arr[5]; arr[10] = 42; // UB — out-of-bounds write
int* p = NULL; *p = 1; // UB — null pointer dereference
int* q = malloc(4); free(q); *q = 1; // UB — use-after-free
// A frase chave da norma ISO C (C11 §3.4.3):
// "behavior, upon use of a nonportable or erroneous program construct
// or of erroneous data, for which this International Standard imposes
// no requirements"
//
// Tradução prática: o compilador assume que UB nunca acontece.
// Se o teu código contém UB, o compilador pode — e vai — optimizar
// como se o caminho de código que causa UB fosse inalcançável.
O aspecto mais contra-intuitivo de UB é que o compilador não o "ignora" — usa-o activamente como premissa para optimizações. Se o compilador prova que um caminho de código só é alcançável através de UB, pode eliminar esse caminho completamente. O resultado: código de verificação de segurança escrito pelo programador é silenciosamente removido do executável.
// Exemplo 1 — eliminação de verificação de overflow:
// O programador escreve uma verificação de overflow:
int adicionar_seguro(int a, int b) {
if (a + b < a) { // ← verificar se houve overflow
return -1; // ← tratamento de erro
}
return a + b;
}
// O compilador raciocina com -O2:
// "signed integer overflow é UB"
// "se a + b causa overflow, este código é UB — nunca pode acontecer"
// "portanto, a + b < a é sempre false"
// "portanto, o if nunca é executado — posso eliminar o bloco inteiro"
// Código gerado com -O2 (equivalente ao que o compilador produz):
int adicionar_seguro(int a, int b) {
return a + b; // ← a verificação foi eliminada!
}
// A intenção do programador era verificar overflow.
// O programa compilado não verifica overflow.
// Com inputs a=INT_MAX, b=1: retorna INT_MIN em vez de -1.
// Exemplo 2 — eliminação de verificação de null pointer:
void processar(int* p) {
if (p + 1 < p) { // verificação de wrap-around de ponteiro (intenção: detectar overflow)
return; // nunca chegamos aqui em sistemas 64-bit normais, mas...
}
*p = 42; // dereferencia p
if (p == NULL) { // ← verificação de null DEPOIS de já ter dereferencado
return;
}
}
// O compilador raciocina:
// "*p = 42 dereferencar p" — se p fosse NULL, seria UB — logo o compilador assume p != NULL
// "se p != NULL então p == NULL é sempre false"
// "portanto, o segundo if é dead code — eliminar"
// Código gerado:
void processar(int* p) {
if (p + 1 < p) { return; }
*p = 42;
// verificação de null eliminada — o compilador "sabe" que p != NULL
}
// A moral: verificações de null DEPOIS de dereferênciar são eliminadas pelo compilador.
// Exemplo 3 — loop infinito eliminado por UB de signed overflow:
// CVE pattern — loop que devia terminar:
void copiar_dados(char* dst, const char* src, int n) {
for (int i = 0; i < n; i++) { // i é signed int
dst[i] = src[i];
}
}
// Com n = INT_MAX + 1 (passado como argumento):
// O compilador raciocina:
// "i começa em 0, incrementa por 1"
// "signed overflow é UB — i nunca pode fazer overflow"
// "portanto i sempre cresce — o loop sempre termina quando i >= n"
// "portanto posso assumir que n é um valor razoável e optimizar livremente"
// Resultado: o loop copia além dos limites do array — buffer overflow
// Solução: usar size_t (unsigned) para índices e tamanhos
void copiar_dados_seguro(char* dst, const char* src, size_t n) {
for (size_t i = 0; i < n; i++) { // size_t é unsigned — overflow é well-defined
dst[i] = src[i];
}
}
// ── 1. Signed Integer Overflow ────────────────────────────────────────────────
#include <limits.h>
int x = INT_MAX; // 2 147 483 647
x = x + 1; // UB — signed overflow
// em hardware x86: silenciosamente retorna INT_MIN (-2 147 483 648)
// mas o compilador pode assumir que nunca acontece e optimizar
// Verificação correcta de overflow (sem causar UB):
#include <stdint.h>
bool soma_overflow(int a, int b, int* resultado) {
// __builtin_add_overflow — extensão GCC/Clang, verifica SEM causar UB
return __builtin_add_overflow(a, b, resultado);
}
// Alternativa: usar unsigned (overflow é well-defined — wrap-around)
unsigned int u = UINT_MAX;
u = u + 1; // well-defined: u = 0 (wrap-around garantido pela norma)
// ── 2. Out-of-Bounds Access ───────────────────────────────────────────────────
int arr[5] = {1, 2, 3, 4, 5};
arr[5] = 99; // UB — índice 5 está fora de [0..4]
arr[-1] = 99; // UB — índice negativo
// Padrão seguro — verificar sempre antes de aceder:
void escrever_seguro(int* arr, size_t len, size_t idx, int val) {
if (idx >= len) {
// tratar erro — não escrever
return;
}
arr[idx] = val;
}
// ── 3. Null Pointer Dereference ───────────────────────────────────────────────
int* p = NULL;
*p = 42; // UB — em prática: SIGSEGV na maioria das plataformas
// mas o compilador pode eliminar verificações baseado nisto
// Padrão: verificar NULL ANTES de dereferênciar (nunca depois):
void usar_ponteiro(int* p) {
if (p == NULL) { return; } // verificar primeiro
*p = 42; // dereferênciar depois
}
// ── 4. Use-After-Free ─────────────────────────────────────────────────────────
// (ver artigo Ponteiros & Memória — abordado em detalhe)
int* p = malloc(sizeof(int));
free(p);
*p = 42; // UB — comportamento completamente indefinido
// ── 5. Violação de Strict Aliasing ────────────────────────────────────────────
// A regra: só podes aceder a um objecto através de um ponteiro do seu tipo
// (ou char*, que é uma excepção explícita da norma)
float f = 3.14f;
int* ip = (int*)&f; // cast para tipo diferente
int x = *ip; // UB — strict aliasing violation
// O compilador assume que ponteiros de tipos diferentes não se sobrepõem
// Optimizações agressivas podem reordenar/eliminar leituras e escritas
// Excepção legítima — inspecção de bytes com char*:
float f = 3.14f;
unsigned char* bytes = (unsigned char*)&f; // OK — char* é excepção da norma
for (int i = 0; i < sizeof(float); i++) {
printf("%02x ", bytes[i]); // imprime representação binária de f
}
// ── 6. Shift Inválido ─────────────────────────────────────────────────────────
int x = 1 << 31; // UB — shift para bit de sinal em signed int
int y = 1 << 32; // UB — shift >= largura do tipo (int tem 32 bits)
int z = -1 << 1; // UB — left shift de valor negativo
// Solução: usar unsigned para operações de bit
unsigned int u = 1u << 31; // well-defined — 2 147 483 648
unsigned int v = 1u << 0; // well-defined
Não é teoria — UB está na origem de vulnerabilidades críticas em software amplamente utilizado. Os exemplos seguintes são casos documentados onde UB causou directamente uma falha de segurança explorável.
// ── CVE-2009-1897 — Linux Kernel, integer overflow → heap overflow ───────────
// Código vulnerável (simplificado) no kernel Linux:
int tamanho_total = n_elementos * sizeof(struct elemento);
// Se n_elementos = 0x20000001 e sizeof(struct elemento) = 8:
// 0x20000001 * 8 = 0x100000008 → overflow signed → resultado: 8 (valor pequeno!)
// malloc(8) aloca apenas 8 bytes
// o código depois escreve n_elementos structs → heap overflow massivo
// A verificação de overflow que existia foi eliminada pelo compilador:
if (tamanho_total / sizeof(struct elemento) != n_elementos) { // ← eliminada!
return -EINVAL;
}
// O compilador assumiu que o overflow nunca acontecia → eliminou a verificação
// ── CVE-2014-9295 — NTP, múltiplos buffer overflows ──────────────────────────
// Código vulnerável (padrão representativo):
char buffer[512];
int offset = calcular_offset(pacote_rede); // controlado pelo atacante via rede
buffer[offset] = valor; // offset pode ser negativo ou > 512 — UB + buffer overflow
// A verificação existente foi ineficaz porque usava signed int para offset
// Com offset = -1: buffer[-1] escreve antes do início do buffer na stack
// Sobrescreve variável local adjacente → execução de código arbitrário
// ── CVE-2017-9798 — Apache httpd "Optionsbleed" ──────────────────────────────
// Use-after-free no processamento de OPTIONS HTTP:
// 1. String alocada no heap para lista de métodos permitidos
// 2. free() chamado prematuramente
// 3. Código continua a ler da string libertada
// 4. O heap reutilizou aquela memória para outro pedido
// 5. Resposta OPTIONS vaza fragmentos de memória de outros pedidos
// — potencialmente incluindo tokens de sessão, passwords em memória
// Impacto: information disclosure — leitura de memória arbitrária do processo
// CVSS: 7.5 (High)
// ── CVE-2021-3156 — sudo heap overflow (Baron Samedit) ───────────────────────
// Off-by-one em parsing de argumentos — escrita um byte além do buffer
// Causa: loop com condição errada que não verifica o terminador null correctamente
// size_t len = strlen(str);
// for (size_t i = 0; i <= len; i++) { ... } // i <= len inclui o null terminator
// ^^ devia ser i < len
// A escrita extra um byte além do buffer (null terminator) sobrescreve
// metadados do heap allocator → controlo do fluxo de execução → root
// Presente no sudo desde 1.8.2 (2011) — descoberto e explorado em 2021
// Afectou todas as distribuições Linux — privilege escalation para root local
// ── CVE-2022-0185 — Linux Kernel, signed integer overflow → heap overflow ────
// No subsistema de filesystems, verificação de tamanho com signed int:
// int tamanho = valor_do_utilizador;
// if (tamanho > LIMITE) { return -EINVAL; } // verificação com signed
// memcpy(buffer, src, tamanho); // tamanho negativo → copia enorme!
//
// Com tamanho = -1 (signed): passa na verificação (-1 < LIMITE)
// memcpy com size_t(-1) = ENORME → heap overflow → privilege escalation
// Explorado para escape de container (container → host root)
UB em C existe por uma razão histórica — performance e portabilidade máximas numa era em que o hardware variava enormemente. Java e Rust fizeram escolhas diferentes, com trade-offs distintos.
// ── Java — eliminar UB com verificações em runtime ───────────────────────────
// Integer overflow — well-defined (wrap-around, como unsigned em C):
int x = Integer.MAX_VALUE + 1; // → Integer.MIN_VALUE — previsível, não é UB
// Para detectar overflow: Math.addExact() lança ArithmeticException
int resultado = Math.addExact(Integer.MAX_VALUE, 1); // → ArithmeticException
// Array out-of-bounds — excepção determinística:
int[] arr = new int[5];
arr[10] = 99; // → ArrayIndexOutOfBoundsException — sempre, previsível
// em C: undefined behaviour silencioso
// Null dereference — excepção determinística:
String s = null;
s.length(); // → NullPointerException — sempre, previsível
// em C: undefined behaviour (frequentemente SIGSEGV, mas não garantido)
// Use-after-free — impossível por design:
// Não existe free() manual — o GC liberta apenas quando não há referências
// Enquanto existe uma referência, o objecto é válido
// Strict aliasing — não existe:
// Java não expõe ponteiros raw — não há como violar aliasing rules
// O custo das verificações em runtime Java:
// - Array bounds check: ~1-5% de overhead em código optimizado pelo JIT
// - Null check: quase gratuito — o JIT elimina checks redundantes
// - GC: 2-5× mais memória, pausas GC periódicas
// ── Rust — eliminar UB em compile time com o borrow checker ──────────────────
// Integer overflow:
// Em debug: panic! (crash controlado) ao detectar overflow
// Em release: wrap-around (como Java) — comportamento well-defined
// Para overflow explícito: wrapping_add(), saturating_add(), checked_add()
let x: i32 = i32::MAX;
let y = x.checked_add(1); // → None (overflow detectado)
let z = x.wrapping_add(1); // → i32::MIN (wrap-around explícito)
let w = x.saturating_add(1); // → i32::MAX (satura ao máximo)
// Array out-of-bounds:
// Verificação em runtime (como Java) — panic! em vez de UB
let arr = [1, 2, 3, 4, 5];
let x = arr[10]; // → panic: index out of bounds: the len is 5 but the index is 10
// Com get() — sem panic, retorna Option:
let x = arr.get(10); // → None — sem crash, tratamento explícito
// Use-after-free — impossível por design do compilador:
let p = Box::new(42); // aloca no heap
drop(p); // liberta explicitamente
// println!("{}", *p); // ← ERRO DE COMPILAÇÃO — borrow checker detecta use-after-free
// "error[E0382]: use of moved value: `p`"
// Null pointer dereference — não existe null em Rust:
// Ponteiros opcionais usam Option<T> — obrigado a tratar o caso None
fn usar(p: Option<&i32>) {
match p {
Some(valor) => println!("{}", valor),
None => println!("sem valor"), // forçado a tratar este caso
}
}
// O custo do borrow checker:
// - Zero overhead em runtime — verificações são em compile time
// - Curva de aprendizagem significativa — o compilador rejeita código válido mas inseguro
// - Alguns padrões legítimos requerem unsafe{} block — responsabilidade explícita
| UB / Bug | C | Java | Rust |
|---|---|---|---|
| Signed integer overflow | UB — compilador optimiza assumindo impossível | Well-defined wrap-around; Math.addExact() para detecção |
Panic em debug; wrap em release; APIs explícitas |
| Array out-of-bounds | UB — leitura/escrita em memória arbitrária | ArrayIndexOutOfBoundsException — sempre |
Panic em runtime; get() para Option |
| Null dereference | UB — frequentemente SIGSEGV mas não garantido | NullPointerException — sempre |
Não existe null — Option<T> obrigatório |
| Use-after-free | UB — corrupção silenciosa ou exploração | Impossível — GC garante validade enquanto referenciado | Erro de compilação — borrow checker |
| Data race | UB — comportamento completamente indefinido | Resultado não determinístico mas sem UB; synchronized |
Erro de compilação — ownership system |
| Overhead de segurança | Zero — sem verificações | Runtime (~1-5% arrays, GC overhead) | Zero — verificações em compile time |
// Estratégia de detecção em camadas — da mais rápida à mais abrangente:
// ── Camada 1: Warnings do compilador (compile time, zero custo) ───────────────
gcc -Wall -Wextra -Wundefined-behavior-sanitizer -Wshadow \
-Wformat=2 -Wconversion -Wsign-conversion \
-o programa programa.c
// -Wsign-conversion: avisa em conversões signed ↔ unsigned (fonte frequente de UB)
// Custo: zero — apenas analyse estática durante compilação
// ── Camada 2: UBSanitizer (runtime em testes/CI) ──────────────────────────────
gcc -fsanitize=undefined -g -o programa_ub programa.c
./programa_ub
// Detecta em runtime: signed overflow, out-of-bounds, null deref,
// violações de alignment, shift inválido, ...
// Output de exemplo:
// programa.c:42:15: runtime error: signed integer overflow:
// 2147483647 + 1 cannot be represented in type 'int'
// Overhead: ~20% — aceitável em CI, não em produção
// ── Camada 3: AddressSanitizer (runtime em testes/CI) ────────────────────────
gcc -fsanitize=address -g -o programa_asan programa.c
./programa_asan
// Detecta: buffer overflow, use-after-free, double free, stack overflow
// Overhead: ~2× — usar em CI e testes de integração
// ── Camada 4: Análise estática (CI, sem executar) ─────────────────────────────
clang --analyze programa.c // Clang Static Analyzer
cppcheck --enable=all programa.c
// Detecta padrões de UB sem executar — complementar aos sanitizers
// ── Pipeline CI recomendado ───────────────────────────────────────────────────
# Build normal (produção):
gcc -O2 -Wall -Wextra -Wsign-conversion -fstack-protector-strong \
-D_FORTIFY_SOURCE=2 -pie -fPIE -o programa programa.c
# Build de testes (CI — detecção de UB):
gcc -O1 -fsanitize=address,undefined -g -o programa_test programa.c
./run_tests programa_test
# Análise estática (CI):
clang --analyze programa.c
cppcheck --enable=all --error-exitcode=1 programa.c
__builtin_add_overflow() ou unsigned para verificações correctas.size_t para índices (não int).memcpy() ou char* para reinterpretar bytes.unsigned para operações de bit.-fsanitize=address,undefined obrigatório em todos os testes e CI — detecta em runtime o que o compilador não apanha em compile time.