Уязвимости и проблемы в коде часто возникают не только из-за ошибок при его написании, но и из-за неправильного тестирования. Для того чтобы отлавливать 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 провалены...
В процессе тестирования видно, что тесты SQLi провалены...

Тесты пройдены!

Реализуем функционал смены пароля, добавления пользователя согласно требованиям через тестирование. Все эти примеры ты найдешь здесь:. Да, прошу прощения, если мой стиль кодинга немного стремный, и, конечно, я не полностью покрыл все идеи тестами, но такой уж я торопыга :). Главное — донести идею (отмазался. — Прим. совести). Кстати, замечу, что тесты проверяют и ролевую модель доступа, а не только SQLi, что, несомненно, плюс!

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

Вместо вывода

Разработка через тестирование может показаться занудным делом, но тесты пишутся достаточно быстро. Кроме того, включение кейсов, связанных с безопасностью, валидацией логики и ввода, позволят прямо в процессе разработки исключить большинство детских уязвимостей. Кроме того, это позволит развить культуру ИБ в команде и сохранить ее, что наиболее важно, — ведь если уйдет разработчик, который писал тесты, или придет новый, кто еще не в теме, то тесты позволят сохранить «знания о проблемах и методах контроля».

Конечно, не всегда и не везде такое возможно использовать, и многое зависит от разработчиков, сложности проектов и взаимосвязи компонентов, которые мы тестируем. Тем не менее тестирование — важный момент, который так или иначе лежит на плечах разработчика, и это скажется на безопасности лучше, чем обращение к консультантам или покупка лицензии сканера, особенно если развивать эту идею дальше. Всем безопасной разработки!

Аватар

Алексей Синцов

Известный white hat, докладчик на security-конференциях, соорганизатор ZeroNights и просто отличный парень. В данный момент занимает должность Principal Security Engineer в компании Nokia, где отвечает за безопасность сервисов платформы HERE

Теги:

Check Also

HTB Forge. Эксплуатируем SSRF для доступа к критической информации

Сегодня мы с тобой пройдем среднюю по сложности машину с площадки Hack The Box под названи…

Подписаться
Уведомить о
3 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии