Tegyük fel, hogy egy olyan alkalmazást szeretnénk írni, amelyben a felhasználó által grafikus felületen vagy más, szövegbevitelre alkalmas eszközön megadott egyváltozós matematikai kifejezést (képletet) kell kiértékelni a független változó adott értékénél, majd pedig az eredményt feldolgozni. Ahhoz, hogy fel tudja a programunk ismerni a független változót, ezért annak jelölését előre rögzítjük: legyen a matematikában szokásos x betű. A képlet tartalmazhat olyan matematikai függvényeket, amelyek vagy beépített függvények (pl. abs(), pow(), round() ), vagy a szabványos könyvtár math moduljában szerepelnek (pl. sqrt(), sin(), cos(), log(), exp(), stb.).
A kifejezés kiértékelésére kézenfekvő, hogy az eval() beépített függvényt használjuk. De mivel lehet téves adatbevitel, ami nem értelmezhető a kiértékeléskor, és előfordulhat nullával való osztás is, ezért célszerű a kiértékelés egy egyéni függvényben elvégezni, ahol a kivételeket is kezelni tudjuk. Erre mutatunk példát alább. Itt azt is megfigyelhetjük, hogy hiba esetén dobandó kivételekhez definiáltunk egy egyéni kivételosztályt, amellyel egységesen tudjuk a kivételkezelést végrehajtani, és amelynek nevéből rögtön következtetni lehet, hogy a programunk mely részéről származik a hiba.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
from math import * class FormulaError(Exception): def __init__(self, message='', details=''): self.args = (f'Érvénytelen karaktersor a képletben' + (f': "{message}".' if message else '.'), details) def __str__(self): return ' '.join(self.args) def eval_str(formula_string: str, x) -> int | float | None: """A karakterláncként megadott egyváltozós matematikai képletet értékeli ki az x helyen. A képletben a független változót 'x' kell, hogy jelölje. """ try: y = eval(formula_string) if isinstance(y, (int, float)): return y except ZeroDivisionError: # Nullával való osztás esetén nem engedünk kivételdobást, mert például ha függvényábrázolási céllal hívjuk e # függvényt, akkor egy adott pont kieshet, ezért ne szakítsuk meg a folyamatot. return None except NameError as ne: other_message = '' if len(ne.name) == 1: other_message = 'Ha ez lenne a független változó, akkor "x" kell helyette.' raise FormulaError(ne.name, other_message) |
A tesztelést a következő programsorokkal végezzük, ahol különböző karakterláncokat adunk át az eval_str() függvénynek, és nézzük, hogy milyen eredményt kapunk.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# TESZT if __name__ == '__main__': test_formulas_with_args = [('abs(round(pow(3, 0.5), 2)-2)+int(1.12)', 3), # Konstans érték. ('abs(round(pow(x, 0.5), 2)-2)+int(1.12)', 3), # Beépített matematikai függvények. ('abs(round(pow(z, 0.5), 2)-2)+int(1.12)', 3), # Helytelen független változó név. ('1/x', 0), # Nullával való osztás. ('sin(x)/x if not isclose(x,0) else 1', 1.), # math modul függvényei. ('sin(x)/x if not isclose(x,0) else 1', 0), # feltételes kifejezés működése. ("x*1 and getattr(1, '__ceil__')()", 1), # Potenciálisan veszélyes beépített függvény. ("__import__('os').remove('D:/my_config.txt') and x*0", 1), # Támadás. ] formatted_result = '{:60}|{:5}| {}'.format print(f'{"Képlet":^60}|{"x":^5}|{" ":10}{"Eredmény":30}') print('=' * 120) for formula, arg in test_formulas_with_args: try: print(formatted_result(formula, arg, eval_str(formula, arg))) except Exception as ex: print(formatted_result(formula, arg, str(ex))) |
|
1 2 3 4 5 6 7 8 9 10 11 |
Képlet | x | Eredmény ======================================================================================================================== abs(round(pow(3, 0.5), 2)-2)+int(1.12) | 3| 1.27 abs(round(pow(x, 0.5), 2)-2)+int(1.12) | 3| 1.27 abs(round(pow(z, 0.5), 2)-2)+int(1.12) | 3| Érvénytelen karaktersor a képletben: "z". Ha ez lenne a független változó, akkor "x" kell helyette. 1/x | 0| None sin(x)/x if not isclose(x,0) else 1 | 1.0| 0.8414709848078965 sin(x)/x if not isclose(x,0) else 1 | 0| 1 x*1 and getattr(1, '__ceil__')() | 1| 1 __import__('os').remove('D:/my_config.txt') and x*0 | 1| [WinError 2] The system cannot find the file specified: 'D:/my_config.txt' |
Megállapíthatjuk, hogy egészen az utolsó bemeneti adatig a tervezettnek megfelelően működik a függvény. Az utolsó sor egy rosszindulatú támadást szimulál, ahol e karaktersor kiértékelését is kikényszerítjük az and logikai operátor alkalmazásával. Ennek bal oldali operandusa azonban nem egy matematikai kifejezés, hanem egy függvényhívás. Ez sajnos le is futott, ami egy adott fájl törlését eredményezte volna, ha a fájl létezik.
Az utolsó sorból ránézésre is nyilvánvaló, hogy egy nem kívantos karaktersorozat lett megadva. De az előtte levő sor sem veszélytelen. A beépített getattr() és setattr() is használható lenne rosszindulatú kód futtatására. Ezért az sem lenne jó, ha ezek kiértékelésre kerülnének. De valójában a beépített függvények többsége sem illik egy egyvátozós matematikai képletbe, legfeljebb csak a kifejezetten matematikai függvények, mint a fentebb említett abs(), pow(), round(), de az int() is ennek tekinthető.
Az utolsó sor káros kódjának kiértékelését két tényező tette lehetővé. Az egyik az, hogy megengedtük a képletekbe szintén nem illő dunder attribútumneveket (azon neveket, amelyek két aláhúzással kezdődnek és végződnek). Másrészt megengedtük az attribútumelérést, vagyis a pont (‘.’) operátor használatát.
Hogy a potenciális károkozást elkerüljük, el kell érni egyrészt azt, hogy a pont operátor ne működjön, másrészt, hogy a dunder attribútumneveket az eval() függvény ne tudja értelmezni.
A pont jel egyszerű kiszűrése a bemeneti karaktersorozatból nem járható út, mert a pont egyben tizedes elválasztó jel is. Matematikai kifejezésekben gyakran szerepelnek. Ezért csak azt tudjuk megmondani, ha biztosan kizárható, hogy a pont jel Pythonban szabályos tizedestört része. Például, ha előtte zárójel van, akkor az biztos, hogy nem tizedestört. Azt, hogy a pont milyen más megelőző karakterrel együtt lehet egy tizedestört része, alaposan át kell gondolni. Ha ez megvan, akkor minden olyan esetben, ahol a pontot nem egy megengedett karakter előzi meg, vissza kell utasítani a bemenő sorozatot. A kiszűrést végző függvény egy lehetséges megvalósítása látható alább. A részletes kommentek segítik a működés megértését.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
def find_invalid_dot_sub(s: str) -> str: """Ha a függvény olyan pont karaktert ('.') talál az argumentumban, amely előtt álló karakter alapján biztosan kizárható, hogy a pont Pythonban szabályos tizedestört része legyen, akkor a pontot és az előtte álló karaktert adja vissza. A függvény feltételezi, hogy az argumentum egyetlen kifejezést vagy képletet tartalmaz. Ezért bizonyos karakterek, pl. az utasításelválasztó (';'), az ellipszis ('..') vagy a mátrixszorzás ('@') karakterei nem relevánsak, és ezért nem szerepelnek a vizsgálatban. Ha a visszatérési érték üres karakterlánc, az azt jelenti, hogy nem sikerült biztosan kizárni, hogy a pont a kifejezésben szabályos tizedestört része. """ # Azon karakterek készlete, amelyek, ha megjelennek a pontjel előtt, akkor nem zárható ki, hogy a pont # a kifejezésben szabályos tizedestört része. allowed_chars_before_dot = set( '0123456789' # decimális számjegyek '+-' # előjelek ' \t\n' # szóközök '*/%' # aritmetikai operátorok '<>!=' # összehasonlító operátorok része '&|^~' # bitműveleti operátorok '([{:,' # nyitó zárójelek, és elválasztók ) while True: string_before, dot, string_after = s.partition('.') if not dot: # Nincs pont karakter a függvényargumentumban. return '' if string_before: char_before_dot = string_before[-1] if char_before_dot not in allowed_chars_before_dot: return char_before_dot + '.' if not string_after: # Érvénytelen karaktereket nem talátunk, és nincs további vizsgálni való. return '' s = string_after # Tovább folytatjuk a vizsgálatot. |
A második szűrési feladat, hogy a dunder attribútumokat beazonosítsuk a karakterláncban, ha vannak. Ennek megvalósítását mutatja a következő függvénydefiníció:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
def find_dunder(s: str) -> str: """Megkeresi és visszaadja az első szabályos dunder attribútum nevet. Csak akkor ad vissza üres stringet, ha nincs egyetlen szabályos sem. """ parts = s.split('__') # pl.: "x.__class__.y" -> ["x.", "class", ".y"] # pl.: "___bad__ x.__class__" -> ['', '_bad', ' x.', 'class', ''] for i in range(len(parts) - 2): left, mid, right = parts[i], parts[i + 1], parts[i + 2] # Belső részre követelmény: érvényes identifier, nem kezdődik/zárul '_'-lal. if not (mid and mid.isidentifier() and not mid.startswith('_') and not mid.endswith('_')): continue # A bal oldal nem végződhet '_' karakterre. if left.endswith('_'): continue # A jobb oldal nem kezdődhet '_' karakterrel. if right.startswith('_'): continue return f'__{mid}__' return '' |
Van tehát két függvényünk, amelyekkel még a kiértékelés előtt kiszűrhetjük azokat a felhasználó által bevitt karaktersorozatokat, amelyek nemkívánatos vagy károkozásra alkalmas utasításokként értelmezhetők. De ez még nem elég. Az eval() függvénynek meg kell adni, hogy a beépített függvények közül melyeket szabad csak értelmezni. Továbbá tudatni kell vele, hogy mely karaktert tekintse független változónak, és melyek a math modul használható függvénynevei.
Az eval() függvény úgy képes ezekről tudomást szerezni, ha az opcionális globals, és locals paramétereinek átadjuk a megfelelő névtereket reprezentáló szótárakat, amelyekben már csak az általunk engedélyezett nevek szerepelnek.
Az ‘x’ karakterhez mint névhez az eval_str() függvény x paraméterének értéke fog társulni. Ez lokális változó, tehát az eval() locals argumentuma lesz. Ha a math modult a függvényen belül importáljuk, akkor a matematikai függvénynevek is lokális nevek lesznek. Tehát ha ezeket egy szótárba gyűjtjük, akkor az x nevet tartalmazó szótárral egyesítve adhatjuk át az eval() locals paraméterének. Azért célszerű így tenni, mert ekkor az eval() globális neveinek meghatározása leegyszerűsítődik: a globals paraméternek csak egy olyan szótárt kell átadi, amelyben egyetlen kulcs van, a ‘__builtins__’, amelyhez csak a beépített matematikai függvények név-objektum párjai társulnak.
Az eval_str() függvény ezen elvek alapján módosított változata alább látható. A részletes kommentek itt is segítik a megértést.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
class FormulaError(Exception): def __init__(self, message='', details=''): self.args = (f'Érvénytelen karaktersor a képletben' + (f': "{message}".' if message else '.'), details) def __str__(self): return ' '.join(self.args) def eval_str(formula_string: str, x) -> int | float | None: """A karakterláncként megadott egyváltozós matematikai képletet értékeli ki az x helyen. A képletben a független változót 'x' kell, hogy jelölje. """ # I. Kiszűrjük azon karaktersorozatokat, amelyek nemkívánatos vagy károkozásra alkalmas utasításokként értelmezhetők. # A pont ('.') operátorral végezhető attribútumhozzáférést mindenképpen ki kell zárni, mert ha ezt # hagynánk, akkor ezen keresztül közvetlenül vagy az öröklési láncban navigálva veszélyes műveleteket # lehetővé tevő objektumokat lehetne elérni. # Ilyen veszélyes, káros tevékenységet előidéző kifejezésre példa: # __import__('os').remove('D:/my_config.txt') if dot_sub := find_invalid_dot_sub(formula_string): raise FormulaError(dot_sub) # A dunder metódusok nem szerepelhetnek egy normál matematikai képletben, ezért kiszűrjük azokat, mert # egyes veszélyes műveletek végzésére teremthetnek alkalmat (pl. __import__, __builtins__, __class__, __mro__). if dunder_name := find_dunder(formula_string): raise FormulaError(dunder_name) # II. Meghatározzuk az eval() által alkalmazható névtereket. # A szükséges modulokat nem a függvényen kívül, hanem itt importáljuk be, hogy a globális névtér szűrésével # ne kelljen foglalkozni. import builtins import math # A globális névtérben lekorlátozzuk a beépített függvények körét az egyváltozós matematikai függvényekre. allowed_builtins = {'__builtins__': {k: v for k, v in vars(builtins).items() if k in {'abs', 'pow', 'round', 'int'}}} # Használhatóvá tesszük a math modul matematikai függvényeit a lokális névtérben. allowed_math = {k: getattr(math, k) for k in vars(math)} try: y = eval(formula_string, globals=allowed_builtins, locals={'x': x} | allowed_math) if isinstance(y, (int, float)): return y except ZeroDivisionError: # Nem hagyunk kivételdobást, mert például függvényábrázolási céllal hívjuk e függvényt, akkor # egy adott pont kieshet, ezért ne szakítsuk meg a folyamatot. pass except NameError as ne: other_message = '' if len(ne.name) == 1: other_message = 'Ha ez lenne a független változó, akkor "x" kell helyette.' raise FormulaError(ne.name, other_message) |
/Aki a reguláris kifejezésekben (regex) járatos, az a szűrőfeltételeket a bemutatott függvények helyett azzal is meghatározhatja, és megvalósíthatja a szabványos könyvtár re moduljának használatával./
Ha a következő teszteredményeket megnézzük, akkor megállapíthatjuk, hogy a függvénynek nem kívánatos művelet kiváltására alkalmas értelmezhető kód nem adható.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# TESZT if __name__ == '__main__': test_formulas_with_args = [('abs(round(pow(3, 0.5), 2)-2)+int(1.12)', 3), # Konstans érték. ('abs(round(pow(x, 0.5), 2)-2)+int(1.12)', 3), # Beépített matematikai függvények. ('abs(round(pow(z, 0.5), 2)-2)+int(1.12)', 3), # Helytelen független változó név. ('1/x', 0), # Nullával való osztás. ('sin(x)/x if not isclose(x,0) else 1', 1.), # math modul függvényei. ('sin(x)/x if not isclose(x,0) else 1', 0), # feltételes kifejezés működése. ("x*1 and getattr(1, '__ceil__')()", 1), # Potenciálisan veszélyes beépített függvény. ("__import__('os').remove('D:/my_config.txt') and x*0", 1), # Támadás. ("__import__('os') .remove('D:/my_config .txt') and x*0", 1), # Támadás. ] formatted_result = '{:60}|{:5}| {}'.format print(f'{"Képlet":^60}|{"x":^5}|{" ":10}{"Eredmény":30}') print('=' * 120) for formula, arg in test_formulas_with_args: try: print(formatted_result(formula, arg, eval_str(formula, arg))) except Exception as ex: print(formatted_result(formula, arg, str(ex))) |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
Képlet | x | Eredmény ======================================================================================================================== abs(round(pow(3, 0.5), 2)-2)+int(1.12) | 3| 1.27 abs(round(pow(x, 0.5), 2)-2)+int(1.12) | 3| 1.27 abs(round(pow(z, 0.5), 2)-2)+int(1.12) | 3| Érvénytelen karaktersor a képletben: "z". Ha ez lenne a független változó, akkor "x" kell helyette. 1/x | 0| None sin(x)/x if not isclose(x,0) else 1 | 1.0| 0.8414709848078965 sin(x)/x if not isclose(x,0) else 1 | 0| 1 x*1 and getattr(1, '__ceil__')() | 1| Érvénytelen karaktersor a képletben: "__ceil__". __import__('os').remove('D:/my_config.txt') and x*0 | 1| Érvénytelen karaktersor a képletben: ").". __import__('os') .remove('D:/my_config .txt') and x*0 | 1| Érvénytelen karaktersor a képletben: "__import__". |
Fontos tudni, hogy a rosszindulatú kódok eval() függvény által történő értelmezésének kivédése a bemutatott módon, azaz előszűrések és névtérszűkítéssel ebben a speciális, nagyon körülhatárolt esetben (csak egyváltozós matematikai kifejezés az elfogadható) elérhető volt. Általánosan azonban ilyen módon nem lehet garantált védelmet biztosítani, hanem a legbiztosabb módszer az adott feladathoz illeszkedő egyéni értelmező készítése és alkalmazása. Ezért kell az eval() függvényt – és az exec() függvényt is – kellő óvatossággal, átgondoltan használni.
Ebben a bejegyzésben az eval() mellett szó volt általában a beépített függvényekról és a speciális metódus- és adatattribútumokról, amelyeket a szakzsargon dunder attribútumoknak is nevez a double underscore szavak összevonásából képezve. A kivételek és kivételkezelés is jelentős szerepet kapott, és láthattunk egyéni kivételosztály definiálására és használatára példát. Ezen kívül a névterek ismerete is szükséges volt a megfelelő implementációhoz. Mindezekről a Python tudásépítés lépésről lépésre című e-könyv „Beépített függvények”, „Kivételes bánásmód – kivételek és kezelésük”, „Egyéni kivételtípusok megvalósítása”, „Hatókörök és névterek”, valamint a „Speciális metódus- és adatattribútumok” fejezetekben lehet részletesen olvasni.