C Segurança (Buffer Overflow)

O C deixa você escrever direto na memória, byte a byte, e não confere se você está dentro dos limites. Se um array tem lugar para 8 letras e o programa escreve 12, o C escreve mesmo: passa por cima do que estava na memória ao lado. Isso é um estouro de buffer (buffer overflow), a falha mais famosa — e mais perigosa — da história do C. Ela derruba o programa, estraga dados sem avisar e é a porta por onde muitos ataques entram. Nesta aula você vai ver o estouro acontecer de verdade, entender por que ele passa calado, e aprender as funções e as opções de compilação que o pegam.

Você vai aprender
  • O que é um estouro de buffer e por que o C não o impede sozinho.
  • As funções perigosas (gets, strcpy, strcat, sprintf, scanf("%s")) e as versões seguras (fgets, snprintf, %29s).
  • Um estouro silencioso que troca o valor de outra variável e libera um acesso que deveria ser proibido.
  • Como ligar os dois fiscais e o que cada um mostra de verdade no laboratório: o AddressSanitizer (-fsanitize=address) e o FORTIFY_SOURCE (-D_FORTIFY_SOURCE=2 -O2).
  • Por que um erro que explode no seu Linux pode passar despercebido no editor do guia.

Todas as saídas desta aula são reais, feitas com o gcc 15 na máquina fictícia da Ana, na pasta ~/c/seg. Esta é uma aula sobre defender o seu próprio programa dos seus próprios enganos: nada aqui serve para atacar o programa de outra pessoa.


A ideia: a prateleira que invade a vizinha

Pense num array como uma prateleira com um número fixo de espaços — digamos, 8. Você pode guardar coisas no espaço 0 até o espaço 7. Em quase toda linguagem, se você tentar pôr algo no espaço 8, a linguagem reclama na hora. No C, não: ele simplesmente coloca a coisa no lugar onde o espaço 8 estaria, que já é a prateleira do vizinho. O vizinho pode ser outra variável, um valor de controle do programa ou uma peça interna que faz o programa voltar das funções. Escrever ali é o estouro de buffer.

O nome vem do inglês buffer ("pulmão", um espaço reservado de memória) e overflow ("transbordar"): o dado transbordou do espaço reservado. Três coisas podem acontecer:

  • O programa morre com *** stack smashing detected *** — às vezes. É o melhor caso, porque você percebe.
  • O programa continua e entrega um resultado errado, porque estragou uma variável ao lado. Pior, porque parece que funcionou.
  • Alguém de fora controla o estrago e faz o programa rodar o que quiser. É assim que muitos vírus e invasões começam.

A origem do transbordo é quase sempre a mesma: uma função que copia texto sem saber o tamanho do destino. Veja a lista das perigosas e o que usar no lugar:

Perigosa (não sabe o limite)Por quêVersão segura
gets(nome)Lê uma linha sem limite nenhum. Foi removida do C no C11.fgets(nome, sizeof nome, stdin)
scanf("%s", nome)Lê uma palavra sem limite.scanf("%29s", nome) (um a menos que o array)
strcpy(dest, origem)Copia até o '\0' da origem, caiba ou não no destino.snprintf(dest, sizeof dest, "%s", origem)
strcat(dest, origem)Gruda no fim, sem olhar o espaço que sobra.snprintf montando o texto todo de uma vez
sprintf(dest, ...)Escreve formatado, sem limite de tamanho.snprintf(dest, sizeof dest, ...)

Repare no padrão: toda versão segura recebe o tamanho do destino. A regra de ouro desta aula é uma só: nunca copie texto para um array sem dizer à função quanto cabe nele.


1. O clássico: scanf("%s") sem limite

A Ana quer ler o nome de uma pessoa. O array tem 8 bytes. Veja o %s "pelado", sem número:

#include <stdio.h>

int main(void) {
    char nome[8];
    printf("Nome: ");
    if (scanf("%s", nome) != 1) {
        return 1;
    }
    printf("Olá, %s!\n", nome);
    return 0;
}

