CSS 资源计时攻击:通过字体加载时间推断用户网络环境或缓存状态
大家好,今天我们要探讨一个颇具隐蔽性的安全问题:CSS 资源计时攻击,特别是利用字体加载时间来推断用户网络环境或缓存状态。这种攻击方式巧妙地利用了浏览器加载资源时的计时特性,结合精心设计的 CSS 规则,最终泄露敏感信息。
1. 计时攻击原理概述
计时攻击(Timing Attack)是一种侧信道攻击,它通过测量执行某些操作所需的时间来推断秘密信息。攻击者并不直接获取秘密数据,而是通过观察系统行为的细微差别,例如执行特定算法所需的时间,来间接推断出秘密。
在 Web 安全领域,计时攻击可以利用各种浏览器的特性,例如 JavaScript 代码执行时间、DOM 操作时间、资源加载时间等。今天我们关注的是资源加载时间,尤其是字体加载时间。
2. 字体加载机制与攻击可能性
浏览器加载字体资源时,会经历以下过程:
- CSS 解析: 浏览器解析 HTML 文档,遇到
<link>标签或<style>标签中的 CSS 规则。 - 字体资源请求: 如果 CSS 规则中使用了
@font-face声明引入了外部字体文件,浏览器会向服务器发送请求,获取字体文件。 - 字体资源下载: 服务器响应请求,浏览器下载字体文件。
- 字体资源渲染: 浏览器解析字体文件,并用其渲染文本。
这个过程中,字体资源下载时间会受到多种因素的影响:
- 网络环境: 网络带宽、延迟等因素会直接影响下载速度。
- 缓存状态: 如果字体文件已经被缓存,则可以从本地缓存直接加载,速度会非常快;否则需要从服务器重新下载。
- 服务器性能: 服务器的响应速度也会影响下载时间。
攻击者可以利用这些因素来推断用户的信息。例如:
- 网络环境推断: 通过比较不同字体文件的加载时间,可以推断用户的网络速度。
- 缓存状态推断: 通过检查字体文件是否从缓存加载,可以判断用户是否之前访问过该网站,从而推断用户是否是回头客。
3. CSS 字体加载计时攻击的实现方法
以下是一些 CSS 字体加载计时攻击的实现方法:
3.1. 使用 JavaScript 和 CSS 结合进行计时
这种方法是最常见的,它利用 JavaScript 来测量字体加载的时间,并结合 CSS 来控制字体的加载。
步骤:
- 定义
@font-face规则: 在 CSS 中定义@font-face规则,引入需要测试的字体文件。 - 使用 JavaScript 监听字体加载事件: 使用 JavaScript 的
FontFaceSet API来监听字体加载事件。 - 记录加载时间: 在字体加载事件触发时,记录加载时间。
- 根据加载时间推断信息: 根据加载时间的长短,推断用户的网络环境或缓存状态。
代码示例:
<!DOCTYPE html>
<html>
<head>
<title>CSS Font Timing Attack</title>
<style>
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2'); /* 替换为你的字体文件 */
}
body {
font-family: sans-serif;
}
.hidden {
visibility: hidden; /* 初始状态隐藏文字 */
}
.visible {
visibility: visible; /* 加载字体后显示文字 */
}
</style>
</head>
<body>
<p id="testText" class="hidden" style="font-family: 'MyFont'">This is a test text.</p>
<script>
document.fonts.load('16px MyFont').then(function() {
const startTime = performance.now();
const testElement = document.getElementById('testText');
testElement.classList.remove('hidden');
testElement.classList.add('visible');
requestAnimationFrame(() => {
const endTime = performance.now();
const loadTime = endTime - startTime;
console.log('Font load time:', loadTime, 'ms');
// 根据加载时间进行判断
if (loadTime < 50) {
console.log('Font loaded from cache (likely)');
} else if (loadTime < 200) {
console.log('Fast network');
} else {
console.log('Slow network');
}
// 将加载时间发送到服务器
sendDataToServer(loadTime);
});
}).catch(error => {
console.error('Font loading failed:', error);
});
function sendDataToServer(loadTime) {
// 使用 fetch 或 XMLHttpRequest 将加载时间发送到服务器
fetch('/log_font_load_time', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ loadTime: loadTime })
}).then(response => {
console.log('Data sent to server:', response.status);
}).catch(error => {
console.error('Error sending data:', error);
});
}
</script>
</body>
</html>
代码解释:
@font-face规则定义了字体名称和字体文件的 URL。document.fonts.load('16px MyFont')尝试加载名为 "MyFont" 的字体。performance.now()用于获取高精度的时间戳。requestAnimationFrame()确保在浏览器完成渲染后才计算加载时间,提高精度。sendDataToServer()函数用于将加载时间发送到服务器,以便进行分析。
注意: myfont.woff2 需要替换为你实际的字体文件路径。 /log_font_load_time 需要替换为你服务器端接收加载时间的 API 端点。 服务器端需要相应的代码来处理接收到的数据。
3.2. 使用 CSS font-display 属性进行计时
font-display 属性控制字体在加载期间的显示方式。它可以设置以下值:
auto: 浏览器默认行为。block: 字体加载期间,文本不可见。swap: 字体加载期间,使用备用字体显示文本,加载完成后切换到目标字体。fallback: 字体加载期间,先使用备用字体显示文本,一段时间后如果字体仍未加载,则继续使用备用字体。optional: 字体加载期间,先使用备用字体显示文本,但浏览器可以自行决定是否加载目标字体。
我们可以利用 font-display 属性的特性来进行计时攻击。例如,我们可以设置 font-display: swap,并测量字体切换的时间。
步骤:
- 定义
@font-face规则,并设置font-display: swap: 在 CSS 中定义@font-face规则,引入需要测试的字体文件,并设置font-display: swap。 - 使用 JavaScript 监听字体切换事件: 监听字体切换事件(通常是通过观察元素的
font-family属性的变化)。 - 记录切换时间: 在字体切换事件触发时,记录切换时间。
- 根据切换时间推断信息: 根据切换时间的长短,推断用户的网络环境或缓存状态。
代码示例:
<!DOCTYPE html>
<html>
<head>
<title>CSS Font Timing Attack with font-display</title>
<style>
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2');
font-display: swap;
}
body {
font-family: sans-serif; /* 初始字体 */
}
.test-text {
font-family: 'MyFont', sans-serif; /* 尝试使用 MyFont,否则使用 sans-serif */
}
</style>
</head>
<body>
<p id="testText" class="test-text">This is a test text.</p>
<script>
const testElement = document.getElementById('testText');
let startTime = performance.now();
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
// 理论上,font-family 的改变应该触发这个事件,但实际触发可能依赖于浏览器实现
// 这里为了简化,假设 font-family 改变导致了 style 改变
const endTime = performance.now();
const swapTime = endTime - startTime;
console.log('Font swap time:', swapTime, 'ms');
// 根据切换时间进行判断
if (swapTime < 50) {
console.log('Font loaded from cache (likely)');
} else if (swapTime < 200) {
console.log('Fast network');
} else {
console.log('Slow network');
}
observer.disconnect(); // 停止观察
}
});
});
// 开始观察元素的 style 属性
observer.observe(testElement, { attributes: true, attributeFilter: ['style'] });
// 初始时间,在字体开始加载之前记录
// 另一种更可靠的方式是,直接检查 document.fonts.ready promise 是否 resolve
document.fonts.ready.then(() => {
startTime = performance.now(); // 确保在字体加载完成之后,再记录开始时间
});
</script>
</body>
</html>
代码解释:
font-display: swap指示浏览器在字体加载期间使用备用字体(sans-serif),加载完成后切换到MyFont。MutationObserver用于监听元素的属性变化。 在这个例子中,我们监听style属性的变化,理论上font-family的改变会导致style改变。document.fonts.readypromise 在所有字体加载完成后 resolve,可以用来更准确地确定字体开始加载的时间。
注意: MutationObserver 的使用可能因为浏览器实现差异而有所不同。 在某些浏览器中,直接监听 font-family 属性的变化可能更可靠。 同样,myfont.woff2 需要替换为你实际的字体文件路径。
3.3. 使用 CSS 动画和伪类进行计时
这种方法利用 CSS 动画和伪类(例如 :active)来触发字体加载,并测量动画的执行时间。
步骤:
- 定义
@font-face规则: 在 CSS 中定义@font-face规则,引入需要测试的字体文件。 - 创建 CSS 动画: 创建一个 CSS 动画,该动画会触发字体加载。例如,可以使用
transform属性来改变元素的大小或位置。 - 使用
:active伪类触发动画: 使用:active伪类来触发动画。当用户点击元素时,动画开始执行,字体开始加载。 - 使用 JavaScript 监听动画结束事件: 使用 JavaScript 的
animationend事件来监听动画结束事件。 - 记录动画执行时间: 在动画结束事件触发时,记录动画执行时间。
- 根据动画执行时间推断信息: 根据动画执行时间的长短,推断用户的网络环境或缓存状态。
代码示例:
<!DOCTYPE html>
<html>
<head>
<title>CSS Font Timing Attack with Animation</title>
<style>
@font-face {
font-family: 'MyFont';
src: url('myfont.woff2') format('woff2');
}
body {
font-family: sans-serif;
}
.animated-text {
font-family: 'MyFont';
display: inline-block; /* 使元素可以应用 transform */
animation: fontLoadAnimation 0.5s forwards; /* 初始不执行动画 */
animation-play-state: paused; /* 初始暂停动画 */
}
.animated-text:active {
animation-play-state: running; /* 点击时开始动画 */
}
@keyframes fontLoadAnimation {
0% { transform: scale(1); }
100% { transform: scale(1.1); }
}
</style>
</head>
<body>
<p>Click the text below to trigger the animation and font loading:</p>
<span id="animatedText" class="animated-text">This is animated text.</span>
<script>
const animatedText = document.getElementById('animatedText');
let startTime = 0;
animatedText.addEventListener('mousedown', () => { // 使用 mousedown 确保在点击时开始计时
startTime = performance.now();
});
animatedText.addEventListener('animationend', () => {
const endTime = performance.now();
const animationTime = endTime - startTime;
console.log('Animation time:', animationTime, 'ms');
// 根据动画时间进行判断
if (animationTime < 550) { // 考虑到动画本身的时间,需要设置一个合适的阈值
console.log('Font loaded from cache (likely)');
} else if (animationTime < 700) {
console.log('Fast network');
} else {
console.log('Slow network');
}
});
</script>
</body>
</html>
代码解释:
@keyframes fontLoadAnimation定义了一个简单的缩放动画。.animated-text:active在元素被点击时(active状态)启动动画。animation-play-state: paused;初始状态暂停动画,防止页面加载时自动执行。mousedown事件用于在鼠标按下时开始计时,确保在动画开始执行时记录时间。animationend事件在动画结束时触发,用于计算动画执行时间。
注意: 动画的执行时间可能会受到多种因素的影响,例如 CPU 负载、浏览器性能等。 因此,需要根据实际情况调整阈值。 同样,myfont.woff2 需要替换为你实际的字体文件路径。
4. 攻击的风险与影响
CSS 字体加载计时攻击虽然看似微小,但可能带来以下风险与影响:
- 用户隐私泄露: 攻击者可以利用这种攻击方式来推断用户的网络环境、缓存状态等信息,从而构建用户画像。
- 目标用户识别: 攻击者可以利用这种攻击方式来识别特定的用户,例如回头客、特定地区的访客等。
- 安全漏洞利用: 攻击者可以将这种攻击方式与其他攻击方式结合起来,例如跨站脚本攻击(XSS),来进一步利用安全漏洞。
5. 防御方法
以下是一些防御 CSS 字体加载计时攻击的方法:
-
使用内容安全策略 (CSP): CSP 可以限制浏览器加载外部资源的来源,从而减少攻击面。 例如,可以设置
font-src指令来限制字体文件的来源。<meta http-equiv="Content-Security-Policy" content="default-src 'self'; font-src 'self' https://fonts.example.com"> -
使用字体子集化: 字体子集化是指只包含网页中使用的字符的字体文件。 这样可以减小字体文件的大小,从而减少加载时间,降低计时攻击的精度。可以使用在线工具或者字体编辑软件进行字体子集化。
-
使用缓存控制策略: 合理设置缓存控制策略,例如
Cache-Control头部,可以避免浏览器频繁地重新下载字体文件。 但是,过度缓存可能会导致用户无法及时获取更新的字体文件。 -
增加计时噪声: 在 JavaScript 代码中添加一些随机的延迟,可以增加计时攻击的难度。 但是,过多的延迟可能会影响用户体验。
-
限制 JavaScript 精度: 降低 JavaScript 的计时精度可以减少攻击者获取精确计时数据的能力。 但是,这可能会影响一些依赖高精度计时的 Web 应用。
-
监控异常流量: 监控网站的流量,特别是字体文件的请求流量,可以及时发现异常行为。
-
定期安全审计: 定期进行安全审计,检查网站是否存在潜在的安全漏洞。
6. 攻击示例:推断用户是否为回头客
假设攻击者想要知道用户是否是回头客,可以采用以下步骤:
-
首次访问: 用户首次访问网站时,网站会加载一个特定的字体文件(例如
unique_font_a.woff2)。攻击者记录下这个字体文件的加载时间,并将其与一个预设的阈值进行比较。 -
后续访问: 用户再次访问网站时,如果字体文件是从缓存加载的,加载时间会非常短。攻击者再次记录字体文件的加载时间,并与首次访问时的加载时间进行比较。
-
判断: 如果后续访问的加载时间远小于首次访问时的加载时间(例如小于 50ms),则可以判断用户是回头客。
代码示例(简化):
// 首次访问时执行
if (localStorage.getItem('firstVisit') === null) {
document.fonts.load('16px UniqueFontA').then(() => {
const loadTime = performance.now() - startTime;
console.log('First visit font load time:', loadTime);
localStorage.setItem('firstVisit', loadTime); // 保存首次访问加载时间
});
} else {
// 后续访问时执行
document.fonts.load('16px UniqueFontA').then(() => {
const loadTime = performance.now() - startTime;
const firstVisitLoadTime = parseFloat(localStorage.getItem('firstVisit'));
console.log('Subsequent visit font load time:', loadTime);
if (loadTime < 0.2 * firstVisitLoadTime) { // 加载时间远小于首次访问
console.log('User is a returning visitor (likely)');
} else {
console.log('User is not a returning visitor (less likely)');
}
});
}
注意: 这个示例仅仅是为了说明攻击原理,实际攻击可能更加复杂,并且需要考虑各种干扰因素。
7. 实验与验证
为了验证 CSS 字体加载计时攻击的有效性,可以进行一些实验。
- 实验 1:网络环境的影响: 在不同的网络环境下(例如 Wi-Fi、4G、3G)测试字体加载时间,观察网络速度对加载时间的影响。
- 实验 2:缓存状态的影响: 先清空浏览器缓存,然后访问网站,记录字体加载时间。 再次访问网站,观察字体是否从缓存加载,并比较加载时间。
- 实验 3:字体大小的影响: 测试不同大小的字体文件对加载时间的影响。
- 实验 4:防御措施的效果: 应用不同的防御措施(例如 CSP、字体子集化),然后再次进行计时攻击,观察防御措施的效果。
通过这些实验,可以更深入地了解 CSS 字体加载计时攻击的原理和影响,并评估防御措施的有效性。
8. 伦理考量
在研究和测试 CSS 字体加载计时攻击时,需要遵守伦理规范。
- 尊重用户隐私: 不要滥用这种攻击方式来窃取用户隐私信息。
- 负责任地披露漏洞: 如果发现了新的安全漏洞,应该及时向相关厂商报告,而不是公开披露。
- 不要进行恶意攻击: 不要利用这种攻击方式来攻击其他网站或用户。
总结:理解风险,采取防御
CSS 字体加载计时攻击是一种隐蔽的安全风险,攻击者可以利用它来推断用户的网络环境、缓存状态等信息。 为了保护用户隐私和网站安全,我们需要了解这种攻击的原理和影响,并采取相应的防御措施。 通过合理配置 CSP、使用字体子集化、增加计时噪声等方法,可以有效地降低计时攻击的风险。
更多IT精英技术系列讲座,到智猿学院