[Writeup] Huntress 2024 (Reverse Engineering): That’s Life

⚠️⚠️⚠️ This is a solution to the challenge. This post will be full of spoilers.
Download the binaries here: https://github.com/mlesterdampios/huntress-2024-binary-challenges

THIS IS ONE OF MY FAVORITE CHALLEGE FOR HUNTRESS 2024 AS THIS REMIND’S ME OF VERITASIUM’S “MATH’S FUNDAMENTAL FLAW”

Upon initial triage, the binary is built from protobuf and every tick is saved in file named game_state.pb. Upon observation, there are 12 X and O below the game screen. Sometimes they switch. Based from inference, we must at least meet all 12 to be O. A single O, means a condition was met, so we must investigate to get the conditions so we can probably win this game.

We start first by extracting the protobuf definitions from the binary using https://github.com/arkadiyt/protodump.

syntax = "proto3";

package thats_life;

option go_package = "github.com/HuskyHacks/thats_life/pb;pb";

message Cell {
    bool alive = 1;
    int32 color = 2;
}

message CellRow {
    repeated Cell cells = 1;
}

message Grid {
    int32 width = 1;
    int32 height = 2;
    repeated CellRow rows = 3;
}

We are able to get the protobuf definition. Now we try to parse the game_state.pb

// cmd/deserialize.go

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "strings"

    "example.com/m/v2/pb"
    "google.golang.org/protobuf/proto"
)

func main() {
    // Path to the serialized Grid file
    filePath := "game_state.pb"

    // Read the serialized Grid from the file
    data, err := ioutil.ReadFile(filePath)
    if err != nil {
        log.Fatalf("Failed to read file %s: %v", filePath, err)
    }

    // Create an empty Grid object
    var grid pb.Grid

    // Deserialize the data into the Grid object
    err = proto.Unmarshal(data, &grid)
    if err != nil {
        log.Fatalf("Failed to deserialize Grid: %v", err)
    }

    // Generate Go code representation
    goCode := formatGridAsGoCode("grid", &grid)

    // Print the generated Go code
    fmt.Println(goCode)
}

// formatGridAsGoCode formats the Grid object into a Go code snippet
func formatGridAsGoCode(varName string, grid *pb.Grid) string {
    var sb strings.Builder

    sb.WriteString(fmt.Sprintf("%s := &pb.Grid{\n", varName))
    sb.WriteString(fmt.Sprintf("    Width:  %d,\n", grid.Width))
    sb.WriteString(fmt.Sprintf("    Height: %d,\n", grid.Height))
    sb.WriteString("    Rows: []*pb.CellRow{\n")

    for _, row := range grid.Rows {
        sb.WriteString("        {\n")
        sb.WriteString("            Cells: []*pb.Cell{\n")
        for _, cell := range row.Cells {
            sb.WriteString(fmt.Sprintf("                {Alive: %t, Color: %d},\n", cell.Alive, cell.Color))
        }
        sb.WriteString("            },\n")
        sb.WriteString("        },\n")
    }

    sb.WriteString("    },\n")
    sb.WriteString("}\n")

    return sb.String()
}

We have successfully deserialized the pb file. Therefore we can create a solution too by forging our own data based on winning conditions and serialize it.

Upon further reverse engineering, there is a win criteria generation in the binary.

Upon investigating the qwordWinCriteria, it seems like it stores the data for the win condition.

Entry 0:
- Field 0 (Row):    0x0A00000000000000 -> 10
- Field 8 (Column): 0x0F00000000000000 -> 15
- Field 16 (Value): 0x1F00000000000000 -> 31

Entry 1:
- Field 0 (Row):    0x1400000000000000 -> 20
- Field 8 (Column): 0x1900000000000000 -> 25
- Field 16 (Value): 0x2000000000000000 -> 32

... and so on.

With these information, we are now ready to craft the solution.

// serialize.go
package main

import (
    "log"
    "os"

    "example.com/m/v2/pb"
    "google.golang.org/protobuf/proto"
)

func main() {
    // Define the win criteria
    winCriteria := []struct {
        Row    int32
        Column int32
        Color  int32
    }{
		{10, 15, 31},
		{20, 25, 32},
		{30, 35, 33},
		{40, 45, 34},
		{25, 50, 35},
		{5, 55, 36},
		{15, 60, 37},
		{35, 65, 31},
		{45, 70, 32},
		{0, 75, 33},
		{1, 80, 34},
		{2, 85, 35},
    }

    // Initialize the grid
    width := int32(400)
    height := int32(50)
    grid := &pb.Grid{
        Width:  width,
        Height: height,
        Rows:   make([]*pb.CellRow, height),
    }

    // Initialize all cells to dead and color 0
    for i := int32(0); i < height; i++ {
        row := &pb.CellRow{
            Cells: make([]*pb.Cell, width),
        }
        for j := int32(0); j < width; j++ {
            row.Cells[j] = &pb.Cell{
                Alive: false,
                Color: 0,
            }
        }
        grid.Rows[i] = row
    }

    // Apply the win criteria
    for _, wc := range winCriteria {
        if wc.Row >= 0 && wc.Row < height && wc.Column >= 0 && wc.Column < width {
            grid.Rows[wc.Row].Cells[wc.Column].Alive = true
            grid.Rows[wc.Row].Cells[wc.Column].Color = wc.Color
        } else {
            log.Fatalf("Win criteria position out of bounds: (%d, %d)", wc.Row, wc.Column)
        }
    }

    // Serialize the Grid to binary format
    data, err := proto.Marshal(grid)
    if err != nil {
        log.Fatalf("Failed to serialize Grid: %v", err)
    }

    // Write to a file
    file, err := os.Create("game_state.pb") // The game expects this filename
    if err != nil {
        log.Fatalf("Failed to create file: %v", err)
    }
    defer file.Close()

    _, err = file.Write(data)
    if err != nil {
        log.Fatalf("Failed to write data to file: %v", err)
    }

    log.Println("Grid serialized to game_state.pb successfully.")
}

Leave a Reply

Your email address will not be published. Required fields are marked *