Compila sem um aviso sequer. Com um nome curto, funciona. Com um nome de 11 letras num array de 8:

$ gcc -std=c23 -Wall -Wextra nome.c -o nome
$ echo Ana | ./nome
Nome: Olá, Ana!
$ echo Maximiliano | ./nome
Nome: *** stack smashing detected ***: terminated
Abortado                   (imagem do núcleo gravada) ./nome
$ echo $?
134
nenhum aviso na compilação
O compilador não tem como saber, enquanto compila, que a pessoa vai digitar 11 letras. O tamanho só existe na hora de rodar.
*** stack smashing detected ***
"Esmagamento da pilha detectado". O scanf escreveu Maximiliano\0 (12 bytes) num array de 8 e passou por cima de uma trava que o gcc coloca na pilha (o stack protector). No fim do main, o programa viu a trava quebrada e se abortou.
Abortado … 134
O programa foi encerrado à força. O código 134 é o "abortado" do Linux (veja exit).
O stack smashing é sorte, não garantia

A trava da pilha só é conferida quando a função termina, e só pega estouros que chegam até ela. Um estouro de 1 ou 2 bytes, ou que atinge só outra variável local, pode não disparar nada — e aí o programa segue com um dado estragado. Nunca conte com o stack smashing para descobrir os seus estouros; use os fiscais da seção 5.

O conserto: diga o tamanho ao scanf

O %s aceita um número antes do s: a quantidade máxima de caracteres que ele pode ler. Para um char nome[8], use %7s: 7 letras mais o '\0' do fim cabem exatos nos 8 bytes (veja Strings). O que passar disso fica na entrada, sem estourar nada:

$ echo Maximiliano | ./nome      « agora com scanf("%7s", nome)
Nome: Olá, Maximil!

No editor do guia, o mesmo %7s corta o nome igualzinho (o resto, iano, fica na entrada):

$ clang -std=c23 -Wall -Wextra nome.c -o nome
$ ./nome
Nome: Maximiliano
Olá, Maximil!
[código de saída: 0]

Missão: este scanf lê o nome com %s sem limite — um estouro esperando para acontecer. O array nome tem 8 bytes. Troque o %s por %7s (7 letras + o '\0'). A entrada da missão é Ana, que cabe de sobra; a saída continua Olá, Ana!, mas agora o programa está seguro. (A missão confere o seu código: o estouro em si passa calado no editor.)

#include <stdio.h>

int main(void) {
    char nome[8];
    printf("Nome: ");
    if (scanf("%s", nome) != 1) {
        return 1;
    }
    printf("Olá, %s!\n", nome);
    return 0;
}
#include <stdio.h>

int main(void) {
    char nome[8];
    printf("Nome: ");
    if (scanf("%7s", nome) != 1) {
        return 1;
    }
    printf("Olá, %s!\n", nome);
    return 0;
}

2. O estouro silencioso que vira "administrador"

Até aqui o estouro derrubou o programa. Agora o caso que assusta de verdade: um estouro que não derruba nada e troca o valor de outra variável. A Ana escreveu um login de brincadeira. A struct guarda o nome (8 bytes) e um campo admin, que vale 0 para uma pessoa comum:

#include <stdio.h>
#include <string.h>

struct Usuario {
    char nome[8];
    int admin;
};

int main(void) {
    struct Usuario u = {.nome = "", .admin = 0};
    char entrada[100];

    printf("Nome: ");
    if (scanf("%99s", entrada) != 1) {
        return 1;
    }
    strcpy(u.nome, entrada);

    printf("Olá, %s!\n", u.nome);
    if (u.admin) {
        printf("Acesso de ADMINISTRADOR liberado (admin = %d).\n", u.admin);
    } else {
        printf("Acesso comum.\n");
    }
    return 0;
}

