eval() в Python: від «зручної функції» до «дірки в безпеці»¶
Довідник про вбудовану функцію eval() — що вона робить, чому вона небезпечна з чужим вводом, і чим її замінити залежно від задачі. Наскрізний приклад — CLI-калькулятор без eval() (cli_calculator.py); calculator_project/ лежить у папці Уроку 13 (lesson_13_modules_stdlib/, модулі та import); концептуально пов'язаний також з Уроком 18 (міні-проєкт CLI).
Що таке eval()?¶
eval() — вбудована функція Python, яка виконує рядок як Python-вираз і повертає результат:
| Параметр | Тип | Опис |
|---|---|---|
expression |
str |
Рядок з Python-виразом |
globals |
dict або None |
Глобальні змінні, доступні у виразі |
locals |
dict або None |
Локальні змінні, доступні у виразі |
eval() проти exec()¶
| Функція | Що робить | Повертає |
|---|---|---|
eval(expr) |
Виконує вираз (expression) | Значення виразу |
exec(code) |
Виконує код (statements) | None |
Базові приклади¶
result = eval("2 + 3 * 4")
print(f"eval('2 + 3 * 4') = {result}")
print(f"Тип результату: {type(result).__name__}")
expressions = ["10 + 5", "10 - 5", "10 * 5", "10 / 3", "10 // 3", "10 % 3", "2 ** 8", "(3 + 5) * 4"]
for expr in expressions:
r = eval(expr)
print(f"{expr!r:<15} -> {r!r} ({type(r).__name__})")
eval('2 + 3 * 4') = 14
Тип результату: int
'10 + 5' -> 15 (int)
'10 - 5' -> 5 (int)
'10 * 5' -> 50 (int)
'10 / 3' -> 3.3333333333333335 (float)
'10 // 3' -> 3 (int)
'10 % 3' -> 1 (int)
'2 ** 8' -> 256 (int)
'(3 + 5) * 4' -> 32 (int)
eval() бачить поточні змінні¶
number = 9
greeting = "Привіт"
pi = 3.14159
print(eval("number * number")) # 81
print(eval("number ** 0.5")) # 3.0
print(eval("pi * 2")) # 6.28318
print(eval("len(greeting)")) # 6
print(eval("greeting.upper()")) # ПРИВІТ
eval() бачить усі змінні поточного scope — це і зручно, і, як стане видно нижче, небезпечно.
eval() підтримує будь-який вираз — не тільки арифметику¶
import math
examples = [
"math.sqrt(16)",
"math.factorial(5)",
"abs(-42)",
"round(3.14159, 2)",
"max(1, 5, 3, 2)",
"[x**2 for x in range(5)]", # навіть list comprehension
]
for expr in examples:
print(f"eval({expr!r}) -> {eval(expr)}")
eval('math.sqrt(16)') -> 4.0
eval('math.factorial(5)') -> 120
eval('abs(-42)') -> 42
eval('round(3.14159, 2)') -> 3.14
eval('max(1, 5, 3, 2)') -> 5
eval('[x**2 for x in range(5)]') -> [0, 1, 4, 9, 16]
Параметри globals та locals¶
eval() приймає два необов'язкові словники, що контролюють яке середовище бачить вираз:
Пошук змінної у eval():
1. locals (якщо переданий)
2. globals (якщо переданий)
3. Стандартні builtins (якщо globals не забороняє)
| Виклик | Що доступно у виразі |
|---|---|
eval(expr) |
Всі глобальні + локальні змінні + builtins |
eval(expr, {}) |
Тільки builtins |
eval(expr, {'__builtins__': None}) |
НІЧОГО (навіть print недоступний) |
eval(expr, {'sqrt': math.sqrt}) |
Тільки sqrt + builtins |
eval(expr, {'__builtins__': None}, {'x': 5}) |
Тільки x |
Контроль через globals¶
import math
result1 = eval("math.sqrt(16)")
print(result1) # 4.0 — без обмежень, eval() бачить усе
try:
result2 = eval("math.sqrt(16)", {}) # порожній globals — math недоступний
except NameError as e:
print(f"NameError: {e}")
allowed = {"sqrt": math.sqrt, "pi": math.pi}
result3 = eval("sqrt(16) + pi", allowed)
print(f"{result3:.4f}")
globals дозволяє точно контролювати, які символи доступні у виразі — аж до перейменування функцій під власні імена:
custom_names = {
"square_root": math.sqrt,
"power": pow,
"cube": lambda x: x ** 3,
"__builtins__": None, # забороняємо стандартні builtins
}
for expr in ["square_root(25)", "power(2, 10)", "cube(4)"]:
print(f"{expr} = {eval(expr, custom_names)}")
globals і locals можна передати разом — locals при цьому перекриває globals:
a = 169
globals_dict = {"__builtins__": None, "sqrt": math.sqrt}
locals_dict = {"a": a, "b": 25}
print(eval("sqrt(a) + sqrt(b)", globals_dict, locals_dict)) # 18.0
globals_dict2 = {"x": 100}
locals_dict2 = {"x": 5}
print(eval("x * 2", globals_dict2, locals_dict2)) # 10 — locals перемогли
Чому eval() небезпечний¶
Головне правило безпеки
Ніколи не передавайте в eval() дані від користувача без суворої фільтрації.
eval() виконує будь-який Python вираз — включно з викликом __import__() для завантаження будь-якого модуля, виконанням системних команд через os.system(), читанням/записом файлів і доступом до __builtins__, а через нього — до всього Python:
Ваш код: print(eval(input("Вираз: ")))
Звичайний ввід: 2 + 2 → 4 ✅
Шкідливий ввід:
__import__("os").system("whoami") → виконує системну команду ❌
__import__("os").system("ls /") → читає список файлів ❌
open("/etc/passwd").read() → читає довільні файли ❌
__import__("shutil").rmtree(".") → видаляє файли ❌
«Я зробив калькулятор через
eval()!» — «Ти зробив Remote Code Execution вразливість.»
Ось таблиця ризику для кількох типових рядків вводу (навмисно НЕ виконуємо шкідливі з них — лише класифікуємо):
Ввід користувача Ризик
----------------------------------------------------------------
'2 + 2' ✅ безпечний
'__import__("os").getcwd()' ⚠️ читає поточну папку
'__import__("os").system("whoami")' ❌ ВИКОНУЄ системну команду
'open("passwords.txt").read()' ❌ ЧИТАЄ файли на диску
'[x for x in ().__class__.__bases__[0].__subclasses__()]' 💀 дістає доступ до всіх класів Python
Навіть «захищений» eval() можна обійти¶
Заборона __builtins__ не рятує сама по собі — через ланцюжок __class__ / __subclasses__ можна дістатись до довільних класів Python, включно з тими, що дають доступ до os/subprocess:
safe_globals = {"__builtins__": None}
result = eval("2 + 2", safe_globals)
print(result) # 4 — прості вирази й далі працюють
exploit_expr = "().__class__.__bases__[0].__subclasses__()"
subclasses = eval(exploit_expr, safe_globals)
print(len(subclasses)) # кілька сотень підкласів — серед них можна знайти шлях до os/subprocess
Висновок: надійно захистити eval() від зловмисника дуже складно. Найкращий захист — не використовувати eval() із user input взагалі.
Безпечні альтернативи¶
Яку альтернативу обрати, залежить від задачі:
| Задача | Альтернатива |
|---|---|
| Прочитати одне число/дату з рядка | явне перетворення типу (int(), float(), datetime.fromisoformat() тощо) |
| Прочитати структуровані дані (списки, словники) у відомому, довіреному форматі | json.loads() |
Прочитати рядок, що є Python-літералом (число, список, словник, кортеж, множина, bool, None) |
ast.literal_eval() |
| Обчислити арифметичний вираз від користувача | власний парсер (нижче) або бібліотека для безпечної математики |
Варіант А — явне перетворення типів¶
Найпростіший і найнадійніший спосіб для одного значення очікуваного типу:
age = int(input("Вік: ")) # ValueError, якщо не число — і це нормально, це очікувана помилка
price = float(input("Ціна: "))
Жодного виконання довільного коду тут неможливе в принципі — int()/float() вміють перетворювати лише рядок у число, більше нічого.
Варіант Б — json.loads() для структурованих даних¶
Якщо дані приходять у форматі JSON (наприклад, з конфіг-файлу, API, фронтенду) — json.loads() розбирає лише JSON-синтаксис, без жодного виконання коду:
import json
data = json.loads('{"name": "Alice", "scores": [95, 87, 92]}')
print(data, type(data)) # {'name': 'Alice', 'scores': [95, 87, 92]} <class 'dict'>
Варіант В — ast.literal_eval() для Python-літералів¶
ast.literal_eval() розбирає рядок як абстрактне синтаксичне дерево і дозволяє лише вузли, що є літералами — числа, рядки, списки, словники, кортежі, множини, True/False/None. Будь-який виклик функції, доступ до атрибута чи інший виконуваний вираз відхиляється з ValueError/SyntaxError:
import ast
safe_inputs = ["42", "3.14", "[1, 2, 3]", "{'name': 'Alice', 'age': 25}", "(1, 2, 3)", "True"]
unsafe_inputs = ["2 + 2", "os.system('ls')", "__import__('os')"]
for s in safe_inputs:
print(f"ast.literal_eval({s!r}) -> {ast.literal_eval(s)!r}")
for s in unsafe_inputs:
try:
ast.literal_eval(s)
except (ValueError, SyntaxError) as e:
print(f"ast.literal_eval({s!r}) -> заблоковано: {type(e).__name__}")
ast.literal_eval('42') -> 42
ast.literal_eval('3.14') -> 3.14
ast.literal_eval('[1, 2, 3]') -> [1, 2, 3]
ast.literal_eval("{'name': 'Alice', 'age': 25}") -> {'name': 'Alice', 'age': 25}
ast.literal_eval('(1, 2, 3)') -> (1, 2, 3)
ast.literal_eval('True') -> True
ast.literal_eval('2 + 2') -> заблоковано: ValueError
ast.literal_eval("os.system('ls')") -> заблоковано: ValueError
ast.literal_eval("__import__('os')") -> заблоковано: ValueError
ast.literal_eval() — значно безпечніший, але не «безумовно безпечний»
ast.literal_eval() не виконує код — він лише перевіряє, що вираз складається з дозволених літералів, і гарантує, що виклики функцій, доступ до атрибутів (__class__ тощо) та імпорти пройти не зможуть. Це робить його придатним для розбору довіреного/напівдовіреного вводу у форматі Python-літералів.
Але це не універсальний "safe eval": він все одно повністю парсить вхідний рядок через ast, і на дуже великих чи спеціально сконструйованих рядках (наприклад, глибоко вкладені структури або величезні числові літерали) можливі проблеми продуктивності (DoS через складність парсингу) — у різних версіях Python траплялися відповідні виправлення. Для даних із недовірених джерел, де формат заздалегідь відомий (а не довільний Python-літерал), надійніше використовувати json.loads() — парсер JSON вужчий і передбачуваніший за граматику Python-літералів.
Варіант Г — словник операторів (для a op b)¶
Для простого «число - оператор - число» без пріоритетів і дужок:
OPERATORS = {
'+': lambda a, b: a + b,
'-': lambda a, b: a - b,
'*': lambda a, b: a * b,
'/': lambda a, b: a / b if b != 0 else None,
'//': lambda a, b: a // b if b != 0 else None,
'%': lambda a, b: a % b if b != 0 else None,
'**': lambda a, b: a ** b,
}
def safe_calculate(a: float, op: str, b: float):
"""Безпечне обчислення без eval()."""
if op not in OPERATORS:
return None, f"Невідомий оператор: {op!r}"
result = OPERATORS[op](a, b)
if result is None:
return None, "Ділення на нуль"
return result, None
for a, op, b in [(10, '+', 5), (15, '/', 4), (5, '/', 0), (5, '@', 3)]:
result, err = safe_calculate(a, op, b)
print(f"{a} {op} {b} -> {err or result}")
Словник операторів не вміє пріоритетів (2 + 3 * 4 без дужок), дужок і унарного мінуса — для цього потрібен повноцінний парсер.
Варіант Д — власний парсер (алгоритм Shunting-Yard)¶
Для калькулятора з повноцінними пріоритетами та дужками застосовується алгоритм Shunting-Yard (Едсгер Дейкстра, 1961): він конвертує інфіксний запис (2 + 3 * 4) у постфіксний (RPN, Reverse Polish Notation: 2 3 4 * +), де немає потреби в дужках, а пріоритети вже враховані, і результат обчислюється простим стеком:
Покрокове обчислення RPN "2 3 4 * +":
Стек: [] читаємо 2 -> стек: [2]
Стек: [2] читаємо 3 -> стек: [2, 3]
Стек: [2, 3] читаємо 4 -> стек: [2, 3, 4]
Стек: [2, 3, 4] читаємо * -> pop(4,3), push(3*4=12) -> [2, 12]
Стек: [2, 12] читаємо + -> pop(12,2), push(2+12=14) -> [14]
Результат: 14
Саме так побудований cli_calculator.py — три етапи:
"2 + 3 * 4"
↓ tokenize()
["2", "+", "3", "*", "4"]
↓ to_rpn() ← Shunting-Yard
["2", "3", "4", "*", "+"]
↓ eval_rpn()
14.0
Приклади нижче виконуються, якщо папка calculator_project/ (де лежить cli_calculator.py) додана в sys.path — наприклад, запуском з тієї ж теки, звідки лежить сам файл модуля:
from cli_calculator import tokenize, to_rpn, eval_rpn, evaluate, format_number
for expr in ["2 + 3 * 4", "(2 + 3) * 4", "-5 + 3.14", "2 ** 3 ** 2", "10 // 3 + 10 % 3", "2 @ 3"]:
ok, result = tokenize(expr)
print(f"{expr!r:<20} -> {result if ok else 'ПОМИЛКА: ' + result}")
'2 + 3 * 4' -> ['2', '+', '3', '*', '4']
'(2 + 3) * 4' -> ['(', '2', '+', '3', ')', '*', '4']
'-5 + 3.14' -> ['-', '5', '+', '3.14']
'2 ** 3 ** 2' -> ['2', '**', '3', '**', '2']
'10 // 3 + 10 % 3' -> ['10', '//', '3', '+', '10', '%', '3']
'2 @ 3' -> ПОМИЛКА: Невідомий символ: '@'
Токени переходять у RPN через to_rpn() — пріоритет і асоціативність визначають, коли оператор "виштовхується" зі стеку в результат:
for expr, note in [
("2 + 3 * 4", "пріоритет * вище +"),
("(2 + 3) * 4", "дужки змінюють пріоритет"),
("-5 + 3", "унарний мінус"),
("2 ** 3 ** 2", "** правоасоціативний"),
("1 + 2 + 3 + 4", "ліва асоціативність"),
]:
ok1, tokens = tokenize(expr)
ok2, rpn = to_rpn(tokens)
print(f"{expr!r:<18} {' '.join(rpn):<18} {note}")
'2 + 3 * 4' 2 3 4 * + пріоритет * вище +
'(2 + 3) * 4' 2 3 + 4 * дужки змінюють пріоритет
'-5 + 3' 5 u- 3 + унарний мінус
'2 ** 3 ** 2' 2 3 2 ** ** ** правоасоціативний
'1 + 2 + 3 + 4' 1 2 + 3 + 4 + ліва асоціативність
eval_rpn() обчислює RPN стеком:
for rpn_str, meaning in [
("2 3 4 * +", "2 + 3*4"),
("2 3 + 4 *", "(2+3)*4"),
("5 u- 3 +", "-5 + 3"),
("2 3 2 ** **", "2**(3**2)"),
]:
ok, result = eval_rpn(rpn_str.split())
print(f"{rpn_str!r:<15} {meaning:<12} = {result}")
'2 3 4 * +' 2 + 3*4 = 14.0
'2 3 + 4 *' (2+3)*4 = 20.0
'5 u- 3 +' -5 + 3 = -2.0
'2 3 2 ** **' 2**(3**2) = 512.0
І evaluate() збирає весь пайплайн в один виклик, коректно обробляючи й помилки (ділення на нуль, невідомий символ, незакриту дужку):
test_expressions = [
"2 + 2", "10 - 3 * 2", "(10 - 3) * 2", "2 ** 8", "2 ** 3 ** 2",
"-5 + 3", "3.14 * 2", "17 // 5", "17 % 5", "10 / 3",
"10 / 0", # ділення на нуль
"abc + 1", # некоректний символ
"(2 + 3", # незакрита дужка
]
for expr in test_expressions:
ok, result = evaluate(expr)
print(f"{expr:<15} = {format_number(result) if ok else 'ПОМИЛКА: ' + str(result)}")
2 + 2 = 4
10 - 3 * 2 = 4
(10 - 3) * 2 = 14
2 ** 8 = 256
2 ** 3 ** 2 = 512
-5 + 3 = -2
3.14 * 2 = 6.28
17 // 5 = 3
17 % 5 = 2
10 / 3 = 3.3333333333333335
10 / 0 = ПОМИЛКА: Ділення на нуль.
abc + 1 = ПОМИЛКА: Невідомий символ: 'a'
(2 + 3 = ПОМИЛКА: Помилка дужок: не закрито '('.
Результати ідентичні eval() — але безпечні¶
for expr in ["2 + 3 * 4", "(2 + 3) * 4", "2 ** 3 ** 2", "-5 + 10", "10 / 4", "10 // 4", "10 % 3"]:
eval_result = eval(expr)
ok, calc_result = evaluate(expr)
match = "✅" if ok and abs(eval_result - calc_result) < 1e-9 else "⚠️"
print(f"{expr!r:<15} eval()={eval_result!s:<8} cli_calculator={calc_result} {match}")
'2 + 3 * 4' eval()=14 cli_calculator=14.0 ✅
'(2 + 3) * 4' eval()=20 cli_calculator=20.0 ✅
'2 ** 3 ** 2' eval()=512 cli_calculator=512.0 ✅
'-5 + 10' eval()=5 cli_calculator=5.0 ✅
'10 / 4' eval()=2.5 cli_calculator=2.5 ✅
'10 // 4' eval()=2 cli_calculator=2.0 ✅
'10 % 3' eval()=1 cli_calculator=1.0 ✅
І, найважливіше — cli_calculator.evaluate() відхиляє будь-який шкідливий ввід замість того, щоб його виконати, бо його токенізатор розпізнає лише цифри, крапку й символи + - * / // % ** ( ) — усе інше одразу дає помилку "Невідомий символ":
attack_inputs = [
'__import__("os").system("whoami")',
'open("passwords.txt").read()',
'[x for x in ().__class__.__bases__]',
'"" * 10**9',
'print("hacked")',
]
for inp in attack_inputs:
ok, result = evaluate(inp)
print(f"{'⚠️ пройшов' if ok else '✅ заблоковано'}: {inp!r} -> {result}")
✅ заблоковано: '__import__("os").system("whoami")' -> Невідомий символ: '_'
✅ заблоковано: 'open("passwords.txt").read()' -> Невідомий символ: 'o'
✅ заблоковано: '[x for x in ().__class__.__bases__]' -> Невідомий символ: '['
✅ заблоковано: '"" * 10**9' -> Невідомий символ: '"'
✅ заблоковано: 'print("hacked")' -> Невідомий символ: 'p'
Коли eval() усе-таки прийнятний¶
| Контекст | Безпечно? |
|---|---|
eval("2 + 2") — хардкодований рядок у власному коді |
✅ Так |
eval(user_input) — будь-який зовнішній ввід |
❌ Ні |
| Особисті скрипти для власного одноразового використання | ⚠️ Обережно |
| Вебзастосунки, публічні API, будь-де, куди доходить чужий ввід | ❌ Ніколи |
Шпаргалка¶
# Синтаксис
eval(expression) # доступ до всіх змінних
eval(expression, globals_dict) # тільки вказані символи
eval(expression, globals_dict, locals_dict) # два рівні видимості
eval(expression, {'__builtins__': None}) # заборонити builtins (не гарантує повний захист!)
# Безпечні альтернативи
import ast
value = ast.literal_eval(user_input) # для Python-літералів; ValueError, якщо не літерал
import json
value = json.loads(user_input) # для JSON-даних із відомим форматом
OPS = {'+': lambda a, b: a + b, '-': lambda a, b: a - b} # для простого a op b
result = OPS[op](a, b)
from cli_calculator import evaluate # для складних арифметичних виразів
ok, result = evaluate(user_input) # безпечний парсер (tokenize -> to_rpn -> eval_rpn)
Задачі — самостійна робота¶
1. Безпечний eval із whitelist¶
Напишіть функцію safe_eval(expr, allowed_functions), яка виконує вираз через eval(), але дозволяє тільки функції зі словника allowed_functions, і забороняє __builtins__:
allowed = {'sqrt': math.sqrt, 'abs': abs}
safe_eval('sqrt(16)', allowed) # -> 4.0
safe_eval('__import__("os")', allowed) # -> має заблокувати виклик
2. Розширення safe_calculate унарним мінусом¶
Додайте до safe_calculate підтримку унарної операції (коли a is None):
3. Аналіз tokenize()¶
Розгляньте функцію tokenize() із cli_calculator.py і дайте відповідь:
- Що поверне
tokenize("1.2.3")? Поясніть чому. - Що поверне
tokenize("--5")? Поясніть чому. - Що поверне
tokenize("3 ** -2")? Поясніть чому.
Підказка — реальна поведінка (можна перевірити самостійно):