Contextualização

O tema da SECCOM (Semana Acadêmica de Ciência da Computação e Sistemas de Informação da UFSC) 2023 foi segurança e criptografia. Durante o evento, fui convidado para palestrar e ajudar a organizar o Hackathon, que neste ano seria focado em CTFs de Cibersegurança e teria mais de 800 reais em premiação para as equipes com melhor colocação.

Além de atuar como jurado do Hackathon (junto de uma Professora e uma veterana do LabSEC da UFSC), eu fiz apresentações na abertura e no encerramento do evento. Na abertura, eu fiz uma breve apresentação da sub-área de cibersegurança e ethical (white-hat) hacking, seguida de uma demonstração (da resolução) de um CTF envolvendo OSINT, forense digital, crypto, engenharia reversa e password cracking.

O objetivo do CTF de demonstração era apresentar algumas técnicas e ferramentas para os alunos de Computação, que normalmente não têm contato com esse tipo de prática no curso. Além disso, eles poderiam começar a pensar em como iriam criar seus próprios desafios para o Hackathon.

No fim da abertura, deixei um desafio para as equipes que quisessem se aventurar a resolver um CTF um pouco mais difícil (e ganhar pontos extras no Hackathon). Haviam 5 flags a serem capturadas no desafio, que estava 100% contido no arquivo ctf.zip, presente no 1o commit do repositório github.com/baioc/seccom-ctf. O formato das flags também foi combinado: elas seguiriam a expressão regular cco{\w+}, por exemplo cco{example_flag0}.

Sugeri a utilização do Kali Linux para resolver o problema, incrementando uma instalação base da versão 2023.3 com os seguintes pacotes adicionais:

$ sudo apt update
$ sudo apt install htop libreoffice gdb xxd ghidra dislocker
$ pip install pycryptodome

Writeup do desafio

O texto que segue é um writeup (um “detonado”) do CTF de desafio. Obviamente, ele contém spoilers. Se você está lendo isso mas planeja tentar resolver o CTF sozinho, não continue a leitura.

Disclaimer

  • Toda a exposição de conteúdo nesta apresentação é feita de forma voluntária e não implica na sua aprovação por qualquer outra entidade.
  • Demonstrações serão realizadas para fins educacionais; replicar esses procedimentos em sistemas de terceiros pode ser ilegal (vide artigos 154-A e 154-B do Código Penal).
  • Não existem garantias associadas aos procedimentos demonstrados; o autor não será responsabilizado por qualquer dano originado deles.

Escondido à vista de todos

Ao tentar descompactar o arquivo ctf.zip, vemos:

  • Um arquivo de texto flag.txt, supostamente contendo nossa primeira flag
  • Um arquivo snapshot.dd com cerca de 90 MB
  • Uma página da web salva para visualização offline, noticias-ufsc.html e noticias-ufsc_files/

Extrair os arquivos não é tão simples, pois o pacote zip foi protegido com senha:

zip-password

A ferramenta unzip pode nos fornecer mais informações sobre o pacote, incluindo algoritmos e taxas de compressão, bem como comentários associados ao pacote e a cada arquivo individual. Nenhuma dessas informações é protegida. Logo de cara, vemos um comentário associado ao arquivo noticias-ufsc.html:

fake-news-btw

Mais adiante na lista de arquivos, vemos um comentário suspeito associado ao arquivo jquery.js:

zip-file-comment

Com a senha “5up4h 57r0nk p455wD”, conseguimos extrair o pacote e obter a 1a flag do desafio:

$ cat flag.txt
=>
cco{pr3P4r3_t0_CRy}

Criptografia quase perfeita

Analisando a página web, vemos que é um rascunho de artigo para o “Notícias UFSC”. O artigo noticia um ataque cibernético sofrido pela SeTIC/UFSC, durante o qual um atacante invadiu a rede da instituição, roubou informações do computador de um dos professores da UFSC e ativou um ransomware para cifrar dados da SeTIC.

noticias-ufsc

Sem encontrar nada de interessante na página web, passamos ao arquivo snapshot.dd. Embora o exiftool não consiga identificar o tipo do arquivo, o file reporta uma tabela de partições do tipo DOS/MBR, juntamente de pelo menos uma partição iniciando no setor 2048:

$ exiftool snapshot.dd
=>
ExifTool Version Number         : 12.65
File Name                       : snapshot.dd
Directory                       : .
File Size                       : 94 MB
File Modification Date/Time     : 2023:11:06 00:31:27-05:00
File Access Date/Time           : 2023:11:15 14:41:26-05:00
File Inode Change Date/Time     : 2023:11:15 14:18:32-05:00
File Permissions                : -rw-r--r--
Error                           : Unknown file type

$ file snapshot.dd
=>
snapshot.dd: DOS/MBR boot sector; partition 1 : ID=0xb, start-CHS (0x4,4,1), end-CHS (0x169,104,2), startsector 2048, 182272 sectors

Sabendo que o arquivo é uma imagem de disco (supostamente criado pelo dd, como indica a extensão), podemos utilizar algumas ferramentas do Sleuth Kit para obter mais detalhes sobre o volume:

$ mmls snapshot.dd
=>
DOS Partition Table
Offset Sector: 0
Units are in 512-byte sectors

      Slot      Start        End          Length       Description