O scanf("%99s", entrada) está correto: lê com limite, para um array de 100. O erro está no strcpy(u.nome, entrada), que copia sem limite para o u.nome, que só tem 8 bytes. Veja o que acontece com um nome comprido:

$ gcc -std=c23 -Wall -Wextra login.c -o login
$ echo Ana | ./login
Nome: Olá, Ana!
Acesso comum.
$ echo Maximiliano | ./login
Nome: Olá, Maximiliano!
Acesso de ADMINISTRADOR liberado (admin = 7302753).
$ echo $?
0

Ninguém mexeu em admin, e ele virou 7302753: deixou de ser zero e o acesso de administrador foi liberado. O que aconteceu? Na memória, o campo admin vem logo depois dos 8 bytes de nome. "Maximiliano" tem 11 letras + o '\0' = 12 bytes. Os 8 primeiros encheram nome; os 4 que sobraram (a, n, o e o '\0') foram escritos bem em cima de admin. O programa não morreu, não avisou, e passou a dar acesso que não devia. É o estouro de buffer na sua forma mais traiçoeira.

Por que exatamente 7302753?

Os quatro bytes que caíram sobre admin foram 'a' (97), 'n' (110), 'o' (111) e '\0' (0). Lidos como um int, do jeito do processador da Ana, dão 97 + 110×256 + 111×65536 = 7302753. Em outra máquina ou com outro nome, sairia outro número — o único certo é que não é mais zero.

E o AddressSanitizer, que pega estouros tão bem? Aqui ele fica calado: o estouro ficou dentro da mesma struct (de nome para admin), e para o ASan a struct inteira é uma região válida. Ele vigia as bordas de cada variável, não as fronteiras entre os campos de uma struct. Quem pega este caso é o outro fiscal, o FORTIFY (seção 5). No editor do guia, o estouro também passa calado e imprime o mesmo admin = 7302753.

A lição

Um programa que "roda e dá certo" não prova que está livre de estouros. O erro pode estar ali, mudando um valor vizinho, esperando o dia em que o nome é comprido demais. Por isso a defesa não é testar e torcer: é nunca copiar sem limite e rodar os fiscais.


3. strcpy e o "+1" esquecido

O strcpy ("string copy", copiar string) copia da origem até o '\0', caiba ou não no destino. Veja um programa que copia o que veio na linha de comando para um array de 8 bytes:

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
    char item[8];
    if (argc < 2) {
        return 1;
    }
    strcpy(item, argv[1]);
    printf("Item: %s\n", item);
    return 0;
}
$ gcc -std=c23 -Wall -Wextra copia.c -o copia
$ ./copia biscoito
Item: biscoito
$ ./copia "biscoito recheado"
*** stack smashing detected ***: terminated
Abortado                   (imagem do núcleo gravada) ./copia "biscoito recheado"
$ echo $?
134

Repare numa armadilha fina: ./copia biscoito pareceu funcionar. Mas "biscoito" tem 8 letras; com o '\0', são 9 bytes num array de 8. O nono byte foi escrito fora do array, só que caiu num canto que não disparou a trava da pilha. É o pior caso: o programa escreveu onde não devia e tudo parece certo. Esse é o clássico erro do "+1" esquecido — o espaço do '\0' que ninguém somou.

O conserto é o snprintf ("string print, com N"), que escreve no máximo o tamanho do destino e sempre termina a string com '\0':

    char item[8];
    if (argc < 2) {
        return 1;
    }
    snprintf(item, sizeof item, "%s", argv[1]);   // nunca passa de 8 bytes
    printf("Item: %s\n", item);

Agora ./copia "biscoito recheado" guarda só biscoit (7 letras + o '\0') e não estoura nada. Perder o fim do texto é chato, mas é muito melhor que estragar a memória. Em um programa de verdade, você daria ao array um tamanho folgado e ainda avisaria quando o texto não coube.

E o strncpy?

