Ogeek Easy Realworld Challenge 1&2 Writeup
这次 Ogeek 的 web 都挺有意思. 这两题偏向代码审计, 而且如题目名 Easy Realworld
, 都是审计应用本身的漏洞, 差不多就是找 0day 了, 在这里分享给大家.
Ogeek Easy Realworld Challenge 1
打开网页, 发现是个在线 ssh 连接器, 根据写的大大 Gateone
, 在 github 上找到 https://github.com/liftoff/GateOne
, 而且上次更新是 2017 年, 很可能存在未修复漏洞. 先 fuzz 了一波没有什么收获, 而且几乎是刚开箱的状态, 于是尝试代码审计.
根据 run_gateone.py
里面的
1from gateone.core.server import main
2
3main(installed=False)
找到 web 服务 gateone/core/server.py
,
在 3692 行可以找到 设置 Handler 的地方,
1handlers = [
2 (index_regex, MainHandler),
3 (r"%sws" % url_prefix,
4 ApplicationWebSocket, dict(apps=APPLICATIONS)),
5 (r"%sauth" % url_prefix, AuthHandler),
6 (r"%sdownloads/(.*)" % url_prefix, DownloadHandler),
7 (r"%sdocs/(.*)" % url_prefix, tornado.web.StaticFileHandler, {
8 "path": docs_path,
9 "default_filename": "index.html"
10 })
11 ]
可以发现 downloads/
用的不是 tornado 自带的 StaticFileHandler, 而是作者自己造的轮子, 可能存在漏洞.
在 924 行可以找到 get 方法的定义
1def get(self, path, include_body=True):
2 session_dir = self.settings['session_dir']
3 user = self.current_user
4 if user and 'session' in user:
5 session = user['session']
6 else:
7 logger.error(_("DownloadHandler: Could not determine use session"))
8 return # Something is wrong
9 filepath = os.path.join(session_dir, session, 'downloads', path)
10 abspath = os.path.abspath(filepath)
11 if not os.path.exists(abspath):
12 self.set_status(404)
13 self.write(self.get_error_html(404))
14 return
15 if not os.path.isfile(abspath):
16 raise tornado.web.HTTPError(403, "%s is not a file", path)
17 import stat, mimetypes
18 stat_result = os.stat(abspath)
19 modified = datetime.fromtimestamp(stat_result[stat.ST_MTIME])
20 self.set_header("Last-Modified", modified)
21 mime_type, encoding = mimetypes.guess_type(abspath)
22 if mime_type:
23 self.set_header("Content-Type", mime_type)
24 # Set the Cache-Control header to private since this file is not meant
25 # to be public.
26 self.set_header("Cache-Control", "private")
27 # Add some additional headers
28 self.set_header('Access-Control-Allow-Origin', '*')
29 # Check the If-Modified-Since, and don't send the result if the
30 # content has not been modified
31 ims_value = self.request.headers.get("If-Modified-Since")
32 if ims_value is not None:
33 import email.utils
34 date_tuple = email.utils.parsedate(ims_value)
35 if_since = datetime.fromtimestamp(time.mktime(date_tuple))
36 if if_since >= modified:
37 self.set_status(304)
38 return
39 # Finally, deliver the file
40 with io.open(abspath, "rb") as file:
41 data = file.read()
42 hasher = hashlib.sha1()
43 hasher.update(data)
44 self.set_header("Etag", '"%s"' % hasher.hexdigest())
45 if include_body:
46 self.write(data)
47 else:
48 assert self.request.method == "HEAD"
49 self.set_header("Content-Length", len(data))
注意关键部分,
1filepath = os.path.join(session_dir, session, 'downloads', path)
2abspath = os.path.abspath(filepath)
3if not os.path.exists(abspath):
4 self.set_status(404)
5 self.write(self.get_error_html(404))
6 return
7if not os.path.isfile(abspath):
8 raise tornado.web.HTTPError(403, "%s is not a file", path)
可以看到没有任何的过滤, 就把 path
拼进了 filepath
, 存在目录穿越, 可以任意文件读.
读 /etc/passwd
找到用户 ctf, 继续 fuzz 一些常用文件, 可以读到 /home/ctf/.bash_history
,
给了 ftp niconiconi
这应该就是题目描述里的内网机器了, 但是我们此时并没有连接 ftp 服务的能力.
继续审计源码, 可以发现 gateone/applications/terminal/plugins/ssh/scripts/ssh_connect.py
里的
1elif protocol == 'telnet':
2 if user:
3 print(_('Connecting to telnet://%s@%s:%s' % (user, host, port)))
4 # Set title
5 print("\x1b]0;telnet://%s@%s\007" % (user, host))
6 else:
7 print(_('Connecting to telnet://%s:%s' % (host, port)))
8 # Set title
9 print("\x1b]0;telnet://%s\007" % host)
10 telnet_connect(user, host, port)
可以发现其实支持 telnet
, 只是没有写出来…, 而 telnet
基本上跟 nc
差不多, 我们可以手敲 ftp 命令来获取 flag
随便试一下, 发现 ctf
, ctf
就是账号密码
这里注意 ftp 传输文件还需要开另一个链接, 可以选择客户端链接服务器 (PASV) 或者 服务器链接客户端 (PORT), 这里当然是客户端链接服务器.
服务器会返回一个 (ip1, ip2, ip3, ip4, p1, p2), p1 * 256 + p2 就是我们需要链接的端口, 然后用 RERT 命令就能读取文件了.
这里还有个小插曲, 这个应用还自带回放功能, 于是可以偷看别人的 flag…
所以我猜后面改题目, 把 flag 设成一半在本机 /flag
上, 一半在内网 ftp 的原因就是这个 2333
而且后面又改了一次, 还加了一题 Easy Realworld Challenge 2
, 可能就是某位大佬发现的 RCE 导致了非预期
Ogeek Easy Realworld Challenge 2
到我们目前的能力是任意文件读取 + SSRF, 根据题目描述, 应该是得拿到 shell 才能读 flag 了. 于是只能继续审计源码 233
可以看到在 ssh 链接时, 是直接拼的命令, 然后放到一个文件里面调用 execvpe 执行 /bin/sh 来跑这个命令, 但是过滤还是很严格的, 没办法注入命令,
1def bad_chars(chars):
2 import re
3 bad_chars = re.compile('.*[\$\n\!\;&` |<>].*')
4 if bad_chars.match(chars):
5 return True
6 return False
7
8#...
9
10while not validated:
11 if not url:
12 url = raw_input(_(
13 "[Press Shift-F1 for help]\n\nHost/IP or ssh:// URL%s: " %
14 default_host_str))
15 if bad_chars(url):
16 raw_input(invalid_hostname_err)
17 url = None
18 continue
19 if not url:
20 if options.default_host:
21 host = options.default_host
22 protocol = 'ssh'
23 validated = True
24 else:
25 raw_input(invalid_hostname_err)
26 continue
但是这个不仅仅有个 ssh 链接的功能, 还能生成 ssh 秘钥,
我们仔细来看这个 gateone/applications/terminal/plugins/ssh/ssh.py
第 615 行 generate_new_keypair
,
1if which('ssh-keygen'): # Prefer OpenSSH
2 openssh_generate_new_keypair(
3 self,
4 name, # Name to use when generating the keypair
5 users_ssh_dir, # Path to save it
6 keytype=keytype,
7 passphrase=passphrase,
8 bits=bits,
9 comment=comment
10 )
11elif which('dropbearkey'):
12 dropbear_generate_new_keypair(self,
13 name, # Name to use when generating the keypair
14 users_ssh_dir, # Path to save it
15 keytype=keytype,
16 passphrase=passphrase,
17 bits=bits,
18 comment=comment)
会判断用的是 openssh 或者 dropbear 来调用对应的秘钥生成命令, 这里肯定是 openssh, 来到 699 行 openssh_generate_new_keypair
, 可以看到
1def openssh_generate_new_keypair(self, name, path,
2 keytype=None, passphrase="", bits=None, comment=""):
3 self.ssh_log.debug('openssh_generate_new_keypair()')
4 openssh_version = shell_command('ssh -V')[1]
5 ssh_major_version = int(
6 openssh_version.split()[0].split('_')[1].split('.')[0])
7 key_path = os.path.join(path, name)
8 # ...
9 ssh_keygen_path = which('ssh-keygen')
10 command = (
11 "%s " # Path to ssh-keygen
12 "-b %s " # bits
13 "-t %s " # keytype
14 "-C '%s' " # comment
15 "-f '%s'" # Key path
16 % (ssh_keygen_path, bits, keytype, comment, key_path)
17 )
18 self.ssh_log.debug("Keygen command: %s" % command)
19 m = self.new_multiplex(command, "gen_ssh_keypair")
同样是拼的命令, 不同的是没有了过滤. 因为 name 可控, 最后导致 keypath 可控, 我们只需要注入一个 ';some cmd;'
就能注入我们自己的命令了.
这里可以用 Burpsuite 来改 websocket 的内容
然后结合之前的任意文件读取来读命令执行的结果
然后就可以发现 flag 啦, 可以直接读. 另外, 除了这个地方还有其他地方也有同样的问题, 这里不再一一举出.