Ogeek Easy Realworld Challenge 1&2 Writeup

发表于 2019 年 8 月 28 日

这次 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 啦, 可以直接读. 另外, 除了这个地方还有其他地方也有同样的问题, 这里不再一一举出.