Уязвимости и проблемы в коде часто возникают не только из-за ошибок при его написании, но и из-за неправильного тестирования. Для того чтобы отлавливать XSS и SQLi, не нужно быть «хакером» или экспертом по ИБ — достаточно использовать модульные тесты (unit tests). Сегодня мы поговорим о том, как правильно это делать.
Источник проблем или банальные атаки: XSS/SQLi
Проблемы вроде XSS или SQLi встречаются часто, и основная их причина кроется в недостаточной фильтрации вывода и ввода. Соответственно, для XSS более критичен вывод, а для SQLi — ввод. В больших и крупных проектах, конечно, все может быть гораздо сложнее. Кроме того, дырки могут возникнуть не в изначальном коде, а в патчах. Написал разработчик новый код, добавил фичу, а вместе с ней в проект попала и уязвимость. Как быть?
Эта задача может быть решена при помощи классических unit-тестов. Эти тесты позволят сразу проверять код на типичные ошибки и ловить их на самой ранней стадии. Кроме того, все плюсы модульного тестирования переносятся и на вопросы ИБ — это и осведомленность разработчика, и документация кода, и покрытие кода, и раннее обнаружение проблем. Фактически такие тесты показывают предсказуемость поведения программы при атаках.
Но стоит помнить: тесты сделают ровно то, на что их запрограммировал девелопер, так что не стоит заменять ими статический анализ кода — это разные задачи. Более того, при хорошем покрытии такие тесты могут решить задачу поиска уязвимостей силами самих разработчиков и снизить траты на внешних консультантов. Поэтому если бюджет у нас ограничен, то лучше развивать внутреннее тестирование, чем привлекать консультантов и покупать дорогие решения.
Безопасность — дело рук разработчика
Отдельная тема — использование unit-тестов в рамках модели SDLC (Software Development Life Cycle), да еще и в случае, когда разработка ведется по методологии Agile. Тут важно понимать, что одной из задач станет внедрение всех модных процедур ИБ прямо в сумасшедший ритм разработки кода. Поэтому важна не только автоматизация большинства процессов, но и включение в эти процессы программистов. Как раз для того, чтобы подключить к процессу кодеров, можно потребовать от них написание юнит-тестов, например на предмет фильтрации входных/выходных данных.
Таким образом, тестировать код на баги полностью смогут сами разработчики с помощью модульного тестирования, а сами кодеры будут понимать, зачем это надо и как это работает. С фильтрацией входа и выхода это кажется наименее сложным и понятным разработчику. При этом тестирование может быть достаточно простым, чтобы не сильно увеличивать время разработки.
Hello World
Давай я просто покажу пример такого подхода, чтобы было нагляднее. В качестве демонстрации будем использовать метод разработки через тестирование, то есть сначала мы опишем тест, который позволит удостовериться, что код работает как надо, а затем напишем код, который будет проходить этот тест. Опишем задачу:
«Пользователь может аутентифицироваться в системе с помощью пары логин-пароль. При этом у каждого пользователя может быть какая-то роль в системе. Кроме того, пользователь может добавлять новых пользователей с любой ролью, но только если этот пользователь характеризуется ролью со значением 0. Изначально такой пользователь есть только один — admin с паролем по умолчанию passw0rd. Также любой пользователь может сменить себе пароль, пользователь с ролью ноль может сменить пароль любому пользователю.
У меня нет возможности выложить тут полный код unit-теста и проговорить каждую строчку, поэтому сразу оговорюсь, что весь код доступен в GitHub.
Итак, вот такие тесты мы написали по логике работы:
'''Синтаксис ввода: login <username> <passw> '''
class LoginTest(unittest.TestCase):
TestUser=None
def setUp(self):
print "SETUP"
self.TestUser=proto.HelloUser()
def tearDown(self):
print "CLEAR"
self.TestUser=None
reload(proto)
'''По умолчанию не аутентифицированы'''
def test_Auth0(self):
self.assertTrue(self.TestUser.current_role<0, "По умолчанию роль должна быть отрицательной")
'''Пробуем неправильный пароль'''
def test_Auth1(self):
self.TestUser.parse("login admin not_exist")
self.assertTrue(self.TestUser.current_role<0,"Удалось получить роль без правильного пароля")
'''Пробуем неправильный логин и пароль'''
def test_Auth2(self):
self.TestUser.parse("login not_admin not_exist")
self.assertTrue(self.TestUser.current_role<0,"Удалось получить роль без существующего логина")
'''Пробуем неправильный логин и правильный пароль'''
def test_Auth3(self):
self.TestUser.parse("login not_admin passw0rd")
self.assertFalse(self.TestUser.current_role==0,"Удалось получить роль без существующего логина")
'''Пробуем правильный логин и пароль'''
def test_Auth4(self):
self.TestUser.parse("login admin passw0rd")
self.assertTrue(self.TestUser.current_role==0,"Должны получить нулевую роль")
def test_Auth5(self):
self.TestUser.parse("log1in admin aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
self.assertTrue(self.TestUser.current_role<0,"Значение роли должно быть отрицательным")
Итак, тесты полностью описывают то, как мы хотим, чтобы это работало. Так как мы подразумеваем еще и безопасность, давай сразу включим фильтры ввода.
'''Синтаксис ввода: login <username> <passw> '''
class LoginSecurityTest(unittest.TestCase):
def setUp(self):
print "SETUP"
self.TestUser=proto.HelloUser()
def tearDown(self):
print "CLEAR"
self.TestUser=None
reload(proto)
'''LDAP Injection 1'''
def test_LDAP1(self):
self.TestUser.parse("login * *")
self.assertFalse(self.TestUser.current_role==0, "LDAP Injection 1")
'''LDAP Injection 2'''
def test_LDAP2(self):
self.TestUser.parse("login admin *")
self.assertFalse(self.TestUser.current_role==0, "LDAP Injection 2")
'''LDAP Injection 3'''
def test_LDAP3(self):
self.TestUser.parse("login admin)(&)) *")
self.assertFalse(self.TestUser.current_role==0, "LDAP Injection 3")
'''SQL Injection'''
def test_SQL1(self):
self.TestUser.parse("login admin' aaa")
self.assertFalse(self.TestUser.current_role==0, "SQL Injection 1")
'''SQL Injection 2'''
def test_SQL2(self):
self.TestUser.parse("login admin'-- aaa")
self.assertFalse(self.TestUser.current_role==0, "SQL Injection 2")
'''SQL Injection 3'''
def test_SQL3(self):
self.TestUser.parse("login admin aaa'or'1'like'1")
self.assertFalse(self.TestUser.current_role==0, "SQL Injection 3")
'''SQL Injection 4'''
def test_SQL4(self):
self.TestUser.parse("login admin '-0=0-'")
self.assertFalse(self.TestUser.current_role==0, "SQL Injection 4")
'''SQL Injection 5'''
def test_SQL5(self):
self.TestUser.parse("login admin 1'='2")
self.assertFalse(self.TestUser.current_role==0, "SQL Injection 5")
'''Input validation 1'''
def test_SQL5(self):
self.TestUser.parse("login'$%^&*\">< admin admin")
self.assertFalse(self.TestUser.current_role==0, "Input validation 1")
'''Input validation 2'''
def test_SQL5(self):
self.TestUser.parse("login admin'$%^&*\">< admin'$%^&*\"><")
self.assertFalse(self.TestUser.current_role==0, "Input validation 2")
Теперь при разработке кода нужно всего лишь сделать так, чтобы все тесты были пройдены. Но суть в том, что мы уже заранее продумали множество ошибок, в том числе и классические. Само собой, можно обмануть и эти тесты, но для этого разработчик должен быть сам себе злобной буратиной. Главный плюс, на мой взгляд, перенос вопросов безопасности непосредственно в процесс разработки на самом раннем этапе и автоматизация проверок. Все эти тесты создаются достаточно быстро, но мы уже заранее готовы проверять реализацию.
Теперь возьмем самый простой код:
class HelloUser:
cmd=''
params=[]
current_role=-1
db=None
lastError=None
def __init__(self):
conn = sqlite3.connect(":memory:")
c = conn.cursor()
# Create table users -> name:password:role
c.execute("CREATE TABLE users (name text, password text, role integer)")
c.execute("INSERT INTO users VALUES ('admin','passw0rd',0)")
conn.commit()
self.db=c
def login(self):
role=self.db.execute("SELECT role FROM users WHERE name='"+self.params[0]+"' AND password='"+self.params[1]+"'")
res=role.fetchone()
if res:
self.current_role=res[0]
else:
self.current_role=-2
def help(self):
print "\nUSE: \t login <username> <password>"
def parse(self,input):
# Command
self.cmd=input.split(" ")[0]
# Parametrs
self.params=filter(None, input.split(" ")[1:])
if self.cmd=='login':
self.login()
print "Logged in with ROLE: "+str(self.current_role)
elif self.cmd=='help':
self.help()
else:
self.help()
Тут у нас есть конкатенация, а значит, все будет плохо. Наши тесты сразу найдут косяки! Разработчик видит ошибку и заменяет конкатенацию:
strSQL="SELECT role FROM users WHERE name='%s' AND password='%s'"
role=self.db.execute(strSQL % (self.params[0],self.params[1]))
Но мы-то знаем, что это просто форматирование строки и не спасает от SQLi. Наши тесты также это все детектируют. Очевидный плюс: если в результате рефакторинга добавится бага, она будет обнаружена тестами на самом раннем этапе. Что ж, меняем код на prepared statements:
strSQL="SELECT role FROM users WHERE name=? AND password=?"
role=self.db.execute(strSQL, [self.params[0],self.params[1]])
Тесты пройдены!
Реализуем функционал смены пароля, добавления пользователя согласно требованиям через тестирование. Все эти примеры ты найдешь здесь:. Да, прошу прощения, если мой стиль кодинга немного стремный, и, конечно, я не полностью покрыл все идеи тестами, но такой уж я торопыга :). Главное — донести идею (отмазался. — Прим. совести). Кстати, замечу, что тесты проверяют и ролевую модель доступа, а не только SQLi, что, несомненно, плюс!
Ты мог заметить, что модуль и тестирование реализованы через метод парсинга команд. Все то же тестирование можно было выполнить, напрямую дергая методы класса, но тогда бы мы не тестировали сам парсинг. В идеале эти тесты можно было разделить, но поскольку парсинг напрямую вызывает методы класса, то так вышло несколько проще и логичнее. Для многих проектов можно делать тесты поведения пользователя и включать туда кейсы, связанные с безопасностью, это будет уже что-то среднее между сканером уязвимостей и юнит-тестированием. Многие разработчики используют и такой подход. На OWASP даже есть фреймворк для такого тестирования веб-приложений. Но проверка кода с помощью классических тестов также может быть полезна, и, надеюсь, я тебя в этом убедил.
Вместо вывода
Разработка через тестирование может показаться занудным делом, но тесты пишутся достаточно быстро. Кроме того, включение кейсов, связанных с безопасностью, валидацией логики и ввода, позволят прямо в процессе разработки исключить большинство детских уязвимостей. Кроме того, это позволит развить культуру ИБ в команде и сохранить ее, что наиболее важно, — ведь если уйдет разработчик, который писал тесты, или придет новый, кто еще не в теме, то тесты позволят сохранить «знания о проблемах и методах контроля».
Конечно, не всегда и не везде такое возможно использовать, и многое зависит от разработчиков, сложности проектов и взаимосвязи компонентов, которые мы тестируем. Тем не менее тестирование — важный момент, который так или иначе лежит на плечах разработчика, и это скажется на безопасности лучше, чем обращение к консультантам или покупка лицензии сканера, особенно если развивать эту идею дальше. Всем безопасной разработки!