Existe um strncpy(dest, origem, n), mas ele é uma cilada: se a origem tem n ou mais letras, ele não coloca o '\0' no fim, e você fica com uma string sem terminador — outro problema. O snprintf sempre termina a string, por isso é o que o guia recomenda.


4. gets: a função banida

A função mais perigosa de todas foi a gets ("get string"), que lia uma linha inteira sem limite nenhum — não havia nem como dizer o tamanho. Ela foi tão fonte de desastres que o C a marcou como obsoleta no C99 e a removeu da linguagem no C11: é a única função da biblioteca padrão que já foi apagada. No gcc 15, usá-la nem compila em C23:

$ gcc -std=c23 -Wall -Wextra gets.c -o gets
gets.c: In function ‘main’:
gets.c:6:5: error: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
    6 |     gets(nome);
      |     ^~~~
      |     fgets

A aula Ler uma Linha (fgets) conta essa história em detalhe e mostra o fgets, a substituta segura que recebe o tamanho do array. Se você ver gets em um código ou livro antigo, troque por fgets na hora.


5. Os dois fiscais: ASan e FORTIFY

Você não vai achar os estouros lendo o código com os olhos — eles passam calados. Precisa de ferramentas que conferem enquanto o programa roda. Duas são fáceis de ligar, e cada uma pega casos diferentes.

AddressSanitizer: o relatório completo

O -fsanitize=address coloca um fiscal em cada acesso à memória (é a mesma ferramenta da aula Sanitizers, vista aqui pelo lado da segurança). Recompile o nome.c da seção 1 com ele e com -g (para ter arquivo e linha):

$ gcc -std=c23 -Wall -Wextra -g -fsanitize=address nome.c -o nome-asan
$ echo Maximiliano | ./nome-asan
Nome: =================================================================
==415616==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7bed3aaf0028 at pc 0x7fed3d0b05dc bp 0x7fff8af96370 sp 0x7fff8af95af8
WRITE of size 12 at 0x7bed3aaf0028 thread T0
    #0 0x7fed3d0b05db in scanf_common ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_format.inc:346
    #1 0x7fed3d0f0e4e in __isoc23_vscanf ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1586
    #2 0x7fed3d0f1634 in __isoc23_scanf ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1616
    #3 0x562116b362de in main /home/ana/c/seg/nome.c:6
«… a pilha da biblioteca e o mapa da memória …»
  This frame has 1 object(s):
    [32, 40) 'nome' (line 4) <== Memory access at offset 40 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /home/ana/c/seg/nome.c:6 in main
==415616==ABORTING
stack-buffer-overflow
"Estouro de buffer na pilha": um array local foi escrito fora dos limites.
WRITE of size 12
Uma escrita de 12 bytes (as 11 letras de "Maximiliano" + o '\0'). Quem escreveu foi o scanf.
#3 … in main /home/ana/c/seg/nome.c:6
A primeira linha com um arquivo seu: o scanf da linha 6.
[32, 40) 'nome' (line 4)
Qual variável foi estourada: o array nome, declarado na linha 4, de 8 bytes.
SUMMARY … nome.c:6
O resumo em uma linha. Se for ler só uma, leia esta.

Lembre da seção 2: o ASan é ótimo para estourar os limites de uma variável, mas não pega o estouro de um campo de struct para o vizinho. Para esse, entra o segundo fiscal.

FORTIFY_SOURCE: a conferência barata do compilador

O -D_FORTIFY_SOURCE=2 pede ao compilador que troque funções como strcpy, memcpy e sprintf por versões que, quando sabem o tamanho do destino, conferem antes de copiar. É quase de graça (não deixa o programa mais lento como o ASan), mas só funciona com otimização ligada (-O2). Volte ao login da seção 2:

$ gcc -std=c23 -Wall -Wextra -O2 -D_FORTIFY_SOURCE=2 login.c -o login-fort
$ echo Maximiliano | ./login-fort
Nome: *** buffer overflow detected ***: terminated
Abortado                   (imagem do núcleo gravada) ./login-fort
$ echo $?
134