000:  Meta      0000000000   0000000000   0000000001   Primary Table (#0)
001:  -------   0000000000   0000002047   0000002048   Unallocated
002:  000:000   0000002048   0000184319   0000182272   Win95 FAT32 (0x0b)

Vemos então uma partição FAT32 allocada a partir do setor 2048. Para montar essa partição diretamente no nosso sistema, basta informar ao mount o offset correto:

$ mkdir mnt

$ sudo mount -o offset=$((512*2048)) snapshot.dd mnt/

$ ls -lh mnt/
=>
total 512
-rwxr-xr-x 1 root root 17 Nov  6 00:28 flag.otp

$ cat mnt/flag.otp
=>
▒�������`7qEJ��

$ ls -lah mnt/
=>
total 5.5K
drwxr-xr-x 2 root root  512 Dec 31  1969 .
drwxr-xr-x 4 kali kali 4.0K Nov 15 14:55 ..
-rwxr-xr-x 1 root root  177 Nov  6 00:30 .bash_history
-rwxr-xr-x 1 root root   17 Nov  6 00:28 flag.otp

A princípio, vemos apenas um arquivo flag.otp na partição montada. Infelizmente, o conteúdo deste arquivo não é legível no momento.

Em seguida, listamos os arquivos novamente, mas desta vez com a flag -a passada ao programa ls, que faz com que ele liste arquivos escondidos. Percebemos então um arquivo .bash_history, com o seguinte conteúdo:

whoami
ls -lh
echo 'Lorem ipsum dolor sit amet' | /tmp/one_time_pad > test.otp
hexdump -C test.otp
rm test.otp
/tmp/one_time_pad > flag.otp
ls -la
rm /tmp/one_time_pad
poweroff

O arquivo .bash_history contém, como seu nome indica, o histórico de comandos executados em uma shell do bash. Neste caso, vemos que o arquivo flag.otp foi gerado com um certo programa one_time_pad. Um pouco antes, o mesmo programa é testado, supostamente lendo a entrada “Lorem ipsum dolor sit amet” e gerando o arquivo test.otp. Outra coisa importante que podemos ver é que o programa one_time_pad foi deletado logo depois da criação da flag e antes do desligamento do sistema.

Mesmo sem conhecer o One-Time Pad (OTP), podemos consultar a Wikipedia e entender que é um algoritmo de criptografia que, quando utilizado corretamente, é teoricamente impossível de quebrar. O algoritmo em si é bem simples, com uma única operação XOR (\(\oplus\)) entre os bits da mensagem a ser cifrada (\(M\)) e uma chave (\(K\)) de uso único: \(C = K \oplus M\)

Na prática, utilizar o OTP corretamente é difícil, e a Wikipedia lista alguns erros comuns:

one-time-pad

Não sabemos como a chave foi gerada, mas com base no histórico podemos ver que foi destruída imediatamente (supostamente ela está contida no programa one_time_pad) depois de cifrar a flag. Todavia, vemos também que a chave parece ter sido reutilizada, já que foi primeiro usada para realizar um teste. Com base no histórico, sabemos a string de teste do OTP, mas o cyphertext do teste parece ter sido deletado.

Como temos uma imagem de disco, podemos utilizar outras ferramentas de forense digital do Sleuth Kit para buscar mais informações. Em especial, o fls pode ser usado para listar arquivos na partição, inclusive se foram deletados:

$ fls -r -o 2048 snapshot.dd
=>
r/r 3:  TEMP        (Volume Label Entry)
r/r 5:  .bash_history
r/r * 8:        .incidente.tar
r/r * 10:       test.otp
r/r 12: flag.otp
r/r * 15:       ..bash_history.swp
v/v 2870771:    $MBR
v/v 2870772:    $FAT1
v/v 2870773:    $FAT2
V/V 2870774:    $OrphanFiles

Em seguida, o icat permite recuperar esses arquivos, bastando infomar o número de inode de cada um deles. Assim, conseguimos extrair dois arquivos adicionais que haviam sido deletados, .incidente.tar e test.otp:

$ icat -o 2048 snapshot.dd 8 > snapshot/incidente.tar

$ icat -o 2048 snapshot.dd 10 > snapshot/test.otp

$ ls -lah snapshot/
=>
total 70M
drwxr-xr-x 2 kali kali 4.0K Nov 15 15:37 .
drwxr-xr-x 4 kali kali 4.0K Nov 15 15:36 ..
-rw-r--r-- 1 kali kali  177 Nov 15 15:36 bash_history
-rw-r--r-- 1 kali kali   17 Nov 15 15:37 flag.otp
-rw-r--r-- 1 kali kali  70M Nov 15 15:36 incidente.tar
-rw-r--r-- 1 kali kali   27 Nov 15 15:36 test.otp

Entendendo o algoritmo One-Time Pad e sabendo que, na operação XOR:

  • \(0\) é o elemento identidade, ou seja, para todo \(X\) temos \(X \oplus 0 = X\)
  • O inverso de todo elemento é ele mesmo, ou seja, \(X \oplus X = 0\)
  • A operação é associativa e comutativa

Então, para recuperar a chave OTP e decifrar a flag, basta seguir a lógica:

\[C_{test} = K \oplus M_{test} \\ \implies K = M_{test} \oplus C_{test}\] \[C_{flag} = K \oplus M_{flag} \\ \implies M_{flag} = K \oplus C_{flag}\]

Primeiramente, utilizei o xxd (infelizmente, não vem instalado por padrão no Kali Linux) para codificar os cyphertexts em hexadecimal / base16:

$ xxd -p flag.otp
=>
188cb3abd011f6b5db60377115454a8b95

$ xxd -p test.otp
=>
3780aeb58f6ecbf4c5266f0e011e6299ed7bb046eeab85a219f44e

Em seguida, podemos utilizar o Python para decodificar o hexadecimal e reverter o One-Time Pad, capturando então a nossa segunda flag:

>>> flag = bytes.fromhex('188cb3abd011f6b5db60377115454a8b95')
>>>
>>> test = bytes.fromhex('3780aeb58f6ecbf4c5266f0e011e6299ed7bb046eeab85a219f44e')
>>> plain = 'Lorem ipsum dolor sit amet'.encode()
>>>
>>> def xor(A, B):
...     return bytearray(a ^ b for a, b in zip(A, B))
...
>>> key = xor(plain, test)
>>> result = xor(key, flag)
>>>
>>> print(result.decode())

cco{2_T1m35_p4D}

Engenharia reversa

Agora, voltamos nossa atenção ao arquivo incidente.tar, que também havia sido deletado mas que conseguimos recuperar com o Sleuth Kit. Depois de confirmar que o arquivo é, de fato, um pacote TAR, vamos analisar seu conteúdo:

$ tar -tvf incidente.tar
=>
-rwxr-xr-x kali/kali     16472 2023-11-05 23:54 incidente/encrypt
-rw-r--r-- kali/kali       731 2023-11-04 17:45 incidente/email.txt
-rw-r--r-- kali/kali  72755795 2023-11-05 23:55 incidente/rec.pdf
drwxr-xr-x kali/kali         0 2023-11-05 23:55 incidente/

$ tar -xf incidente.tar

$ ls -lah incidente
=>
total 70M
drwxr-xr-x 2 kali kali 4.0K Nov  5 23:55 .
drwxr-xr-x 3 kali kali 4.0K Nov 15 16:11 ..
-rw-r--r-- 1 kali kali  731 Nov  4 17:45 email.txt
-rwxr-xr-x 1 kali kali  17K Nov  5 23:54 encrypt
-rw-r--r-- 1 kali kali  70M Nov  5 23:55 rec.pdf

$ file incidente/*
=>
incidente/email.txt: Unicode text, UTF-8 text
incidente/encrypt:   ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b7c6568f2470f831dfaf12c44a8ef45185f8283e, for GNU/Linux 3.2.0, not stripped
incidente/rec.pdf:   PDF document, version \235.\221

O conteúdo do arquivo email.txt parece ser um rascunho de mensagem endereçada ao prof. Jean, do Laboratório de Segurança da UFSC (LabSEC):

Boa noite Prof. Jean,


Acho que conseguimos uma pista sobre o ataque.
Mesmo assim, vamos precisar da sua ajuda.

Como você já sabe, os hackers ativaram o ransomware depois da exfiltração de dados.
A SeTIC nos informou que chegou a capturar o trafego na rede comprometida,
mas esses registros foram cifrados também.

Conseguimos identificar 2 arquivos criados na maquina intermediária usada pelos hackers.
A hora de criação coincide com a do vazamento, então estamos esperançosos.
Um dos arquivos parece ser um PDF, mas não conseguimos visualizar.
O outro é um executável que achamos que pode ter sido usado para cifrar os registros da SeTIC.

Estou enviando ambos os arquivos em anexo.


Contamos com sua ajuda.
Att,

Bem como indicado na mensagem, parece ter algo de errado com o arquivo rec.pdf, pois não conseguimos visualizá-lo:

pdf-corrupted

Note também que o PDF pesa aproximadamente 70 MB. Livros inteiros em PDF não costumam passar muito de 20 MB, então o tamanho do arquivo é suspeito.

Segundo o email, o executável encrypt pode ter sido usado pelos hackers para cifrar registros da SeTIC. Por questões óbvias de segurança, não pretendemos executar o programa; afinal, não temos a mínima ideia do dano que ele pode causar no nosso sistema.

Felizmente, podemos contar com a ajuda do Ghidra (também não vem instalado por padrão no Kali) para tentar entender o que esse executável fez. Depois de criar um novo projeto e importar o executável encrypt no Ghidra, vemos a decompilação do programa, ou seja, uma reconstrução estimada do código original (supondo que foi escrito em C):

ghidra-decompile

Mesmo com nomes de variáveis feios e formatação duvidosa, conseguimos ter uma ideia do que o programa faz (e as mensagens de log são especialmente descritivas):

  • O programa precisa ser executado com pelo menos 2 argumentos.
    1. O primeiro argumento é convertido para um número, e printado como um byte.
      • A mensagem de log indica que esse byte é uma chave de criptografia.
    2. O segundo argumento parece ser um path para um arquivo a ser criptografado.
  • O programa le uma flag da entrada padrão.
  • O programa chama uma função chamada encrypt, que recebe pelo menos 2 argumentos.
    • É possível que existam mais argumentos, mas o Ghidra não detectou automaticamente.
  • Por fim, o programa fecha o arquivo original e é finalizado

Os logs da função encrypt indicam que:

  • Será emitido um magic number enganoso
  • A flag crifrada será escrita em seguida
  • Por fim, os conteúdos cifrados do arquivo serão emitidos

Segue uma listagem completa do código decompilado pelo Ghidra (alterei apenas a assinatura do main):

int main(int argc,char **argv)
{
  int iVar1;
  char *pcVar2;
  size_t sVar3;
  char local_138 [272];
  long local_28;
  FILE *local_20;
  char *local_18;
  byte local_9;

  fwrite("*** encRIPt0r ***\n",1,0x12,stderr);
  if (argc < 3) {
     fwrite("Not enough args!\n",1,0x11,stderr);
     iVar1 = 1;
  }
  else {
     iVar1 = atoi(argv[1]);
     local_9 = (byte)iVar1;
     fprintf(stderr,"Using encryption key 0x%02X ...\n",(ulong)local_9);
     local_18 = argv[2];
     fprintf(stderr,"... to encrypt file \'%s\' (remember to shred the original)\n",local_18);
     local_20 = fopen(local_18,"rb");
     if (local_20 == (FILE *)0x0) {
        fwrite("File does not exist!\n",1,0x15,stderr);
        iVar1 = 2;
     }
     else {
        fwrite("Reading flag from stdin ...\n",1,0x1c,stderr);
        pcVar2 = fgets(local_138,0x100,stdin);
        if (pcVar2 == (char *)0x0) {
           fwrite("Invalid flag!\n",1,0xe,stderr);
           iVar1 = 3;
        }
        else {
           sVar3 = strlen(local_138);
           local_28 = sVar3 - 1;
           encrypt((char *)(ulong)local_9,(int)local_138);
           fclose(local_20);
           iVar1 = 0;
        }
     }
  }
  return iVar1;
}

void encrypt(char *__block,int __edflag)
{
  FILE *in_RCX;
  ulong in_RDX;
  undefined4 in_register_00000034;
  void *__ptr;
  FILE *in_R8;
  byte local_1028 [4096];
  size_t local_28;
  uint local_1c;
  long local_18;
  ulong local_10;

  __ptr = (void *)CONCAT44(in_register_00000034,__edflag);
  fwrite("Emitting misleading magic number ...\n",1,0x25,stderr);
  fwrite("%PDF-",1,5,in_R8);
  fprintf(stderr,"Writing %lu encrypted flag bytes ...\n",in_RDX);
  for (local_10 = 0; local_10 < in_RDX; local_10 = local_10 + 1) {
     *(byte *)((long)__ptr + local_10) = *(byte *)((long)__ptr + local_10) ^ (byte)__block;
  }
  fwrite(__ptr,1,in_RDX,in_R8);
  fwrite("Writing encrypted file contents ...\n",1,0x24,stderr);
  local_18 = 0;
  while( true ) {
     local_28 = fread(local_1028,1,0x1000,in_RCX);
     if (local_28 == 0) break;
     for (local_1c = 0; local_1c < 0x1000; local_1c = local_1c + 1) {
        local_1028[(int)local_1c] = local_1028[(int)local_1c] ^ (byte)__block;
     }
     fwrite(local_1028,local_28,1,in_R8);
     local_18 = local_18 + local_28;
  }
  fprintf(stderr,"Done! Encrypted & wrote %lu bytes from original file\n",local_18);
  return;
}

Podemos utilizar o Ghidra para gradualmente melhorar nosso entendimento do programa, renomeando e re-tipando variáveis com base na nossa experiência com a linguagem C. Mesmo sem saber C muito bem, poderíamos pedir para o ChatGPT (ou similares) resumir o que o programa faz:

gpt-explain

Segue uma cópia da resposta que obti da IA, onde ela explica a função encrypt:

The `encrypt` function takes the encryption key and the flag as arguments and performs the actual encryption process. Here's how it works:

1. It writes a message to indicate that it's starting the encryption process.
2. It writes a PDF header to the output file.
3. It performs a bitwise XOR operation on each byte of the flag with the encryption key byte.
4. It writes the encrypted flag to the output file.
5. It writes a message to indicate that it's starting to encrypt the file contents.
6. It reads blocks of 4096 bytes from the input file and performs a bitwise XOR operation on each byte with the encryption key byte.
7. It writes the encrypted file contents to the output file.
8. It keeps track of the total number of bytes written to the output file.
9. Once it finishes reading the input file, it writes a message indicating that the encryption process is done and the total number of bytes written.
10. The function returns, and the execution continues in the main function.

Vemos que o ChatGPT chegou à mesma conclusão quanto ao magic number de PDF e quanto à ordem de escrita da flag cifrada e do arquivo cifrado. Ele também inferiou o processo de criptografia (e podemos verificar que está correto): o programa utiliza uma chave de 1 byte, e escreve na saída o resultado do XOR entre essa chave e cada um dos bytes sendo cifrados. Assim, podemos inferir que o arquivo rec.pdf teria sido gerado como a saída desse programa.

Não sabemos nada sobre a geração da chave que foi usada, mas como ela tem apenas 1 byte podemos facilmente testar todas as 256 possibilidades. Como sabemos o formato esperado das nossas flags, e que a chave correta vai decifrar uma flag no início do arquivo, podemos reverter a criptografia com o seguinte script:

CFILE = 'rec.pdf'
PFILE = 'original.flagged'

with open(CFILE, 'rb') as ct:
    buf = ct.read(256)

    key = None
    for k in range(0, 256):
        dec = bytearray([ k ^ b for b in buf ])
        if b'cco{' in dec:
            key = k
            break

    print(f"key = {key}")

    buf = buf[5:] # remove false '%PDF-' header

    with open(PFILE, 'wb+') as pt:
        while buf != b'':
            buf = bytearray([ key ^ b for b in buf ])
            pt.write(buf)
            buf = ct.read(4096)

Com um hexdump, podemos observar que os primeiros bytes do arquivo original.flagged (gerado pelo script acima) contêm nossa terceira flag:

$ python decrypt.py
=>
key = 254

$ hexdump -C original.flagged | head -n 3
=>
00000000  63 63 6f 7b 6b 52 49 50  74 30 6e 31 74 33 7d 42  |cco{kRIPt0n1t3}B|
00000010  5a 68 39 31 41 59 26 53  59 b8 a8 39 b3 05 b6 ae  |Zh91AY&SY..9....|
00000020  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  |................|

Deeper and deeper

Ao continuar, precisamos nos lembrar que, se o email que encontramos estiver correto, o arquivo que acabamos de decifrar contém registros da SeTIC obtidos no momento do ataque hacker. Então, para obter esses dados, basta remover a flag do início do arquivo decifrado:

$ tail -c +16 original.flagged > original.bin

$ file original.bin
=>
original.bin: bzip2 compressed data, block size = 900k

Após descomprimir o bzip2, identificamos um arquivo apontado pelo file como um “pcap capture file”:

$ bzip2 -tv original.bin
=>
original.bin: ok

$ bzip -dk original.bin
=>
# bzip2: Can't guess original name for original.bin -- using original.bin.out

$ file original.bin.out
=>
original.bin.out: pcap capture file, microsecond ts (little-endian) - version 2.4 (Ethernet, capture length 262144)

O Kali imediatamente reconhece este tipo de arquivo e vai utilizar, por padrão, o Wireshark para abrí-lo:

wireshark

Temos então o trafego da rede, supostamente capturado pela SeTIC durante o ataque. Para começar a análise, podemos observar estatísticas dos protocolos de comunicação em uso (opção Statistics -> Protocol Hierarchy):

protocols

Como foi utilizado o IPv4, analisamos também as estatísticas de portas e endereços de IP utilizados na comunicação (opção Statistics -> IPv4 Statistics -> Destinations and Ports):

hosts

Resumindo: temos dois hosts se comunicando, com endereços de IP 192.168.56.126 e 192.168.56.1. O primeiro deles (abreviado .126) utilizou portas TCP altas, normalmente atribuídas pelo sistema operacional quando estabelece uma conexão com um servidor externo. O segundo host (sufixo de IP .1) utilizou as portas TCP 23 (telnet) e 1337, que são portas baixas normalmente abertas por servidores.

A tela padrão do Wireshark nos permite analisar cada pacote individual que foi capturado, mas também podemos utilizar a opção Analyze -> Follow -> TCP Stream para observar toda a conversa em uma conexão TCP:

follow-tcp-stream

A stream 0 contém uma conversa em plaintext, supostamente conduzida entre hackers que participaram do ataque à SeTIC/UFSC:

testando
ok
enviando imagem
recebido
enviando script
recebido
enviando senha
recebido
ok

A stream 1 é bem maior, e não conseguimos identificar de imediato do que se trata. Já as streams 2 e 3 parecem conter, respectivamente, um script de Python e algumas linhas em hexadecimal:

from os import urandom
from sys import stdout, stderr

# pip install pycryptodome
from Crypto.Cipher import AES
from Crypto.Util import Counter
from Crypto.Util.Padding import pad


class Cypher:
    bits = 128
    block_size = bits // 8

    def __init__(self, n):
        self.keys = [[None] * Cypher.block_size] * n
        self.ctrs = [Counter.new(Cypher.bits, initial_value=i) for i in range(n)]
        for i in range(n):
            for j in range(Cypher.block_size):
                self.keys[i][j] = urandom(1)

    def encrypt(self, msg, index):
        key = b''.join(self.keys[index])
        ctr = self.ctrs[index]
        cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
        plain_bytes = msg.encode()
        return cipher.encrypt(pad(plain_bytes, Cypher.block_size))


msgs = [
    "consegui clonar dados confidenciais do PC dele",
    "vou proteger a imagem com uma senha randomica:",
    "??????????????????????",
    "nao se preocupe, essas msgs vao ser cifradas com AES-CTR",
]

cypher = Cypher(len(msgs))

for i, msg in enumerate(msgs):
    enc = cypher.encrypt(msg, i)
    stdout.write(enc.hex() + '\n')

# NOTE: lembrar de anotar essa chave pq sem ela vai ser impossivel de lembrar minha senha
#for key in cypher.keys: stderr.write(b''.join(key).hex())
9f1ee383dc37b0bcfd2c6c5c9a16030bbc72a38ab4ca2064c3675ec87315e897f22b6fe6940c399a33218d8de7153a57
ae7cb2c5b7982c7fc86652de361aab97fe237ba39d437aa51d219c85ea504b300890953abee39ed65cf19d57fb9eb193
b3623ce6d04339ea13628693f2354b1b05bd893aeca2fab83996fe3e90aeb99b
08999b3abfe7d0c241f99b57efd4d6bdc49382d2c82f27515c265ae5d54796eaf4f2c132de2e41beb482fbd603db0d4805eea2ecfc74a95a648b8ac84a1fdd58

Com base na conversa da stream 0, podemos tentar identificar as outras como “a imagem”, “o script” e “a senha”, respectivamente.

Infelizmente, uma decodificação simples do hexadecimal não nos dá algo reconhecível. Todavia, podemos perceber que o formato da “senha” parece condizer com o que o script está gerando (trecho destacado a seguir): 4 mensagens cifradas, codificadas em hexadecimal e separadas por uma quebra de linha:

msgs = [
    "consegui clonar dados confidenciais do PC dele",
    "vou proteger a imagem com uma senha randomica:",
    "??????????????????????",
    "nao se preocupe, essas msgs vao ser cifradas com AES-CTR",
]

cypher = Cypher(len(msgs))

for i, msg in enumerate(msgs):
    enc = cypher.encrypt(msg, i)
    stdout.write(enc.hex() + '\n')

A última mensagem parece não estar mentindo, pois o método Cypher.encrypt de fato utiliza a biblioteca pycryptodome para implementar uma cifra AES-CTR (com chaves de 128 bits) sobre cada mensagem. Ao pesquisar “AES CTR” na web, podemos acabar novamente na Wikipedia, que explica que “CTR” é um “modo” para cifras de bloco.

aes-ctr-wikipedia

O modo CTR de certa forma simula um One-Time Pad: a operação final da cifra ainda é \(C_i = X_i \oplus M_i\) (e \(M_i = X_i \oplus C_i\) para decifrar). A diferença é que isso é feito em blocos (neste caso, de 128 bits), e cada bloco gera um \(X_i\) diferente a partir de:

  • Uma chave \(K\) (pode ser reutilizada em todos os blocos)
  • Um initialization vector (\(IV_i\)) composto por:
    • Um contador, normalmente incrementado a cada bloco
    • Um nonce, que jamais deve ser reutilizado com o mesmo contador e chave

A cifra de bloco propriamente dita (no caso do AES, seria o algoritmo de Rijndael) “mistura” a chave e o IV para gerar o “one-time pad” de cada bloco: \(X_i = AES(K, IV_i)\)

O script, no entanto, não faz menção a nenhum IV ou nonce. Como a pycryptodome é uma biblioteca open-source, podemos analisar a implementação da classe Crypto.Util.Counter, onde vemos que, se o contador for construído sem os parâmetros suffix e prefix, o nonce vai ser sempre igual (a uma string vazia):

def new(nbits, prefix=b"", suffix=b"", initial_value=1, little_endian=False, allow_wraparound=False):
    """Create a stateful counter block function suitable for CTR encryption modes.

    Each call to the function returns the next counter block.
    Each counter block is made up by three parts:

    +------+--------------+-------+
    |prefix| counter value|postfix|
    +------+--------------+-------+

    The counter value is incremented by 1 at each call."""

    # implementation...

Em outras palavras, \(IV_i = CTR_i\), o valor do contador.

Analisando o código com mais atenção, percebemos também um erro comum de programação em Python relacionado à criação de arrays:

self.keys = [[None] * Cypher.block_size] * n

A expressão [None] * Cypher.block_size cria um array onde cada elemento é igual ao valor entre chaves: None. Em seguida, esse array é replicado n vezes na expressão de fora. Embora a intenção provavelmente não seja essa, o resultado é que cada elemento em keys aponta para o mesmo array de tamanho Cypher.block_size. Assim, alterar o elemento keys[k][0] causa alterações em key[i][0], para todo i.

De fato, se instalarmos o pycryptodome e rodarmos o programa depois de descomentar as últimas linhas do script, vamos observar que as chaves de 128 bits geradas para cada mensagem são iguais ao longo de uma execução:

$ python s2.py > /dev/null
=>
f29deafeabf31c6c2eb5f2f5b9442969
f29deafeabf31c6c2eb5f2f5b9442969
f29deafeabf31c6c2eb5f2f5b9442969
f29deafeabf31c6c2eb5f2f5b9442969

$ python s2.py > /dev/null
=>
cb7944c5dcbf9b8d402dcc3694069908
cb7944c5dcbf9b8d402dcc3694069908
cb7944c5dcbf9b8d402dcc3694069908
cb7944c5dcbf9b8d402dcc3694069908

Ou seja, não sabemos qual chave de 128 bits foi usada originalmente, mas sabemos que a mesma chave foi usada para cifrar cada mensagem. Além disso, sabemos que os IVs são iguais aos contadores de cada bloco, e temos um contador inicializado para cada mensagem:

self.ctrs = [Counter.new(Cypher.bits, initial_value=i) for i in range

Note que o valor inicial do contador depende do indice da mensagem.

Juntando tudo isso, sabemos que, para cada bloco j na mensagem i: \(C_{ij} = M_{ij} \oplus AES(K_i, i+j)\)

Além disso, devido ao bug na inicialização das chaves, sabemos que todos os \(K_i\) são iguais. Isso implica que cada expressão na forma \(AES(K_i, j)\) pode ser expressada simplesmente como \(X_j\).

Voltando, então, à mensagem 1: \(C_{10} = M_{10} \oplus X_1 \\ C_{11} = M_{11} \oplus X_2 \\ C_{12} = M_{12} \oplus X_3\)

E à mensagem 2, que contém a senha de nosso interesse: \(C_{20} = M_{20} \oplus X_2 \\ C_{21} = M_{21} \oplus X_3\)

Aqui vemos claramente que, devido aos problemas no uso do AES-CTR (em especial a falta de um nonce), o script acaba reutilizando \(X_2\) e \(X_3\) para cifrar blocos das mensagens 1 e 2. Vamos começar a reverter a criptografia separando os blocos de cada mensagem:

>>> msgs = [
...     "consegui clonar dados confidenciais do PC dele",
...     "vou proteger a imagem com uma senha randomica:",
...     "??????????????????????",
...     "nao se preocupe, essas msgs vao ser cifradas com AES-CTR",
... ]
>>>
>>> encs = [
...     '9f1ee383dc37b0bcfd2c6c5c9a16030bbc72a38ab4ca2064c3675ec87315e897f22b6fe6940c399a33218d8de7153a57',
...     'ae7cb2c5b7982c7fc86652de361aab97fe237ba39d437aa51d219c85ea504b300890953abee39ed65cf19d57fb9eb193',
...     'b3623ce6d04339ea13628693f2354b1b05bd893aeca2fab83996fe3e90aeb99b',
...     '08999b3abfe7d0c241f99b57efd4d6bdc49382d2c82f27515c265ae5d54796eaf4f2c132de2e41beb482fbd603db0d4805eea2ecfc74a95a648b8ac84a1fdd58',
... ]
>>>
>>> from Crypto.Util.Padding import pad
>>> def encode_and_pad(msg): return pad(msg.encode(), 16)
...
>>> M = [ [m[j:j+16] for j in range(0, len(m), 16)] for m in map(encode_and_pad, msgs) ]
>>>
>>> C = [ [e[j:j+16] for j in range(0, len(e), 16)] for e in map(bytes.fromhex, encs) ]

No caso da mensagem 1, temos todos os plaintexts e ciphertexts, então podemos recuperar \(X_1\) a \(X_3\):

>>> def xor(A, B): return bytearray(a ^ b for a, b in zip(A, B))
...
>>> X = {}
>>> for j in range(len(M[1])):
...     X[1 + j] = xor(C[1][j], M[1][j])

Utilizando \(X_2\) e \(X_3\), conseguimos recuperar a mensagem 2, revelando a quarta flag do desafio:

>>> decrypted = xor(X[2], C[2][0]) + xor(X[3], C[2][1])
>>> decrypted.decode()
'        cco{yEsNcE}   \n\n\n\n\n\n\n\n\n\n'

Let it rip!

Acabamos de utilizar o conteúdo das streams TCP 0, 2 e 3 para conseguir a penúltima flag do CTF. O script capturado também dos dá mais informações sobre a stream 1, “a imagem”:

"consegui clonar dados confidenciais do PC dele"
"vou proteger a imagem com uma senha randomica:"

Os dados transferidos na stream 1 totalizam cerca de 70 MB. Infelizmente, o Wireshark não parece ser eficiente o bastante para permitir a exportação de um hexdump desse payload. Sendo assim, podemos recorrer à ferramenta CLI tshark para extrair a imagem em um arquivo:

$ tshark -r original.bin.out -Q -z 'follow,tcp,raw,1' \
| head -n -1 | tail -n +7 \
| sed 's/^\s\+//g' \
| xxd -r -p > s1.enc

$ file s1.enc
s1.enc: GPG symmetrically encrypted data (AES256 cipher)

Como esperado, a imagem está protegida. Segundo a mensagem no script, a senha dela é exatamente a quarta flag, capturada anteriormente. Com isso, podemos decifrar o arquivo com o GnuPG:

$ gpg -d s1.enc > s1.dec
=>
gpg: AES256.CFB encrypted data
gpg: encrypted with 1 passphrase

$ ls -lh s1.dec
=>
-rw-r--r-- 1 kali kali 72M Nov 15 22:45 s1.dec

$ file s1.dec
=>
s1.dec: DOS/MBR boot sector
    MS-MBR Windows 7 english at offset 0x163
    "Invalid partition table" at offset 0x17b
    "Error loading operating system" at offset 0x19a
    "Missing operating system", disk signature 0x7b3b5028;
    partition 1 : ID=0x7, start-CHS (0x0,2,3), end-CHS (0x8,205,5), startsector 128, 141312 sectors

Parece que temos, mais uma vez, uma imagem de disco. A partition table é do tipo DOS/MBR e o disco tem ao menos uma partição iniciando no setor 128. Desta vez, entretanto, nos deparamos com erros ao tentar listar os arquivos ou montar a partição:

$ mmls s1.dec
=>
DOS Partition Table
Offset Sector: 0
Units are in 512-byte sectors

      Slot      Start        End          Length       Description
000:  Meta      0000000000   0000000000   0000000001   Primary Table (#0)
001:  -------   0000000000   0000000127   0000000128   Unallocated
002:  000:000   0000000128   0000141439   0000141312   NTFS / exFAT (0x07)
003:  -------   0000141440   0000147455   0000006016   Unallocated

$ fls -r -o 128 s1.dec
=>
Possible encryption detected (High entropy (8.00))

$ sudo mount -o offset=$((512*128)) s1.dec mnt/
=>
mount: /home/kali/seccom-ctf/snapshot/incidente/mnt: unknown filesystem type 'BitLocker'.
       dmesg(1) may have more information after failed mount system call.

O fls indica “Possible encryption detected”, o que é confirmado pelo mount: parece que o volume foi protegido com BitLocker, um sistema de criptografia de disco do Windows.

Uma busca rápida por “Kali tools BitLocker” nos aponta para duas ferramentas que podem ser úteis:

  • dislocker: (não vem instalado por padrão) utilizado para montar partições do BitLocker, se você souber a senha
  • bitlocker2john: extrai hashes de senhas de discos protegidos com o BitLocker

Levando em conta que crackear senhas foi a única técnica ensinada no CTF de demonstração que ainda não utilizamos, podemos tentar essa ideia. Primeiramente, precisamos extrair os hashes das senhas que vamos crackear:

$ bitlocker2john -i s1.dec | egrep '\$bitlocker\$' > bitlocked.hash
=>
Signature found at 0x10003
Version: 8
Invalid version, looking for a signature with valid version...

Signature found at 0x2110000
Version: 2 (Windows 7 or later)

VMK entry found at 0x21100ad

VMK encrypted with User Password found at 21100ce
VMK encrypted with AES-CCM

VMK entry found at 0x211018d

VMK encrypted with Recovery Password found at 0x21101ae
Searching AES-CCM from 0x21101ca
Trying offset 0x211025d....
VMK encrypted with AES-CCM!!

Signature found at 0x28bb000
Version: 2 (Windows 7 or later)

VMK entry found at 0x28bb0ad

VMK entry found at 0x28bb18d

Signature found at 0x3066000
Version: 2 (Windows 7 or later)

VMK entry found at 0x30660ad

VMK entry found at 0x306618d

$ cat bitlocked.hash
=>
$bitlocker$0$16$f1e83bdb6ff56c685b2e9e2fd482bdeb$1048576$12$20579fb9ff0dda0103000000$60$5e224f5665f71821128e49950fb920ac7e64946c1ae1b44ac7abc2f521d92f4de6b39373ee59bd351576a12d55ea9eeb7db96e88753332df68d96407
$bitlocker$1$16$f1e83bdb6ff56c685b2e9e2fd482bdeb$1048576$12$20579fb9ff0dda0103000000$60$5e224f5665f71821128e49950fb920ac7e64946c1ae1b44ac7abc2f521d92f4de6b39373ee59bd351576a12d55ea9eeb7db96e88753332df68d96407
$bitlocker$2$16$3b4de4c80870c035dce829c8c4c5e212$1048576$12$20579fb9ff0dda0106000000$60$d79213c35271e26a75f76142fc9823b432500f9d780e0e0c8064eaf55b438884c3836e1bdcbbe8a5e5bcd1c64fde95ccb3a2a59a6b43a08486138606
$bitlocker$3$16$3b4de4c80870c035dce829c8c4c5e212$1048576$12$20579fb9ff0dda0106000000$60$d79213c35271e26a75f76142fc9823b432500f9d780e0e0c8064eaf55b438884c3836e1bdcbbe8a5e5bcd1c64fde95ccb3a2a59a6b43a08486138606

Em seguida, verificamos se o John the Ripper suporta esse formato. Como é o caso, podemos iniciar o processo de bruteforcing / dictionary attack com a wordlist rockyou:

$ john --list=formats | grep -i 'bitlocker'
=>
AxCrypt, AzureAD, BestCrypt, BestCryptVE4, bfegg, Bitcoin, BitLocker,
416 formats (149 dynamic formats shown as just "dynamic_n" here)

$ john --wordlist=/usr/share/wordlists/rockyou.txt --format=BitLocker bitlocked.hash
=>
Note: This format may emit false positives, so it will keep trying even after finding a possible candidate.
Using default input encoding: UTF-8
Loaded 2 password hashes with 2 different salts (BitLocker, BitLocker [SHA-256 AES 32/64])
Cost 1 (iteration count) is 1048576 for all loaded hashes
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
0g 0:00:00:01 0.00% (ETA: 2023-12-10 12:20) 0g/s 3.252p/s 6.504c/s 6.504C/s iloveyou..rockyou
0g 0:00:00:15 0.00% (ETA: 2024-01-06 15:52) 0g/s 3.718p/s 7.702c/s 7.702C/s tweety..hello
0g 0:00:00:32 0.00% (ETA: 2024-01-06 11:46) 0g/s 3.840p/s 7.680c/s 7.680C/s 112233..diamond
0g 0:00:00:59 0.00% (ETA: 2024-01-10 09:34) 0g/s 3.644p/s 7.288c/s 7.288C/s hellokitty..angelica
0g 0:00:01:00 0.00% (ETA: 2024-01-10 09:44) 0g/s 3.639p/s 7.279c/s 7.279C/s austin..horses
0g 0:00:02:03 0.00% (ETA: 2024-01-14 10:04) 0g/s 3.439p/s 6.878c/s 6.878C/s kitty..valeria
0g 0:00:06:15 0.01% (ETA: 2024-01-16 08:07) 0g/s 3.366p/s 6.744c/s 6.744C/s iluvme..douglas
0g 0:00:07:08 0.01% (ETA: 2024-01-16 04:50) 0g/s 3.371p/s 6.743c/s 6.743C/s missy..darius
Session aborted

Depois de um tempo, decidimos parar o cracking e tentar tornar o processo um pouco mais rápido.

Vamos começar filtrando um pouco a nossa wordlist / dicionário, já que estamos tentando várias senhas fracas. Para entender quais senhas manter, criamos uma VM com Windows 10 Pro e uma partição protegida com BitLocker. O BitLocker exigiu que a senha tivesse 8 ou mais caracteres, mas não foi preciso adicionar números, caracteres especiais ou misturar letras maiusculas e minusculas.

Além disso, passamos a utilizar o hashcat, que é geralmente mais rápido que o john. Por fim, vamos crackear apenas o hash/senha de usuário, que estimamos que seja muito mais fraca do que a chave de recuperação do BitLocker (48 digitos numéricos gerados aleatoriamente).

$ awk 'length >= 8' /usr/share/wordlists/rockyou.txt > rockyou8.txt

$ echo '$bitlocker$0$16$f1e83bdb6ff56c685b2e9e2fd482bdeb$1048576$12$20579fb9ff0dda0103000000$60$5e224f5665f71821128e49950fb920ac7e64946c1ae1b44ac7abc2f521d92f4de6b39373ee59bd351576a12d55ea9eeb7db96e88753332df68d96407' > user.hash

$ hashcat --help | grep -i bitlocker
=>
  22100 | BitLocker | Full-Disk Encryption (FDE)

$ hashcat -a 0 -m 22100 user.hash rockyou8.txt
=>
hashcat (v6.2.6) starting

OpenCL API (OpenCL 3.0 PoCL 3.1+debian  Linux, None+Asserts, RELOC, SPIR, LLVM 15.0.6, SLEEF, DISTRO, POCL_DEBUG) - Platform #1 [The pocl project]
==================================================================================================================================================
* Device #1: pthread-sandybridge-Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz, 1436/2936 MB (512 MB allocatable), 4MCU

Minimum password length supported by kernel: 4
Maximum password length supported by kernel: 256

Hashes: 1 digests; 1 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1

Optimizers applied:
* Single-Hash
* Single-Salt
* Slow-Hash-SIMD-LOOP

Watchdog: Temperature abort trigger set to 90c

Host memory required for this attack: 0 MB


Dictionary cache hit:
* Filename..: rockyou8.txt
* Passwords.: 9607178
* Bytes.....: 104537834
* Keyspace..: 9607178

Cracking performance lower than expected?

* Append -w 3 to the commandline.
  This can cause your screen to lag.

* Append -S to the commandline.
  This has a drastic speed impact but can be better for specific attacks.
  Typical scenarios are a small wordlist but a large ruleset.

* Update your backend API runtime / driver the right way:
  https://hashcat.net/faq/wrongdriver

* Create more work items to make use of your parallelization power:
  https://hashcat.net/faq/morework

[s]tatus [p]ause [b]ypass [c]heckpoint [f]inish [q]uit =>

$bitlocker$0$16$f1e83bdb6ff56c685b2e9e2fd482bdeb$1048576$12$20579fb9ff0dda0103000000$60$5e224f5665f71821128e49950fb920ac7e64946c1ae1b44ac7abc2f521d92f4de6b39373ee59bd351576a12d55ea9eeb7db96e88753332df68d96407:genesis1

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 22100 (BitLocker)
Hash.Target......: $bitlocker$0$16$f1e83bdb6ff56c685b2e9e2fd482bdeb$10...d96407
Time.Started.....: Wed Nov 15 23:50:53 2023 (2 mins, 20 secs)
Time.Estimated...: Wed Nov 15 23:53:13 2023 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: File (rockyou8.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........:       14 H/s (9.98ms) @ Accel:32 Loops:4096 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new)
Progress.........: 2016/9607178 (0.02%)
Rejected.........: 0/2016 (0.00%)
Restore.Point....: 1984/9607178 (0.02%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:1044480-1048576
Candidate.Engine.: Device Generator
Candidates.#1....: princess07 -> spoiled1
Hardware.Mon.#1..: Util: 84%

Started: Wed Nov 15 23:50:44 2023
Stopped: Wed Nov 15 23:53:15 2023

Em cerca de 2 minutos e meio, conseguimos encontrar a senha do disco: genesis1. Com a senha, o dislocker consegue decifrar o disco. Basta então montarmos a partição para obter a quinta e última flag do CTF:

$ dislocker-file -v -O $((512 * 128)) -V s1.dec -u unlocked.p1
=>
Enter the user password: ********

$ sudo mount unlocked.p1 mnt

$ ls -lah mnt
=>
total 13K
drwxrwxrwx 1 root root 4.0K Nov  2 22:44  .
drwxr-xr-x 3 kali kali 4.0K Nov 16 00:00  ..
drwxrwxrwx 1 root root    0 Nov  2 22:44 '$RECYCLE.BIN'
-rwxrwxrwx 1 root root   14 Nov  2 22:24  flag.txt
drwxrwxrwx 1 root root 4.0K Nov  2 22:46 'System Volume Information'

$ cat mnt/flag.txt
=>
cco{C4nc14n}

Comentários

Updated: