Como funciona a vulnerabilidade do JPEG of Death?

94

Tenho lido sobre um antigo exploit contra GDI + no Windows XP e Windows Server 2003 chamado JPEG da morte para um projeto no qual estou trabalhando.

A exploração é bem explicada no seguinte link: http://www.infosecwriters.com/text_resources/pdf/JPEG.pdf

Basicamente, um arquivo JPEG contém uma seção chamada COM contendo um campo de comentário (possivelmente vazio) e um valor de dois bytes contendo o tamanho do COM. Se não houver comentários, o tamanho é 2. O leitor (GDI +) lê o tamanho, subtrai dois e aloca um buffer do tamanho apropriado para copiar os comentários no heap. O ataque envolve colocar um valor de 0no campo. GDI + subtrai 2, levando a um valor -2 (0xFFFe)que é convertido em um inteiro não assinado 0XFFFFFFFEpor memcpy.

Código de amostra:

unsigned int size;
size = len - 2;
char *comment = (char *)malloc(size + 1);
memcpy(comment, src, size);

Observe que malloc(0)na terceira linha deve retornar um ponteiro para a memória não alocada no heap. Como escrever 0XFFFFFFFEbytes ( 4GB!!!!) não pode travar o programa? Isso grava além da área de heap e no espaço de outros programas e do sistema operacional? O que acontece depois?

Pelo que entendi memcpy, ele simplesmente copia ncaracteres do destino para a fonte. Nesse caso, a origem deve estar na pilha, o destino no heap e nestá 4GB.

Rafa
fonte
malloc alocará memória do heap. Acho que o exploit foi feito antes do memcpy e depois que a memória foi alocada
iedoc
assim como uma nota lateral: é não memcpy que promove o valor para um inteiro sem sinal (4 bytes), mas sim a subtração.
rev
1
Atualizei minha resposta anterior com um exemplo ao vivo. O malloctamanho ed é de apenas 2 bytes em vez de 0xFFFFFFFE. Este tamanho enorme é usado apenas para o tamanho da cópia, não para o tamanho da alocação.
Neitsa de

Respostas:

96

Essa vulnerabilidade foi definitivamente um estouro de heap .

Como escrever 0XFFFFFFFE bytes (4 GB !!!!) pode não travar o programa?

Provavelmente sim, mas em algumas ocasiões você teve tempo para explorar antes que o travamento acontecesse (às vezes, você pode fazer o programa voltar à sua execução normal e evitar o travamento).

Quando o memcpy () inicia, a cópia sobrescreverá alguns outros blocos de heap ou algumas partes da estrutura de gerenciamento de heap (por exemplo, lista livre, lista ocupada, etc.).

Em algum ponto, a cópia encontrará uma página não alocada e acionar um AV (violação de acesso) na gravação. O GDI + tentará então alocar um novo bloco no heap (consulte ntdll! RtlAllocateHeap ) ... mas as estruturas do heap estão agora todas bagunçadas.

Nesse ponto, ao criar cuidadosamente sua imagem JPEG, você pode sobrescrever as estruturas de gerenciamento de heap com dados controlados. Quando o sistema tenta alocar o novo bloco, provavelmente irá desvincular um bloco (livre) da lista livre.

Os blocos são gerenciados com (notavelmente) ponteiros flink (Link para a frente; o próximo bloco na lista) e piscas (Link para trás; o bloco anterior na lista). Se você controlar o flink e o blink, poderá ter uma possível WRITE4 (condição de escrever o quê / onde), onde controla o que pode escrever e onde pode escrever.

Nesse ponto, você pode sobrescrever um ponteiro de função (os ponteiros SEH [Structured Exception Handlers] eram um alvo de escolha naquela época em 2004) e obter a execução do código.

Veja a postagem do blog Corrupção de pilha: um estudo de caso .

Nota: embora eu tenha escrito sobre a exploração usando o freelist, um invasor pode escolher outro caminho usando outros metadados de heap ("metadados de heap" são estruturas usadas pelo sistema para gerenciar o heap; flink e blink fazem parte dos metadados de heap), mas a exploração de desvinculação é provavelmente a "mais fácil". Uma pesquisa no Google por "exploração de heap" retornará vários estudos sobre isso.

Isso grava além da área de heap e no espaço de outros programas e do sistema operacional?

Nunca. Os sistemas operacionais modernos são baseados no conceito de espaço de endereço virtual para que cada processo tenha seu próprio espaço de endereço virtual que permite endereçar até 4 gigabytes de memória em um sistema de 32 bits (na prática, você só tem a metade dele no terreno do usuário, o resto é para o kernel).

Resumindo, um processo não pode acessar a memória de outro processo (exceto se solicitar ao kernel por meio de algum serviço / API, mas o kernel verificará se o chamador tem o direito de fazê-lo).


Decidi testar essa vulnerabilidade neste fim de semana, para que pudéssemos ter uma boa ideia do que estava acontecendo, em vez de pura especulação. A vulnerabilidade agora tem 10 anos, então pensei que não havia problema em escrever sobre ela, embora não tenha explicado a parte da exploração nesta resposta.

Planejamento

A tarefa mais difícil foi encontrar um Windows XP com apenas SP1, como era em 2004 :)

Em seguida, baixei uma imagem JPEG composta apenas por um único pixel, conforme mostrado abaixo (corte para abreviar):

File 1x1_pixel.JPG
Address   Hex dump                                         ASCII
00000000  FF D8 FF E0|00 10 4A 46|49 46 00 01|01 01 00 60| ÿØÿà JFIF  `
00000010  00 60 00 00|FF E1 00 16|45 78 69 66|00 00 49 49|  `  ÿá Exif  II
00000020  2A 00 08 00|00 00 00 00|00 00 00 00|FF DB 00 43| *          ÿÛ C
[...]

Uma imagem JPEG é composta por marcadores binários (que introduzem segmentos). Na imagem acima, FF D8é o marcador SOI (Start Of Image), enquanto FF E0, por exemplo, é um marcador de aplicativo.

O primeiro parâmetro em um segmento de marcador (exceto alguns marcadores como SOI) é um parâmetro de comprimento de dois bytes que codifica o número de bytes no segmento de marcador, incluindo o parâmetro de comprimento e excluindo o marcador de dois bytes.

Simplesmente adicionei um marcador COM (0x FFFE) logo após o SOI, já que os marcadores não têm uma ordem estrita.

File 1x1_pixel_comment_mod1.JPG
Address   Hex dump                                         ASCII
00000000  FF D8 FF FE|00 00 30 30|30 30 30 30|30 31 30 30| ÿØÿþ  0000000100
00000010  30 32 30 30|30 33 30 30|30 34 30 30|30 35 30 30| 0200030004000500
00000020  30 36 30 30|30 37 30 30|30 38 30 30|30 39 30 30| 0600070008000900
00000030  30 61 30 30|30 62 30 30|30 63 30 30|30 64 30 30| 0a000b000c000d00
[...]

O comprimento do segmento COM é definido 00 00para acionar a vulnerabilidade. Eu também injetei bytes 0xFFFC logo após o marcador COM com um padrão recorrente, um número de 4 bytes em hexadecimal, que será útil ao "explorar" a vulnerabilidade.

Depurando

Clicar duas vezes na imagem acionará imediatamente o bug no shell do Windows (também conhecido como "explorer.exe"), em algum lugar gdiplus.dll, em uma função chamada GpJpegDecoder::read_jpeg_marker().

Esta função é chamada para cada marcador na imagem, ela simplesmente: lê o tamanho do segmento do marcador, aloca um buffer cujo comprimento é o tamanho do segmento e copia o conteúdo do segmento neste buffer recém-alocado.

Aqui está o início da função:

.text:70E199D5  mov     ebx, [ebp+arg_0] ; ebx = *this (GpJpegDecoder instance)
.text:70E199D8  push    esi
.text:70E199D9  mov     esi, [ebx+18h]
.text:70E199DC  mov     eax, [esi]      ; eax = pointer to segment size
.text:70E199DE  push    edi
.text:70E199DF  mov     edi, [esi+4]    ; edi = bytes left to process in the image

eaxregister aponta para o tamanho do segmento e edié o número de bytes restantes na imagem.

O código então prossegue para ler o tamanho do segmento, começando pelo byte mais significativo (o comprimento é um valor de 16 bits):

.text:70E199F7  xor     ecx, ecx        ; segment_size = 0
.text:70E199F9  mov     ch, [eax]       ; get most significant byte from size --> CH == 00
.text:70E199FB  dec     edi             ; bytes_to_process --
.text:70E199FC  inc     eax             ; pointer++
.text:70E199FD  test    edi, edi
.text:70E199FF  mov     [ebp+arg_0], ecx ; save segment_size