O estouro que o ASan deixou passar e que o programa normal nem percebeu foi pego aqui: *** buffer overflow detected *** ("estouro de buffer detectado"), e o programa parou com código 134, antes de liberar o acesso de administrador. O FORTIFY conhece o tamanho de u.nome (8 bytes, escrito no código) e sabe que o strcpy de 12 bytes não cabe.

No laboratório, o -O2 já liga o FORTIFY sozinho

O Ubuntu 26.04 da Ana vem configurado para ativar o FORTIFY (no nível 3) assim que você compila com -O2, mesmo sem escrever -D_FORTIFY_SOURCE. Por isso, no laboratório, gcc -O2 login.c já aborta com buffer overflow detected. Em outras distribuições isso pode não vir ligado; escrever -D_FORTIFY_SOURCE=2 garante.

Mas o FORTIFY tem um limite: ele só confere as funções que recebem um destino de tamanho conhecido. O scanf("%s") da seção 1 não é uma dessas, então nem o FORTIFY o pega na hora da cópia — ali quem avisa é a trava da pilha (o stack smashing) ou o ASan. Por isso os dois fiscais se completam.

CasoPrograma normal-fsanitize=address-O2 -D_FORTIFY_SOURCE=2
scanf("%s") estoura um array local (seção 1)Às vezes stack smashing, 134Pega: relatório com a linhaNão pega na cópia (ainda pode dar stack smashing)
strcpy de um campo para o vizinho na struct (seção 2)Passa calado: dado estragadoPassa calado (é dentro da struct)Pega: buffer overflow detected, 134
strcpy estoura um array local (seção 3)Às vezes stack smashing, 134Pega: relatório com a linhaPega: buffer overflow detected, 134

Qual usar: durante o desenvolvimento e nos testes, compile com -g -fsanitize=address; na versão que vai para os usuários, deixe -O2 -D_FORTIFY_SOURCE=2 (rápido e ainda protege). Os dois juntos cobrem quase tudo.

No editor do guia, nenhum dos dois roda

O editor compila o seu programa para WebAssembly, e nem a biblioteca do AddressSanitizer nem o FORTIFY existem lá. Pedir -fsanitize=address dá erro de ligação; -D_FORTIFY_SOURCE=2 é aceito mas não faz nada (o login continua imprimindo admin = 7302753). Por isso os estouros desta aula são mostrados no terminal do laboratório, e as missões conferem o seu código. Para rodar os fiscais de verdade, use o seu Linux (veja Sanitizers).

Missão final: a função guardar usa strcpy para copiar o nome digitado para u.nome, de 8 bytes — o estouro da seção 2. Troque o strcpy por snprintf(destino, tam, "%s", origem), que respeita o tamanho. A entrada é Maximiliano; com a cópia segura, o programa guarda só Maximil (7 letras + o '\0') e escreve Guardado: Maximil.

#include <stdio.h>
#include <string.h>

void guardar(char destino[], size_t tam, const char *origem) {
    (void) tam;
    strcpy(destino, origem);
}

int main(void) {
    char nome[8];
    char entrada[100];
    printf("Nome: ");
    if (scanf("%99s", entrada) != 1) {
        return 1;
    }
    guardar(nome, sizeof nome, entrada);
    printf("Guardado: %s\n", nome);
    return 0;
}
#include <stdio.h>
#include <string.h>

void guardar(char destino[], size_t tam, const char *origem) {
    snprintf(destino, tam, "%s", origem);
}

int main(void) {
    char nome[8];
    char entrada[100];
    printf("Nome: ");
    if (scanf("%99s", entrada) != 1) {
        return 1;
    }
    guardar(nome, sizeof nome, entrada);
    printf("Guardado: %s\n", nome);
    return 0;
}

Quando dá errado

*** stack smashing detected ***: terminated

O que aconteceu: um array local foi escrito além do fim, e a trava da pilha percebeu no fim da função. Quase sempre é um scanf("%s"), strcpy ou strcat sem limite, ou um laço com <= no lugar de <.

