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

Теги:

3 комментария

  1. 12.09.2014 at 23:40

    Тесты рулят как не крути.Но по правде юзаешь норм фреймворк то встать на грабли

    XSS/SQLi вообще не реально, уже все в prepared statements.

  2. http://vkochetkov.blogspot.com/

    09.10.2014 at 22:52

    При подобном подходе правильнее использовать не жестко определенное множество векторов, а фаззинг тестируемой точки входа всеми возможными грамматическими конструкциями островного языка, позволяющими выйти за пределы того или иного класса токенов. Это позволяет покрыть все множество состояний парсера и, на практике, вполне укладывается во временные ограничения процесса CI.

    • 10.10.2014 at 16:06

      Не подходит для тестирования логики, я говорил не только про парсер. Но в целом согласен, просто тут более ограниченный «набор» данных для тестирования. Скорее это тестирование на ожидаемое поведение, чем поиск плохого вектора, и часто это эффективнее при разработке, но имеет и недостатки, тут надо смотреть от целей и ресурсов.

Оставить мнение

Check Also

WTF is APT? Продвинутые атаки, хитрости и методы защиты

Наверняка ты уже читал о масштабных сетевых атаках, от которых пострадали банки, крупные п…