E o byte menos significativo:

.text:70E19A15  movzx   cx, byte ptr [eax] ; get least significant byte from size --> CX == 0
.text:70E19A19  add     [ebp+arg_0], ecx   ; save segment_size
.text:70E19A1C  mov     ecx, [ebp+lpMem]
.text:70E19A1F  inc     eax             ; pointer ++
.text:70E19A20  mov     [esi], eax
.text:70E19A22  mov     eax, [ebp+arg_0] ; eax = segment_size

Feito isso, o tamanho do segmento é usado para alocar um buffer, seguindo este cálculo:

alloc_size = segment_size + 2

Isso é feito pelo código abaixo:

.text:70E19A29  movzx   esi, word ptr [ebp+arg_0] ; esi = segment size (cast from 16-bit to 32-bit)
.text:70E19A2D  add     eax, 2 
.text:70E19A30  mov     [ecx], ax 
.text:70E19A33  lea     eax, [esi+2] ; alloc_size = segment_size + 2
.text:70E19A36  push    eax             ; dwBytes
.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)

No nosso caso, como o tamanho do segmento é 0, o tamanho alocado para o buffer é 2 bytes .

A vulnerabilidade está logo após a alocação:

.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)
.text:70E19A3C  test    eax, eax
.text:70E19A3E  mov     [ebp+lpMem], eax ; save pointer to allocation
.text:70E19A41  jz      loc_70E19AF1
.text:70E19A47  mov     cx, [ebp+arg_4]   ; low marker byte (0xFE)
.text:70E19A4B  mov     [eax], cx         ; save in alloc (offset 0)
;[...]
.text:70E19A52  lea     edx, [esi-2]      ; edx = segment_size - 2 = 0 - 2 = 0xFFFFFFFE!!!
;[...]
.text:70E19A61  mov     [ebp+arg_0], edx

O código simplesmente subtrai o tamanho do segmento (o comprimento do segmento é um valor de 2 bytes) do tamanho do segmento inteiro (0 em nosso caso) e termina com um underflow inteiro: 0 - 2 = 0xFFFFFFFE

O código então verifica se há bytes restantes para analisar na imagem (o que é verdadeiro) e, em seguida, pula para a cópia:

.text:70E19A69  mov     ecx, [eax+4]  ; ecx = bytes left to parse (0x133)
.text:70E19A6C  cmp     ecx, edx      ; edx = 0xFFFFFFFE
.text:70E19A6E  jg      short loc_70E19AB4 ; take jump to copy
;[...]
.text:70E19AB4  mov     eax, [ebx+18h]
.text:70E19AB7  mov     esi, [eax]      ; esi = source = points to segment content ("0000000100020003...")
.text:70E19AB9  mov     edi, dword ptr [ebp+arg_4] ; edi = destination buffer
.text:70E19ABC  mov     ecx, edx        ; ecx = copy size = segment content size = 0xFFFFFFFE
.text:70E19ABE  mov     eax, ecx
.text:70E19AC0  shr     ecx, 2          ; size / 4
.text:70E19AC3  rep movsd               ; copy segment content by 32-bit chunks

O trecho acima mostra que o tamanho da cópia é 0xFFFFFFFE pedaços de 32 bits. O buffer de origem é controlado (conteúdo da imagem) e o destino é um buffer no heap.

Condição de escrita

A cópia irá disparar uma exceção de violação de acesso (AV) quando atingir o final da página de memória (isso pode ser do ponteiro de origem ou do ponteiro de destino). Quando o AV é disparado, o heap já está em um estado vulnerável porque a cópia já substituiu todos os blocos de heap seguintes até que uma página não mapeada foi encontrada.

O que torna esse bug explorável é que 3 SEH (Structured Exception Handler; este é tentar / exceto em baixo nível) estão capturando exceções nesta parte do código. Mais precisamente, o primeiro SEH desenrolará a pilha de forma que volte para analisar outro marcador JPEG, ignorando completamente o marcador que acionou a exceção.

Sem um SEH, o código teria simplesmente travado todo o programa. Portanto, o código ignora o segmento COM e analisa outro segmento. Então, voltamos GpJpegDecoder::read_jpeg_marker()com um novo segmento e quando o código aloca um novo buffer:

.text:70E19A33  lea     eax, [esi+2] ; alloc_size = semgent_size + 2
.text:70E19A36  push    eax             ; dwBytes
.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)

O sistema irá desvincular um bloco da lista livre. Acontece que as estruturas de metadados foram substituídas pelo conteúdo da imagem; portanto, controlamos a desvinculação com metadados controlados. O código abaixo está em algum lugar do sistema (ntdll) no gerenciador de heap:

CPU Disasm
Address   Command                                  Comments
77F52CBF  MOV ECX,DWORD PTR DS:[EAX]               ; eax points to '0003' ; ecx = 0x33303030
77F52CC1  MOV DWORD PTR SS:[EBP-0B0],ECX           ; save ecx
77F52CC7  MOV EAX,DWORD PTR DS:[EAX+4]             ; [eax+4] points to '0004' ; eax = 0x34303030
77F52CCA  MOV DWORD PTR SS:[EBP-0B4],EAX
77F52CD0  MOV DWORD PTR DS:[EAX],ECX               ; write 0x33303030 to 0x34303030!!!

Agora podemos escrever o que quisermos, onde quisermos ...

Neitsa
fonte
3

Como não conheço o código da GDI, o que está abaixo é apenas especulação.

Bem, uma coisa que me vem à mente é um comportamento que notei em alguns sistemas operacionais (não sei se o Windows XP tinha isso) foi ao alocar com novo / malloc, você pode realmente alocar mais do que sua RAM, desde que você não escreve para aquela memória.

Este é realmente um comportamento do kernel do Linux.

De www.kernel.org:

As páginas no espaço de endereço linear do processo não são necessariamente residentes na memória. Por exemplo, as alocações feitas em nome de um processo não são satisfeitas imediatamente, pois o espaço é apenas reservado no vm_area_struct.

Para entrar na memória residente, uma falha de página deve ser acionada.

Basicamente, você precisa sujar a memória antes de realmente ser alocada no sistema:

  unsigned int size=-1;
  char* comment = new char[size];

Às vezes, ele não fará uma alocação real na RAM (seu programa ainda não usará 4 GB). Eu sei que já vi esse comportamento em um Linux, mas não posso, entretanto, replicá-lo agora na minha instalação do Windows 7.

Partindo desse comportamento, o cenário a seguir é possível.

Para fazer com que essa memória exista na RAM, você precisa sujá-la (basicamente memset ou algum outro tipo de gravação):

  memset(comment, 0, size);

No entanto, a vulnerabilidade explora um estouro de buffer, não uma falha de alocação.

Em outras palavras, se eu tivesse isso:

 unsinged int size =- 1;
 char* p = new char[size]; // Will not crash here
 memcpy(p, some_buffer, size);

Isso levará a uma gravação após o buffer, porque não existe um segmento de 4 GB de memória contínua.

Você não colocou nada em p para sujar os 4 GB de memória, e não sei se memcpysuja a memória de uma vez, ou apenas página por página (acho que é página por página).

Eventualmente, ele acabará sobrescrevendo o quadro de pilha (Stack Buffer Overflow).

Outra vulnerabilidade mais possível seria se a imagem fosse mantida na memória como um array de bytes (ler o arquivo inteiro no buffer) e o tamanho dos comentários fosse usado apenas para pular informações não vitais.

Por exemplo

     unsigned int commentsSize = -1;
     char* wholePictureBytes; // Has size of file
     ...
     // Time to start processing the output color
     char* p = wholePictureButes;
     offset = (short) p[COM_OFFSET];
     char* dataP = p + offset;
     dataP[0] = EvilHackerValue; // Vulnerability here

Como você mencionou, se o GDI não alocar esse tamanho, o programa nunca travará.

MichaelCMS
fonte
4
Isso poderia ser com um sistema de 64 bits, onde 4 GB não é um grande problema (falando sobre espaço adicional). Mas em um sistema de 32 bits (eles também parecem vulneráveis), você não pode reservar 4 GB de espaço de endereço, porque isso seria tudo o que existe! Portanto, um malloc(-1U)certamente falhará, retornará NULLe memcpy()quebrará.
rodrigo
9
Não acho que esta linha seja verdadeira: "Eventualmente, ele acabará gravando em outro endereço de processo." Normalmente, um processo não pode acessar a memória de outro. Veja os benefícios do MMU .
chue x
Benefícios da @MMU sim, você está certo. Eu deveria dizer que ultrapassará os limites normais do heap e começará a sobrescrever o frame da pilha. Vou editar minha resposta, obrigado por apontá-la.
MichaelCMS