Como resolver: recompile com -g -fsanitize=address para ver a linha exata, e troque a função perigosa pela versão com tamanho (%29s, snprintf).

*** buffer overflow detected ***: terminated

O que aconteceu: o FORTIFY (ligado por -O2 -D_FORTIFY_SOURCE=2, ou pelo -O2 sozinho no laboratório) pegou uma cópia que não cabia no destino. É uma mensagem diferente do stack smashing: esta vem do fiscal do compilador, não da trava da pilha.

Como resolver: veja qual strcpy/memcpy/sprintf copiou demais e passe o tamanho do destino.

O programa "funciona", mas uma variável muda sozinha
$ echo Maximiliano | ./login
Nome: Olá, Maximiliano!
Acesso de ADMINISTRADOR liberado (admin = 7302753).

O que aconteceu: um estouro silencioso escreveu por cima da variável vizinha (seção 2). O ASan não pega quando é dentro de uma struct.

Como resolver: compile com -O2 -D_FORTIFY_SOURCE=2, que pega esse caso, e conserte a cópia sem limite.

gcc: error: implicit declaration of function ‘gets’

O que aconteceu: o código usa gets, que foi removida do C no C11 (seção 4).

Como resolver: troque por fgets(nome, sizeof nome, stdin) (veja fgets).


Referência rápida

ItemO que é
scanf("%29s", nome)Lê uma palavra com limite: um a menos que o tamanho do array.
snprintf(dest, sizeof dest, "%s", origem)Copia/formata sem estourar e sempre termina com '\0'.
fgets(linha, sizeof linha, stdin)Lê uma linha inteira com limite; substitui o gets.
gets, strcpy, strcat, sprintf, scanf("%s")Sem limite: evite ou dê sempre o tamanho do destino.
-g -fsanitize=addressFiscal que pega estouros de array com a linha exata (no desenvolvimento).
-O2 -D_FORTIFY_SOURCE=2Conferência barata do compilador; pega até estouro entre campos de struct (na versão final).
*** stack smashing detected ***A trava da pilha pegou um estouro de array local (134).
*** buffer overflow detected ***O FORTIFY pegou uma cópia grande demais (134).

Exercícios

1. Ler uma palavra com segurança

O array é char nome[20]. Complete o formato do scanf para nunca passar do fim:

scanf("%s", nome);

2. Copiar sem estourar

Complete a função que copia origem para destino respeitando o tamanho:

(destino, sizeof destino, "%s", origem);

3. O fiscal da versão final

Complete as duas opções que ligam o FORTIFY (uma delas também liga a otimização):

gcc -std=c23 -Wall -Wextra -O2 prog.c -o prog

4. Por que strcpy(destino, origem) é perigoso?

5. Um programa leu um nome comprido, não deu erro nenhum, mas uma variável vizinha mudou de valor. O que foi?

6. Qual fiscal pega o estouro de um campo de struct para o campo vizinho?


Resumo

  • Um estouro de buffer é escrever além do fim de um array. O C não impede, e o erro pode derrubar o programa, estragar um dado vizinho em silêncio ou abrir a porta para um ataque.
  • A causa é quase sempre copiar texto sem o tamanho do destino: gets, strcpy, strcat, sprintf, scanf("%s").
  • As versões seguras recebem o tamanho: %29s, snprintf(dest, sizeof dest, ...), fgets. O gets foi removido do C no C11.
  • Um programa que "roda e dá certo" não prova nada: o estouro pode estar lá, calado.
  • -g -fsanitize=address pega estouros de array com a linha exata; -O2 -D_FORTIFY_SOURCE=2 é barato e pega até estouro entre campos de struct. Use o ASan no desenvolvimento e o FORTIFY na versão final.
  • No editor do guia, os dois fiscais não funcionam; rode-os no seu Linux.

Próxima aula: Boas Práticas.