CSS 资源计时攻击:通过字体加载时间推断用户网络环境或缓存状态

CSS 资源计时攻击:通过字体加载时间推断用户网络环境或缓存状态

大家好,今天我们要探讨一个颇具隐蔽性的安全问题:CSS 资源计时攻击,特别是利用字体加载时间来推断用户网络环境或缓存状态。这种攻击方式巧妙地利用了浏览器加载资源时的计时特性,结合精心设计的 CSS 规则,最终泄露敏感信息。

1. 计时攻击原理概述

计时攻击(Timing Attack)是一种侧信道攻击,它通过测量执行某些操作所需的时间来推断秘密信息。攻击者并不直接获取秘密数据,而是通过观察系统行为的细微差别,例如执行特定算法所需的时间,来间接推断出秘密。

在 Web 安全领域,计时攻击可以利用各种浏览器的特性,例如 JavaScript 代码执行时间、DOM 操作时间、资源加载时间等。今天我们关注的是资源加载时间,尤其是字体加载时间。

2. 字体加载机制与攻击可能性

浏览器加载字体资源时,会经历以下过程:

  1. CSS 解析: 浏览器解析 HTML 文档,遇到 <link> 标签或 <style> 标签中的 CSS 规则。
  2. 字体资源请求: 如果 CSS 规则中使用了 @font-face 声明引入了外部字体文件,浏览器会向服务器发送请求,获取字体文件。
  3. 字体资源下载: 服务器响应请求,浏览器下载字体文件。
  4. 字体资源渲染: 浏览器解析字体文件,并用其渲染文本。

这个过程中,字体资源下载时间会受到多种因素的影响:

  • 网络环境: 网络带宽、延迟等因素会直接影响下载速度。
  • 缓存状态: 如果字体文件已经被缓存,则可以从本地缓存直接加载,速度会非常快;否则需要从服务器重新下载。
  • 服务器性能: 服务器的响应速度也会影响下载时间。

攻击者可以利用这些因素来推断用户的信息。例如:

  • 网络环境推断: 通过比较不同字体文件的加载时间,可以推断用户的网络速度。
  • 缓存状态推断: 通过检查字体文件是否从缓存加载,可以判断用户是否之前访问过该网站,从而推断用户是否是回头客。

3. CSS 字体加载计时攻击的实现方法

以下是一些 CSS 字体加载计时攻击的实现方法:

3.1. 使用 JavaScript 和 CSS 结合进行计时

这种方法是最常见的,它利用 JavaScript 来测量字体加载的时间,并结合 CSS 来控制字体的加载。

步骤:

  1. 定义 @font-face 规则: 在 CSS 中定义 @font-face 规则,引入需要测试的字体文件。
  2. 使用 JavaScript 监听字体加载事件: 使用 JavaScript 的 FontFaceSet API 来监听字体加载事件。
  3. 记录加载时间: 在字体加载事件触发时,记录加载时间。
  4. 根据加载时间推断信息: 根据加载时间的长短,推断用户的网络环境或缓存状态。

代码示例:

<!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,并测量字体切换的时间。

步骤:

  1. 定义 @font-face 规则,并设置 font-display: swap: 在 CSS 中定义 @font-face 规则,引入需要测试的字体文件,并设置 font-display: swap
  2. 使用 JavaScript 监听字体切换事件: 监听字体切换事件(通常是通过观察元素的 font-family 属性的变化)。
  3. 记录切换时间: 在字体切换事件触发时,记录切换时间。
  4. 根据切换时间推断信息: 根据切换时间的长短,推断用户的网络环境或缓存状态。

代码示例:

<!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.ready promise 在所有字体加载完成后 resolve,可以用来更准确地确定字体开始加载的时间。

注意: MutationObserver 的使用可能因为浏览器实现差异而有所不同。 在某些浏览器中,直接监听 font-family 属性的变化可能更可靠。 同样,myfont.woff2 需要替换为你实际的字体文件路径。

3.3. 使用 CSS 动画和伪类进行计时

这种方法利用 CSS 动画和伪类(例如 :active)来触发字体加载,并测量动画的执行时间。

步骤:

  1. 定义 @font-face 规则: 在 CSS 中定义 @font-face 规则,引入需要测试的字体文件。
  2. 创建 CSS 动画: 创建一个 CSS 动画,该动画会触发字体加载。例如,可以使用 transform 属性来改变元素的大小或位置。
  3. 使用 :active 伪类触发动画: 使用 :active 伪类来触发动画。当用户点击元素时,动画开始执行,字体开始加载。
  4. 使用 JavaScript 监听动画结束事件: 使用 JavaScript 的 animationend 事件来监听动画结束事件。
  5. 记录动画执行时间: 在动画结束事件触发时,记录动画执行时间。
  6. 根据动画执行时间推断信息: 根据动画执行时间的长短,推断用户的网络环境或缓存状态。

代码示例:

<!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. 攻击示例:推断用户是否为回头客

假设攻击者想要知道用户是否是回头客,可以采用以下步骤:

  1. 首次访问: 用户首次访问网站时,网站会加载一个特定的字体文件(例如 unique_font_a.woff2)。攻击者记录下这个字体文件的加载时间,并将其与一个预设的阈值进行比较。

  2. 后续访问: 用户再次访问网站时,如果字体文件是从缓存加载的,加载时间会非常短。攻击者再次记录字体文件的加载时间,并与首次访问时的加载时间进行比较。

  3. 判断: 如果后续访问的加载时间远小于首次访问时的加载时间(例如小于 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精英技术系列讲座,到智猿学院

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注