Анализ уязвимостей процесса аутентификации
Введение
Приветствую.
UPD: На основе данной статьи сделал доклад на CodeFest:
Последней каплей вдохновения для этого поста стал перевод курса MIT «Безопасность компьютерных систем» от @ua-hosting, огромное спасибо им за это. Если кто-то еще не читал/смотрел этот курс, то я настоятельно его рекомендую.
А для затравки и в качестве введения для этого поста приведу примеры, которые приводились во введение в этом курсе.
В первую очередь хочется сказать, что материал который предоставляется в этом посте нельзя использовать в незаконных целях и направлен на то, чтобы мы как разработчики знали как может вести себя злоумышленник и были защищены от этого, а не наоборот. Если вы захотите попробовать некоторые из подходов на практике, то прочитайте вот эту статью “Ответы юриста: как избежать ответственности за поиск уязвимостей”.
Cool story 1
В определенный момент времени Yahoo решила дать своим пользователям получать доступ к аккаунту не только по логину и паролю, но и в случае, если вы забыли пароль, ответить на пару вопросов, ответ на который могли знать только вы (т.к. у них не было возможности отправить вам пароль на какой либо другой резервный аккаунт).
И в один прекрасный день, Сара Пэйлин, у которой был ящик на Yahoo, обнаружила, что данные ее почтового ящика утекли. Дело в том, что на кодовые вопросы для доступа к аккаунту были «Где вы посещали школу? Как звали вашего друга? Когда у вас день рождения?», а ответы на эти вопросы были на ее странице в википедии. И каждый мог получить доступ к ее аккаунту, просто прочитав страницу о ней.
Cool story 2
Еще один “прекрасный” случай произошел с парнем по имени Мэт Хонан. Злоумышленников заинтересовал твиттер этого человека. Из персонального сайта нашли его email — mhonan@gmail.com, а информация Whois одного из проектов говорила что его адрес 1559B Sloat Blvd, San Francisco. Далее злоумышленник через форму восстановления пароля google узнал его резервный ящик m••••n@me.com. Ящики на me.com — это по совместительству еще и AppleID — идентификатор, используемый для совершения покупок в магазине AppStore. Дабы восстановить пароль к аккаунту @me.com через звонок в техподдержку Apple, необходимо знать:
- адрес почты @me.com;
- billing address — адрес проживания;
- последние 4 цифры кредитной карты, привязанной к AppleID.
Первые два пункта хакер уже знал. Осталось узнать последние 4ре цифры кредитки, и в этом ему помог Amazon. Хакер позвонил в техподдержку Амазона, представился Мэттом и сказал, что хочет привязать к аккаунту еще одну кредитную карту. Все что нужно знать для этого — ФИО, мыло и адрес. Далее он повесил трубку и позвонил еще раз, но уже с другим запросом — он “забыл пароль от аккаунта” и хотел бы привязать еще один ящик для восстановления пароля. Всё, что нужно знать в таком случае — ФИО, адрес и номер свежей добавленной кредитной карты (!). Теперь осталось зайти на страничку Амазона и посмотреть последние 4 цифры привязанной старым хозяином кредитной карты. Вуаля! Подробнее об этом случае можно почитать здесь.
Какие выводы мы можем сделать по этим примерам:
- Взлом подразумевает наличие “плохого парня”
- Подход к ИБ должен быть глобальным
- Чем меньше мы открываем данных, тем безопаснее система.
Но давайте продолжим и рассмотрим технические детали и возможные уязвимости одного из основополагающих процессов — процесса аутентификации.
Так же очень хочется уделить особое внимание инструментам, которые помогут нам не быть слепыми котятами и дать возможность более прозрачно видеть детали процесса отправки запросов. В частности, изначально пост назывался “Анализ уязвимостей форм для аутентификации”, но я из него убрал слово “форм”, так как хочется показать такой инструмент, как postman.
Инструментарий
- Postman
- tcpdump
- wireshark
- katools (kali linux tool)
- puppeteer
Давайте рассмотрим очень простой пример.
Очень простой пример
Задача: нам нужно по логину и паролю аутентифицировать пользователя.
Для этого создадим табличку:
CREATE TABLE users (
login TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);
Как БД будем использовать sqlite3
.
И напишем запрос аутентификации:
app.get('/api/v1/login', (req, res) => {
db.all(`
SELECT rowid AS id, login, password
FROM users
WHERE login = '${req.query.login}'
`, function(err, rows) {
if (err) {
res.send(err.message);
} else {
let loginFlag = false;
if (rows && rows.length > 0) {
if (req.query.password === rows[0].password) {
loginFlag = true;
}
}
res.send(loginFlag ? "logged in" : "bad news");
}
})
})
Полный листинг сервера здесь.
UPD: Никогда! Никогда! НИКОГДА! не выводите “голые” ошибки и стэктрейсы в ответ. Так как это даст дополнителную информацию злоумышленнику. Например, в нашем примере`res.send(err.message);` даст информацию о том, что мы используем SQLite.
Что здесь происходит, по GET
запросу, мы идем в БД и ищем запись по логину. Если такая есть, сверяем пароль который к нам пришел с тем, который записан в БД. Если все ок выводим "logged in"
, если нет "bad news"
. В базе у нас заведен пользователь с логин = "v1" и паролем "123456".
Этот код, является отличным рассадником уязвимостей, давайте посмотрим почему.
Во-первых. Давайте воспользуемся Postman и составим запрос: 127.0.0.1:3030/api/v1/login?login=v1&password=123456
.
В ответ, мы получим "logged in"
, если мы введем другие данные, например 127.0.0.1:3030/api/v1/login?login=whatthefoxsay&password=test
, то получим "bad news"
, т.е. API работает и выполняет свою задачу.
SQL инъекции
Но что будет, если мы отправим: 127.0.0.1:3030/api/v1/login?login=whatthefoxsay'&password=test
Нам придет SQLITE_ERROR: unrecognized token: "'whatthefoxsay''"
Происходит это потому, что мы используем вот такую конструкцию:
login = '${req.query.login}'
И в результате формирования запроса мы получим:
SELECT rowid AS id, login, password
FROM users
WHERE login = 'whatthefoxsay'';
Где есть синтаксическая ошибка в виде последнего знака '
.
Ок. получив эту ошибку, мы можем догадаться что при обращении /api/v1/login
идет запрос в БД с поиском логина и пароля. Значит мы можем попробовать каким-то образом немного изменить запрос и подставить туда свои данные.
UNION
в SQL
есть оператор UNION
, который позволяет объединять запрос в один по строкам. Работает он так:
SELECT 1,2,3 UNION SELECT 3,2,1;
Выведет:
1,2,3
3,2,1
Давайте попробуем им воспользоваться:
/login?login=whatthefoxsay' union select 'test', 'test&password=test
Получим:
SQLITE_ERROR:
SELECTs to the left and right of UNION do not have the same number of result columns
ага, в данном ответе нам говорят что количество колонок не совпадает. Ок. Давайте это исправим:
/login?login=whatthefoxsay' union select 'test', 'test', 'test&password=test
Получаем: logged in
Вуаля! мы прошли аутентификацию.
Как нам обезопаситься от таких случаев? Все драйвера для БД должны предоставлять инструменты для экранирования входных параметров. В примере с sqlite3 нам нужно использовать вместо:
conn.all(`
SELECT rowid AS id, login, password
FROM users
WHERE login = '${req.query.login}'
`,
function (err, rows) {}
);
Вот это:
conn.all(`
SELECT rowid AS id, login, password
FROM users
WHERE login = $login
`, {
$login: req.body.login
},
function (err, rows) {}
);
Полный листинг сервера здесь.
В этом случае символ '
будет экранироваться и мы получим что-то типа: login = 'whatthefoxsay\' union select \'test\', \'test\', \'test'
Что нас спасет и при попытке отправить:
/login?login=whatthefoxsay' union select 'test', 'test', 'test&password=test
Получим: bad news
Драйвера всегда предоставляют возможность безопасной интерполяции параметров. Используете это!
Например в документации psycopg2 (python драйвер для работы с postgresql), написано:
Warning: Never, never, NEVER use Python string concatenation (+) or string parameters interpolation (%) to pass variables to a SQL query string. Not even at gunpoint.
Предупреждение: Никогда, никогда, НИКОГДА не используйте конкатенацию строк (+) или (%) интерполяцию, чтобы передать параметры в SQL запрос. Даже под дулом пистолета.
Разобрались. Давайте пойдем дальше, и заметим, что мы отсылаем GET
запрос с нашим логином и паролем. Мало того! Мы используем http
вместо https
! Нужно помнить о том, что URL запроса (часть которого - это GET
параметры)гораздо чаще логируются HTTP
серверами и если у злоумышленника будет доступ к логам сервера, он сможет получить их.
TCP/HTTP(s) GET vs POST
в случае, с HTTP
, что с POST
, что c GET
возможна очень простая атака посредника (Man in the middle (MITM)), если конечно у нас есть возможность оказаться посередине. Как пример, вот вы пришли в кафе, подключились к wifi. А какой-то плохой парень взломал роутер и решил записать весь трафик проходящий через него, чтобы узнать куда ходят посетители кафе.
В этом случае он на одном из узлов записывает tcp-дамп:
sudo tcpdump -i lo port 3030 -w ./dump.pcap
Здесь
-i lo
- сетевой интерфейс который мы слушаем, так как у меня все развернуто локально я слушаюlocalhost
.port 3030
- чтобы не засорять эфир, ограничимся портом 3030, который слушает наше приложение.-w ./dump.pcap
- говорит писать дамп в файл.
После отправим два запроса и GET
, и POST
на наше API аутентификации /api/v1/login
. По завершению записи мы воспользуемся wireshark
, чтобы посмотреть что получилось. И увидим, что в дампе оказались наши два запроса с явками и паролями.
Давайте подключим HTTPS
и перейдем на POST
Для начала создадим самоподписанные сертификаты:
openssl req -nodes -new -x509 -keyout server.key -out server.cert
Теперь подключим их к нашему приложению:
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());const fs = require('fs');
const https = require('https');
const privateKey = fs.readFileSync('./server.key', 'utf8');
const certificate = fs.readFileSync('./server.cert', 'utf8');
const credentials = {key: privateKey, cert: certificate};app.post('/api/v1/login', (req, res) => {
/* здесь без изменений */
})const httpsServer = https.createServer(credentials, app);
httpsServer.listen(3030);
Полный листинг сервера здесь
Если мы посмотрим на HTTPS
трафик, то здесь будет все хорошо, так как SSL/TLS
шифрует данные "между" TCP и HTTP протоколами (см. картинку ниже), т.е. все данные HTTP-запроса будут зашифрованы. Кстати, с HTTPS
тоже могут быть проблемы, если сотворить определенную магию с сертификатами, но мы этой темы касаться не будем, скажу одно использовать самоподписанные сертификаты в большинстве случаев является плохой практикой.
А вот что видно в дампе:
Если отойти от аутентификации, архитектурный стиль REST API
для получения данных рекомендует именно GET
. Но давайте представим, что вы пишете приложение для мед.страховой, где есть API /users
, которое принимает параметры: Имя, фамилия, отчество, дата рождения и номер полиса ДМС. В таком случае наш запрос должен выглядеть примерно так: /users?name=Vadim&last_name=Gorbachev&polic=123456
. Соответственно эта информация осядет в логах HTTP сервера, который пример запрос, и эти в них злоумышленник сможет получить пароль в незашифрованном виде. Ах да! шифрование пароля! Это следующая часть нашего доклада.
Шифрование (crypto vs bcrypt)
давайте захэшируем наши пароли, чтобы не хранить в БД в открытом виде, для этого воспользуемся модулем crypto
:
crypto.createHash('md5').update(password).digest('hex');
Полный листинг сервера здесь.
Окей, кажется теперь получше, да? Не совсем.
Во-первых, не используете MD5
, почему спросите вы? А потому, что команда:
crypto.createHash('md5').update('123456').digest('hex');
сгенерирует нам хэш: e10adc3949ba59abbe56e057f20f883e
И если мы воспользуемся таким инструментом как google, то получим: https://google.gik-team.com/?q=e10adc3949ba59abbe56e057f20f883e
что ваш хэш уже даже проиндексирован в поисковике.
Этот пример, конечно утрированный, если мы введем пароль, к примеру whatthefoxsay
, его будет сложнее найти, так как он не настолько простой как 123456
. Но тем, не менее взломать его возможно. И вообще, криптография нам дает только время. Т.е. любой хэш со временем можно взломать. Важно понимать что время может быть равно 10 секундам и 1 млн. лет. Поэтому чем более криптостойкие алгоритмы вы используете, тем надежнее у вас защита (но помните, что она никогда не будет равна 100%, хотя при хороших условиях будет к этому значению стремиться).
Кстати, хотелось бы упомянуть такой инструмент как katools
который является сборником инструментов для анализа уязвимостей репозитория Kali Linux
. в его состав входит такая вещь как findmyhash
. И с помощью команды
findmyhash MD5 -h e10adc3949ba59abbe56e057f20f883e
мы cможем опросить популярные источники “не знаю ли они такого хэша?”.
И мы получим:
***** HASH CRACKED!! *****
The original string is: 123456
The following hashes were cracked:
----------------------------------e10adc3949ba59abbe56e057f20f883e -> 123456
Крайне не рекомендую вводить в подобные инструменты боевые хэши которые вы используете на проде.
MD5 плох! Поэтому давайте воспользуемся, чем-то более надежным.
const getFuncSHA512Salt = (salt) => {
return (password) => {
var hash = crypto.createHmac('sha512', salt);
hash.update(password);
var value = hash.digest('hex');
return value;
}
};
const cryptoSHA512Salt = getFuncSHA512Salt("HVHSNrRWpP1ZSR4bnjXpiHCS1ENYcUuHO")
Хотел отметить, что здесь мы добавляем соль HVHSNrRWpP1ZSR4bnjXpiHCS1ENYcUuHO
. Такое использование, это минимальный способ шифрования который стоит использовать в наше время (возможно, будет не так страшно использова sha256
, но все же).
В той же документации к crypto:
const secret = 'abcdefg';
const hash = crypto.createHmac('sha256', secret)
.update('I love cupcakes')
.digest('hex');
Они по умолчанию приводят пример хеширования уже с солью. Спасибо им!
Давайте немного отвлечемся и рассмотрим еще одну cool story. https://www.opennet.ru/opennews/art.shtml?num=46768
Cool story 3
Возможно, вы помните как npm отозвал пароли около 170 тыс. аккаунтов и вот этот пост. А все дело в том, что очень интересный человек Сковорода Никита Андреевич @ChALkeR, который сейчас состоит в рабочей группе по безопасности node.js, провел анализ уязвимости аутентификации пользователей в npm.
возможно вы использовали пакеты из списка: Express, EventEmitter2, mime-types, semver, npm, fstream, cookie (и cookies), Bower, Component, Connect, koa, co, tar, css, gm, csrf, keygrip, jcarousel, serialport, basic-auth, lru-cache, inflight, mochify, denodeify, и многие другие.
К которым Никита получил доступ и описал подробности в статье опубликованной 2015–12–04.
Позже он провел еще одно исследование от опубликованное 2017–06–21
В котором рассказал что ситуация слабо изменилась. В этот раз список ТОП пакетов был такой: debug, qs, supports-color, yargs, commander, request, strip-ansi, chalk, form-data, mime, tunnel-agent, extend, delayed-stream, combined-stream, forever-agent, concat-stream, vinyl, co, express, escape-html, path-to-regexp, component-emitter, moment, ws, handlebars, connect, escodegen, got, gulp-util, ultron, http-proxy, dom-serializer, url-parse, vinyl-fs, configstore, coa, csso, formidable, color, winston, node-sass, react, react-dom, rx, postcss-calc, superagent, basic-auth, cheerio, jsdom, gulp, sinon, useragent, deprecated, browserify, redux, array-equal, bower, jshint, jasmine, global, mongoose, vhost, imagemin, highlight.js, tape, mysql, mz, nock, rollup, gulp-less, rework, xcode, ionic, cordova, normalize.css, electron, n, react-native, ember-cli, yeoman-generator, nunjucks, koa, modernizr, yo, mongoskin, и многие другие.
Результаты оказались ошеломляющими:
- из 126 тыс, удалось получить доступ к 17 тыс, что примерно 13%
- Количество пострадавших пакетов 73983–14% экосистемы.
- Количество пострадавших через зависимости — 54%
- Получил аккаунты 4 пользователей из списка топ-20
- 42 пользователя имели более 10 миллионов загрузок в месяц (каждый).
- 13 пользователей имели более 50 миллионов.
- Одним из аккаунтов с доступом к koa был “password”
- 662 — «123456», 174 — «123», 124 — password».
- 11% пользователей повторно использовали свои просочившиеся пароли: 10,6% — напрямую, и 0,7% — с очень незначительными изменениями.
- т.е. он мог бы контролировать 1 972 421 945 загрузок в месяц (напрямую), это 20% от общего числа.
Заметьте 662
пользователей использовали пароль 123456
. А вы говорите, я утрирую =)
Один из кейсов получения паролей, был использование базы утекших паролей с других ресурсов. Например, вы зарегистрировались с паролем whatthefoxsay
в npm и на сайте blabla.site. Если взломают сайт blabla.site и получат все явки из него. Как вы думаете злоумышленники не захотят пройтись по ТОП-20 сайтам (таких как gmail, facebook, twitter, ..., npm, github) с попытками ввода логинов/паролей из базы? Чтобы проверить есть ли у вас утекшие логины, можно воспользоваться https://haveibeenpwned.com/ , который рекомендует npm. Будете ли вы получать спам на почту после того как введете вашу почту? Я не знаю, но если вы доверяете npm и их рекомендациям, то можете проверить =)
Возможно, хороший способ обезопасить себя от подобных ситуаций является использование менеджеров паролей. Можно ознакомиться с топом в этой статье, многие рекомендуют 1password
. Но давайте не будет на этом задерживаться и пойдем дальше.
crypto vs bcrypt
Давайте, обратимся к гуру интернетов чтобы узнать действительно ли мы делаем все правильно. И по запросу node.js best practice
наткнемся на репозиторий, который действительно содержит очень много нужных и полезных рекомендаций + к этим рекомендациям многие прислушиваются, о чем свидетельствуют звездочки на github, которых около 23 тыс. В нем есть раздел 6-security-best-practices
, в котором даются советы и на часть примеров которые мы рассмотрели и на многие другие ситуации. Среди которых есть пункт: 6.8. Avoid using the Node.js crypto library for handling passwords, use Bcrypt
Пароли или секреты (ключи API) должны храниться с использованием безопасной функции hash + salt
, такой которую предоставляет bcrypt
, которая должна быть предпочтительным выбором по сравнению с реализацией JavaScript из-за соображений производительности и безопасности.
Также обратим внимание на призыв не использовать Math.random()
в алгоритмах шифрования. Не будем углубляться, но кому интересно почитайте статью Майорова Случайные числа не случайны.
Но вернемся к crypto vs bcrypt. Нам предлагают использовать код:
// asynchronously generate a secure password using 10 hashing rounds
bcrypt.hash('myPassword', 10, function(err, hash) {
// Store secure hash in user record
});// compare a provided password input with saved hash
bcrypt.compare('somePassword', hash, function(err, match) {
if(match) {
// Passwords match
} else {
// // Passwords don't match
}
});
Давайте же последуем совету! И перепишем наше API на:
bcrypt.hash("whatthefoxsay", 10, function(err, hash) {
// Store secure hash in user record
db.createUser(conn,'v7', hash)
});app.post('/api/v1/login', (req, res) => {
conn.all(`
SELECT rowid AS id, login, password
FROM users
WHERE login = $login
`, {
$login: req.body.login
},
function (err, rows) {
if (err) {
res.send(err.message);
} else {
if (rows && rows.length > 0) {
// compare a provided password input with saved hash
bcrypt.compare(
req.body.password,
rows[0].password,
function(err, match) {
res.send(match ? 'logged in' : 'bad news');
}
);
} else {
res.send('bad news');
}
}
})
})
Кажется что все выглядит пуленепробиваемое! Но!
Заметим один нюанс. Давайте попробуем устроить небольшой брутфорс пар логин+пароль по нашему API.
Воспользуемся подобной функцией:
const sendRequest = function (login, password) {
const result = {
start: Date.now(),
login,
password
}
const promise = new Promise(function (res, rej) {
request.post(
'https://127.0.0.1:3030/api/v1/login',
{ json: { login, password } },
function (error, response, body) {
if (!error && response.statusCode == 200) {
result.end = Date.now();
res(result);
}
}
);
})
return promise;
}
И отправим по 10 запросов на каждый из логинов v7
, v7_wrong
, v7_wrong2
$ node ./attaker.js
v7 917
v7_wrong 40
v7_wrong2 39
хмм.. давайте отправим еще раз:
$ node ./attaker.js
v7 837
v7_wrong 42
v7_wrong2 45
Заметим что ответы с логином v7
заметно отличаются от остальных. Что здесь происходит, а происходит то, что если пользователь мы угадали, дальше идет тяжелый, надежный алгоритм bcrypt
, который шифрует нам password
из запроса и пытается сравнить с хэшем из БД. А давайте попробуем вернуть crypto
и sha512
с солью:
$ node ./attaker.js
v6 54
v6_wrong 32
v6_wrong2 30
finish
Заметим, что здесь разница не такая значительная, и если мы хостились на удаленной машине, а не на localhost
, то погрешность в скорости прохождения пакетов по сети просто бы съела это 20-30 мс в 10 запросов. Значит ли это что bcrypt
плох? Конечно нет. Мы просто неправильно его готовим, о чем, к сожалению best practice нам не говорит. На момент написания статьи, мы обсуждаем этот момент в issue, присоединяйтесь.
Но если мы изменим наш код таким образом, чтобы мы всегда вычисляли хэш. Например:
if (rows && rows.length > 0) {
// compare a provided password input with saved hash
bcrypt.compare(req.body.password, rows[0].password, function(err, match) {
console.log('good');
res.send(match ? 'logged in' : 'bad news');
});
} else {
bcrypt.compare(
req.body.password,
"$2b$10$m.fhQdLyRI8ExS/GGh43FOkO.XTCS85QdVpn6sINdlxTGQSJe3Ydi",
function(err, match) {
console.log('bad');
res.send('bad news');
}
);
}
То получим:
$ node ./attaker.js
v7 786
v7_wrong 778
v7_wrong2 762
finish
Что вполне нас обезопасит от тайминговых атак. Кстати, по этой теме есть хорошая статья
usability vs security
Напоследок хочется рассмотреть еще один пример. Который не связан с аутентификацией напрямую. Но связан с формой регистрации. Сейчас фронтендеры ослеплены лучшими практиками UX, все стараются максимально ублажить пользователя и иногда в этой спешке теряются важные нюансы, о которых не стоит забывать.
Как пример, в статье предлагается уведомлять пользователя, после того как он ввел почту, зарегистрирован акк на этот email или нет. Это конечно красиво, но не до конца. Конечно мы можем добавить какие-то дополнительные проверки, на то чтобы выявить что к нам за проверкой почты стучится человек, а не машина. Но вы посмотрите за окно!
Сейчас на улице другие законы! селфдрайвинг кары сбивают промо роботов.
И есть такой волшебный инструмент как puppeteer, с помощью которого притвориться человеком гораздо проще. И мы можем брутфорсом пройти по форме регистрации и собрать базу email’ов пользователей, тех или иных ресурсов.
Для этого есть спасение — это ограничение количества запросов. Например как это делает github. На его ограничение даже можно наткнуться руками, если очень настырно пытаться его спрашивать.
Ссылки на источники
- Листинги кода и примеры описанные в статье
- Курс MIT «Безопасность компьютерных систем»
- Ответы юриста: как избежать ответственности за поиск уязвимостей
- Про Мэта Хонан
- psycopg2: The problem with the query parameters
- Тайминговая атака на Node.js — когда время работает против вас
- Исследование @ChALkeR
- Майоров “Случайные числа не случайны”
- Node.js best practices list
- Node.js Security Working Group
- Юзабилити форм авторизации
Спасибо!