2023 强网杯三道 pyjail 的题解
上周的强网杯 2023 没准备参加,一个是去年帮忙打 ctf 打得实在是有点累了;第二个是上周末有其他比赛冲突了,所以也没时间看题。偶然在公众号推送上看到了强网杯的 wp 提到了有几道 python 题,还是忍不住来玩一玩。
不确定有没有遗漏,好像一共是三道 python 题目,并且都是 pyjail 类型的。自从上次我写了那篇《Python 沙箱逃逸的通解探索之路》之后,感觉很多同类题目都可以秒了,似乎一直都没有更极限的题目出现了,这次不妨一起来看看这次强网杯会不会让人眼前一亮呢。
Pyjail: It's myFILTER!!!
题目代码如下:
1 |
|
这道题常规的解法是比较简单的,一眼扫过去,基础 exp 的 open
不在过滤列表里,那当然是先尝试读一下当前目录、根目录、环境变量之类的看看有没有 flag 再说。至于回显嘛,由于我没实际在官方环境中测试,这个代码看起来像是可以直接 nc 过去做的,如果是这样的话,那利用报错或者是直接 print 结果其实都可以的。看了下网上的 wp,证明 flag 的确是在环境变量里,所以 exp 就是:{print(open("/proc/self/environ").read())}
。如果是在比赛的话,到这里就结束了,赶紧下一道吧。
但作为黑客,实现 RCE 是永远的诱惑。这道题莫非只能读文件么?
分析
我们先来分析一下这里的限制条件:
(做的过程中发现出题人在写 blacklist_words
的时候,"getattr" "__import__",
之间漏了一个 ,
,这直接导致 blacklist_words 中没有 getattr
和 __import__
,不知道是不是故意的,不过这里我们就当这里有逗号好了。)
内置模块的方法劫持
首先,由于 os.system
等内置模块的方法被劫持到 blacklist_fun_callback
了,所以即使我们 exp 中可以 import os
,拿到的 os.system
也依旧是 blacklist_fun_callback
,原因在于橘友们小学 5 年级就知道的 Python 模块导入的缓存机制。为了确保模块单例以及支持模块重用机制,在执行 import 的时候,如果模块是第一次导入,python 会在导入模块的同时把模块名称保存在 sys.modules
这个字典里;如果在导入模块的时候发现它已经在这个字典里了,就会直接返回 sys.modules
中模块对应的值。在这个缓存机制的影响下,题目中修改了众多内置模块的方法,比如 subprocess.Popen
,那就意味着后续所有代码中间接使用到的 subprocess.Popen
也会被劫持。例如,橘友们小学 5 年级就知道 help()
可以用来做 python 沙箱逃逸,原因是因为背后执行了 more
,在 more 里可以用 !
来执行任意命令,比如 !id
。但可能少为人知的是,help()
背后是 pydoc
,在 pydoc.py
中使用了 subprocess.Popen
或者 os.system
(win 平台)
如果要解决这个问题,最简单的方式就是删除 sys.modules
中的 subprocess
,然后重新 import 一次。
内置函数劫持
vars
等内置方法也被劫持到 blacklist_fun_callback
了。但这里与上面不同,这个修改并不会影响其他模块的 vars。因为 python 查找变量的顺序是 LEGB 法则,因此 vars 变量的顺序是先从本地命名空间开始,然后是包含它的模块的命名空间,最后是内置命名空间。由于其他模块中没有局部或模块级别的 vars 定义,所以它们内部会使用 __builtins__
中的原始 vars 函数。如果我们想对内置函数做与上面相同的劫持,应该使用 __builtins__.vars = blacklist_fun_callback
。
如果想在当前上下文中恢复这些内置函数,只需要清空 locals()
或者 globals()
即可(这里它们是一个东西,因为我们的 exp 是在模块层级上执行的,因此 locals()
和 globals()
是同一个字典),这样一来,python 按照 LEGB 法则就会找到 B 的 vars。
其他
这些相对比较常规:
- exp 中不能出现
blacklist_words
的所有关键字 eval
不能出现在 exp 里- exp 所有字符必须全部为 ascii 码
- exp 长度最长为 64
思路 1:常规沙箱逃逸
我们注意到代码中 eval(f"f'{input_code}'")
使用了两层 f-string,不但本身可以直接执行任意代码,也可以通过单引号来进行代码注入。这就意味着直接通过 {eval("1+1")}
来执行任意代码,但由于 blacklist_words 的限制,所以通常会想到用 Unicode 变量名,但是 while 里做了限制,此路不通;还有就是搞一个字符串出来做分隔,例如 f'{ev''al("1+1")}'
,但这也有新的问题,我们为了生成 eval,又加入了 f-string,而 f-string 中如果用到 {}
,则字符串必须是连续的,例如 f'{1*' + f'1}'
是会报错的。
加上其他条件的严格限制(尤其是长度和对方法进行劫持),常规沙箱逃逸的 payload 均宣告出局。同时,我也用之前写的自动化挖掘工具跑了一下,发现的确找不到:
到这里就应该换个思路了。
思路 2:覆盖模块
虽然我们没有办法直接 import,但是通过执行内置的一些函数可以实现间接执行 import。上面提到,help()
由于 subprocess.Popen
被劫持导致无法正常执行,其实这里我们也可以用这个思路,经过代码分析,在 python 的 /lib/python3.9/_sitebuiltins.py
中发现有 import pydoc
:
而 open 又不受限制,这就意味我们只需要在执行目录下创建一个 pydoc.py
,往里面写要执行的代码即可实现任意代码执行,也就意味着实现了 RCE:
1 |
|
pydoc.py
写入完毕之后,再次运行题目代码,只需要输入 {help()}
即可执行设定好的代码:
至此,我们实现了 RCE。至于 pydoc.py
内容怎么写,玩法就很多了,这里不展开了。
思路 3:利用循环+覆盖函数
如果 open
也无法使用呢?
由于题目中使用了 while
,因此 eval 生成的值又会被赋给 input_code 重新参与 eval,那如果我们在第一轮循环中只要操作得当,就可以用一行输入来影响第二轮循环中 while 的判断,同时把最终 exp 传递给第二轮循环的 eval。
"{" in input_code and "}" in input_code
input_code.isascii()
my_filter(input_code)
"eval" not in input_code
len(input_code) < 650
我们先来分析一下:
- 条件 1、2、4 都是无能为力的,因为 input_code 作为内建类型,魔术方法(
.__contains__
)由于是由 Python 解释器在底层实现的,因此是不允许修改的。 - 条件 3、5 我们可以动手脚,
my_filter
、len
都可以覆盖,需要一个参数,并且返回值必须为 True
所以,在第一轮的 exp 里我们需要把 globals()
清空,然后再把 my_filter
加上,最后利用题目中的 eval 来返回第二轮的 exp。那么问题来了,第二轮的 exp 应该是什么呢?
在第二轮的时候,经过第一轮的 eval,就只需要满足条件 1、2、4 即可,并且由于我们清空了 globals()
,导致内置函数都恢复了,可谓是一箭双雕。这样我们就可以用 exec(input())
来执行任意代码了。
由于第一轮 eval 必须返回字符串(主要是条件 2 的限制),所以我们可以用一个列表之类的东西来同时执行代码和返回需要的 exp(这个技巧其实之前也介绍过了):
1 |
|
蛮吊蛮吊,但是长度太长了,81 个字符,距离 64 个字符还有点距离,因此我们尝试来缩短长度。
- 首先
globals()
与locals()
在这里是等价的,但后者少一个字符,换! - 其次
"{ex""ec(in""put())}"
可以换成{"{break""point()}"}
- 只要返回字符串,不一定就得用列表或者元组,用条件表达式也可以,比如
or
于是可以得到一个长度为 74、73 的 exp:
1
2
3
4
5
6
7
8
9
10
11
12
13'''
{
locals().clear() or
locals().update({"my_filter": id}) or
"{break""point()}"
}
'''
# len == 74
{locals().clear()or locals().update({"my_filter":id})or"{break""point()}"}
# len == 73
{",break""point()#{}",locals().clear(),locals().update({"my_filter":id})}
感觉这个长度已经是极限了。然后我突然意识到,出题人为了避免自身代码受到影响,并没有劫持所有高危的内置函数,比如 eval
,所以我又搞了个符合长度要求但是不满足条件的 exp:{["{ev""al(print(1))}",locals().update({"my_filter":id})][0]}
,因为第二轮 exp 中会出现 eval
,而出题人在 while
里特别关照了 eval
。那么还有哪些内置函数,出题人没有为了保障题目自身不出问题而没有劫持呢?答案就是 input()
。
在第二轮里我们可以通过 input 来引入额外的输入,输入的时候再引入 {}
,从而通过内层的 f-string 以及配合外层的 eval 进行代码执行。这样第二轮执行的原型为:
继续倒推回去第一轮,由于 input 没有被劫持,所以连 locals().clear()
也可以省略,因此 exp 长度就可以大幅缩减:
1 |
|
长度仅为 50!蛮吊蛮吊。
至此,我们不用写文件,也可以实现任意命令执行。
Pyjail: It's myRevenge !!!
肉眼扫了下,好像和原来的没啥大区别。经过 diff 发现,作者修复了缺少逗号的问题:
但别的都没变。所以我猜测出题人这里可能对 flag 的位置做了调整,从环境变量中移动到其他未知文件名的文件去了。那这里还是要 rce 嘛,上面已经实现了,哈哈哈,所以这里就不再看了。
Pyjail: It's myAST !!!!
题目代码:
1 |
|
老规矩先分析下限制:
"__"
不能出现在代码里- 通过 ast 检查代码的抽象语法树,黑名单位于
BAD_ATS
- 通过指定
exec
的第二个参数,将代码执行的globals()
重置为只有个__builtins__
,并将具体的内置方法指定为BUILTINS
的值
第一个条件有绕过的可能性,用 Unicode 字符 __
。第二个条件是无法正面绕过的,因为 ast 本身就是 python 执行过程中的中间产物,只能说看看有没有出题人没覆盖到的 ast 节点然后再利用;第三个条件,如果可以访问到非当前模块的命名空间,就可以拿到正常的内置方法了,但是必须的获取属性,或者用 Subscript 之类做替代,但这都被禁用了。。。
所以常规的沙箱逃逸 exp 可以说是都被堵死了。
翻阅了下官方文档:https://docs.python.org/zh-cn/3/library/ast.html
看起来除了 match 相关的语法之外,没有其他能用的东西了。但是这个是在 py3.10 中引入的,我不确定大家做题的时候出题人是否有提示所用的 python 版本,我这没有环境尝试所以也判断不了,这里权当出题人是这么出的吧。
根据 pep 里的示例 https://peps.python.org/pep-0636/#abstract
可以得知两个关键的知识点:
- 可以通过指定关键字参数来获取 match 的属性
- 匹配的逻辑是判断 match 是否为 case 的子类
通过知识点 1,就可以获取参数了!比如 object.__subclasses__
就可以通过
1 |
|
来获取。以此类推,那这题就变成了常规的沙箱逃逸了。由于出题人限制了代码长度,因此我们可以用 dibber 来找一个比较短的继承链,比如:
那么对于 exp object.__subclasses__()[122].append.__globals__['__builtins__']
就可以变形为:
1 |
|
进而实现命令执行:
完结撒花。这三道题的整体难度不算很高。
这应该是今年最后一篇文章了时间过得真快!提前祝橘友们元旦快乐了嗷