Posted on :: Source Code ::

The ChCon Badge 2025 is an interactive conference badge that pushes the boundaries of what's possible on a microcontroller. Built for Hacker Conference 2025, this ESP32-C3 powered device runs a fully functional Minecraft 1.21.4 server, features 24 programmable RGB LEDs with 15 animation modes, displays 3D graphics on an OLED screen, and includes multiple CTF challenges—all without an operating system.

What Makes This Badge Special?

At its core, this badge combines gaming, visual effects, and security challenges into a single embedded device:

  • Minecraft Server: A complete Minecraft 1.21.4 (Java Edition) server running on 160MHz RISC-V hardware
  • Interactive LEDs: 24 WS2812 RGB LEDs with 15 pre-programmed animations
  • Custom Programming: A stack-based virtual machine for user-programmable LED patterns
  • 3D Graphics: 128x64 OLED display showing animated eyes and a rotating Minecraft grass block
  • WiFi Networking: Access Point mode with DHCP server, TCP server, and WebSocket implementation
  • Security Challenges: Multiple obfuscated passwords and CTF puzzles
  • Persistent Storage: Flash-based storage for custom patterns and settings

The Hardware Platform

The badge is built around the ESP32-C3, a RISC-V 32-bit microcontroller running at 160MHz with 4MB flash and built-in WiFi. The hardware includes:

  • 24 WS2812 addressable RGB LEDs
  • SSD1306 128x64 OLED display (I2C)
  • Boot button for brightness control
  • USB Serial JTAG for interactive shell access

All of this runs on bare-metal Rust (#![no_std]) with a 72KB heap allocation—no operating system required.

Bare-Metal Async Architecture

One of the most impressive technical achievements is the async architecture using the Embassy runtime. Five concurrent tasks run simultaneously without an RTOS:

// Five tasks running cooperatively
- LED animation task (60 FPS)
- OLED rendering task
- Heartbeat LED blinker
- WiFi management
- Minecraft TCP server

The main task handles the interactive shell and password prompt. This cooperative multitasking approach using Rust's async/await provides excellent performance on resource-constrained hardware.

The Minecraft Server Implementation

The Minecraft server is perhaps the most technically fascinating component. It implements the full Minecraft 1.21.4 protocol, including handshake, login, configuration, and play states. But here's the clever part: instead of generating packets at runtime, the build system parses a 4.9MB packet capture from a real Minecraft server and generates Rust constants at compile time.

// Auto-generated at build time from real Minecraft server
pub const LOGIN_SEQUENCE: &[&[u8]] = &[
    LOGIN_START_RESPONSE,
    SET_COMPRESSION,
    // ... hundreds more packets
];

This eliminates the need for runtime packet construction on the ESP32. The server includes 24 interactive levers in the Minecraft world, each controlling one LED on the badge.

When players toggle a lever in Minecraft, the server updates the LED state (minecraft_server_simple.rs:511-575):

if packet_id == 0x3C {  // Use Item On (lever interaction)
    if let Some((x, y, z)) = Self::decode_position(&payload[offset..offset + 8]) {
        if let Some(lever_idx) = self.find_lever_at_position(x, y, z) {
            let old_block_state = self.levers[lever_idx].block_state_id;
            let new_block_state = LeverConfig::toggle_block_state(old_block_state);
            self.levers[lever_idx].block_state_id = new_block_state;

            let new_powered = (new_block_state & 1) == 1;
            let led_index = self.levers[lever_idx].led_index;

            critical_section::with(|cs| {
                let mut led_states = crate::state::LEVER_LED_STATES.borrow_ref_mut(cs);
                led_states[led_index as usize] = new_powered;
            });
        }
    }
}

The protocol parsing uses efficient VarInt decoding (minecraft_server_simple.rs:404-423):

fn decode_varint(data: &[u8]) -> Option<(i32, usize)> {
    let mut result: u32 = 0;

    for (i, &byte) in data.iter().enumerate() {
        let value = (byte & SEGMENT_BITS) as u32;
        result |= value << (7 * i);

        if i >= 5 {
            return None; // VarInt too big
        }

        if byte & CONTINUE_BIT == 0 {
            return Some((result as i32, i + 1));
        }
    }
    None
}

Even more impressive: the player's rotation (yaw/pitch) in Minecraft controls a 3D rotating grass block on the OLED display.

Pattern Virtual Machine

Users can program custom LED animations using a stack-based bytecode interpreter with 30+ instructions:

Instruction Set:

  • Stack operations: PUSH, DUP, SWAP, POP, ROT, POPN
  • Arithmetic: ADD, SUB, MUL, DIV, MOD
  • Bitwise: AND, OR, XOR, SHL, SHR
  • LED operations: SET_PIXEL, SET_LED, FILL, HSV_TO_RGB
  • Pattern helpers: COUNTER, LED_INDEX, SINE

The VM executes instructions like this (pattern_vm.rs:254-295):

match opcode {
    OpCode::Push => {
        let value = self.program[self.pc];
        self.pc += 1;
        self.push(value)?;
    }
    OpCode::Add => {
        let b = self.pop()?;
        let a = self.pop()?;
        self.push(a.wrapping_add(b))?;
    }
    OpCode::PopN => {
        let count = self.pop()? as usize;
        for _ in 0..count {
            self.pop()?;
        }
    }
    OpCode::HsvToRgb => {
        let v = self.pop()?;
        let s = self.pop()?;
        let h = self.pop()?;
        let rgb = hsv2rgb(Hsv { hue: h, sat: s, val: v });
        self.push(rgb.r)?;
        self.push(rgb.g)?;
        self.push(rgb.b)?;
    }
}

Example program for a scrolling rainbow effect:

COUNTER         # Push frame counter (0-255)
LED_INDEX       # Push LED index (0-23)
ADD             # Combine for scrolling
10 MUL          # Spread colors
255 255         # Saturation, Value
HSV_TO_RGB      # Convert HSV to RGB
SET_PIXEL       # Set current LED

Programs are stored in flash memory (512 bytes each, 12 slots) and can be loaded on boot.

The CTF Challenges

The badge features three progressive CTF challenges:

Challenge 1: Passwordle

The first challenge is a Wordle-style password guessing game that uses the 24 RGB LEDs as visual feedback. Players must guess the 12-character login password one character at a time:

  • Green LED = correct character in correct position
  • Orange LED = correct character, wrong position
  • White LED = character not in password

This creative use of the LED strip as a display makes for an engaging physical puzzle.

Solution: The password is ChH4ckerCon! (12 characters). The systematic approach:

  1. Try common characters: aaaaaaaaaaaaa - observe orange LEDs for 'a' positions
  2. Guess letter positions: aaaaaaaaaaaaChaaaaaaaaaaChHaaaaaaaaaa
  3. Try numbers in different positions until LEDs turn green
  4. Work through special characters: !@#$% to find ! at position 12
  5. Final password: ChH4ckerCon!

The password is obfuscated in the firmware (obfuscate.rs:25-39):

pub const fn obfuscate<const N: usize>(data: &[u8; N], key: u8) -> [u8; N] {
    let mut result = [0u8; N];
    let mut i = 0;
    while i < N {
        // 1. Rotate left by position mod 8
        let rotate_amount = (i % 8) as u32;
        let step1 = data[i].rotate_left(rotate_amount);
        // 2. Add key
        let step2 = step1.wrapping_add(key);
        // 3. Add position-dependent value
        result[i] = step2.wrapping_add((i as u8).wrapping_mul(7));
        i += 1;
    }
    result
}

This three-step obfuscation (rotation + XOR + position offset) prevents simple string extraction from firmware dumps.

Challenge 2: Stack Archaeology

After logging in, players must find the root password to gain full shell access. The password is hidden within the stack-based programming language itself—buried in the VM's stack memory (pattern_vm.rs:63-178):

/// Secret password hidden at bottom of stack (12 characters)
/// Obfuscated at compile-time to avoid trivial extraction from firmware
const OBFUSCATED_PASSWORD: [u8; 12] = crate::obfuscate_bytes!(b"StackWizard!", 0x7A);

/// Initialize with secret password at bottom of stack
pub fn init_with_secret(&mut self) {
    let password = get_secret_password();
    for &byte in password.iter() {
        self.push(byte).ok();
    }
}

Every time the Pattern VM executes, the password "StackWizard!" is pre-loaded at the bottom of the 256-byte stack. Players need to craft a VM program that uses stack manipulation commands to dig down and reveal these 12 bytes.

Solution: The key insight is to use COUNTER (which pushes the frame counter), POPN (which pops N items from the stack), and then use the LED display as a visual indicator. Here's the solution:

COUNTER         # Push frame counter (0-255)
POPN            # Pop COUNTER items, exposing bottom of stack
                # This reveals the 12-byte password at the stack bottom

# Now display the password bytes using the LEDs
# Use example slot 5 (from the pre-programmed examples)
# which displays stack values as LED colors

The complete attack:

  1. Wait for the frame counter to reach 244 (256 - 12 = 244)
  2. Execute: COUNTER POPN to remove 244 stack items
  3. The remaining 12 bytes spell: StackWizard! (ASCII values)
  4. Use LED visualization to read the ASCII values
  5. Enter su command with password: StackWizard!

This challenge tests understanding of how stack-based virtual machines work and requires creative use of the POPN instruction.

Challenge 3: Minecraft Exploration

The final challenge requires players to:

  1. Start the Minecraft server on the badge
  2. Connect to it and explore the world
  3. Navigate to the bottom of the map
  4. Find and toggle all 24 levers scattered throughout the level
  5. Each lever illuminates one LED on the badge
  6. Successfully toggling all levers lights up all 24 LEDs

This combines gaming skills with the badge's physical interface in a creative way.

Obfuscation for Security

To prevent trivial firmware dumping attacks, all passwords are obfuscated at compile-time using XOR encryption and position-dependent transforms. This forces CTF participants to actually solve the challenges rather than simply extracting strings from the binary.

WebSocket Implementation from Scratch

Since this is a no_std environment, the badge implements RFC 6455 WebSockets from scratch in ~250 lines. The frame parser handles variable-length payloads (websocket.rs:35-104):

pub fn parse_frame(data: &[u8]) -> Option<(WebSocketFrame, usize)> {
    if data.len() < 2 { return None; }

    let byte0 = data[0];
    let byte1 = data[1];
    let fin = (byte0 & 0x80) != 0;
    let opcode = match byte0 & 0x0F {
        0x2 => OpCode::Binary,
        0x8 => OpCode::Close,
        0x9 => OpCode::Ping,
        _ => return None,
    };

    let masked = (byte1 & 0x80) != 0;
    let mut payload_len = (byte1 & 0x7F) as usize;
    let mut offset = 2;

    // Extended payload length
    if payload_len == 126 {
        payload_len = u16::from_be_bytes([data[2], data[3]]) as usize;
        offset = 4;
    } else if payload_len == 127 {
        payload_len = u64::from_be_bytes([
            data[2], data[3], data[4], data[5],
            data[6], data[7], data[8], data[9]
        ]) as usize;
        offset = 10;
    }

    let mask_key = if masked {
        Some([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]])
    } else { None };

    Some((WebSocketFrame { fin, opcode, masked, payload_len, mask_key }, offset + payload_len))
}

The handshake uses SHA-1 and custom base64 encoding (websocket.rs:169-178):

pub fn generate_accept_key(client_key: &str) -> heapless::String<28> {
    let mut hasher = Sha1::new();
    hasher.update(client_key.as_bytes());
    hasher.update(WEBSOCKET_GUID);  // RFC 6455 magic GUID
    let hash = hasher.finalize();
    base64_encode(&hash)
}

3D Graphics on OLED

The OLED display alternates between two modes:

  1. Animated Eyes: Blinking eyes with random pupil movement
  2. Grass Block: 3D rotating Minecraft grass block

The grass block renderer implements full 3D graphics pipeline (display.rs:202-224):

for (i, &[x, y, z]) in vertices.iter().enumerate() {
    // Rotate around Y axis (yaw)
    let cos_y = libm::cosf(-yaw_rad);
    let sin_y = libm::sinf(-yaw_rad);
    let x1 = x * cos_y - z * sin_y;
    let z1 = x * sin_y + z * cos_y;

    // Rotate around X axis (pitch)
    let cos_x = libm::cosf(-pitch_rad);
    let sin_x = libm::sinf(-pitch_rad);
    let y1 = y * cos_x - z1 * sin_x;
    let z2 = y * sin_x + z1 * cos_x;

    rotated_3d[i] = [x1, y1, z2];

    // Perspective projection
    let distance = 100.0;
    let scale = distance / (distance + z2);
    let px = (x1 * scale) as i32 + center_x;
    let py = (y1 * scale) as i32 + center_y;
    projected[i] = (px, py);
}

Back-face culling determines which faces are visible (display.rs:238-263):

for (verts, _name) in &faces {
    let v0 = rotated_3d[verts[0]];
    let v1 = rotated_3d[verts[1]];
    let v2 = rotated_3d[verts[2]];

    // Two edges of the face
    let edge1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
    let edge2 = [v2[0] - v1[0], v2[1] - v1[1], v2[2] - v1[2]];

    // Cross product to get normal (Z component for 2D culling)
    let normal_z = edge1[0] * edge2[1] - edge1[1] * edge2[0];

    // If normal_z > 0, face is pointing towards us
    if normal_z > 0.0 {
        for i in 0..4 {
            let start = verts[i];
            let end = verts[(i + 1) % 4];
            Line::new(Point::new(x1, y1), Point::new(x2, y2))
                .into_styled(line_style)
                .draw(display).ok();
        }
    }
}

The player's yaw and pitch from Minecraft directly control the cube's rotation in real-time.

LED Animations (15 Modes)

The badge includes 15 pre-programmed LED animation modes. Here are some highlights:

Rainbow - Simple HSV color cycling (led_patterns.rs:45-53):

pub fn generate_rainbow(hue: u8) -> [RGB8; LED_COUNT] {
    core::array::from_fn(|i| {
        hsv2rgb(Hsv {
            hue: hue.wrapping_add((i as u8) * (255 / LED_COUNT as u8)),
            sat: 255,
            val: 255,
        })
    })
}

Fire Effect - Procedural flame simulation (led_patterns.rs:137-169):

pub fn generate_fire(frame: u32, seed: u32) -> [RGB8; LED_COUNT] {
    core::array::from_fn(|i| {
        let pos_seed = (i as u32).wrapping_mul(1664525).wrapping_add(seed);
        let frame_seed = frame.wrapping_mul(1013904223);
        let noise = pos_seed.wrapping_add(frame_seed);

        let column = if i < 12 { i } else { 23 - i };
        let height_factor = if i < 12 { 1.0 } else { 0.6 };
        let horizontal_noise = ((noise >> 16) % 100) as f32 / 100.0;
        let temporal_noise = ((frame.wrapping_add(column as u32 * 7)) % 100) as f32 / 100.0;
        let heat = (horizontal_noise * 0.5 + temporal_noise * 0.5) * height_factor;

        let red = (255.0 * libm::fminf(1.0, heat + 0.3)) as u8;
        let green = (255.0 * libm::fminf(1.0, heat * 0.6)) as u8;
        RGB8::new(red, green, 0)
    })
}

Matrix Rain - Falling green characters (led_patterns.rs:171-204):

pub fn generate_matrix(frame: u32) -> [RGB8; LED_COUNT] {
    core::array::from_fn(|i| {
        let column = if i < 12 { i } else { 23 - i };
        let column_offset = (column * 7) as u32;
        let fall_position = (((frame + column_offset) / 2) % 12) as usize;
        let row = if i < 12 { i } else { 23 - i };

        let distance_from_head = if row <= fall_position {
            fall_position - row
        } else {
            12 + fall_position - row
        };

        if distance_from_head < 4 {
            let brightness = match distance_from_head {
                0 => 255,  1 => 180,  2 => 100,  _ => 40,
            };
            RGB8::new(0, brightness, 0)
        } else {
            RGB8::new(0, 0, 0)
        }
    })
}

Complete animation list:

  1. Rainbow 2. Wordle 3. Filmstrip 4. Fire 5. Matrix 6. Pulse 7. Scanner 8. Sparkle 9. Waterfall 10. Breathe 11. Snake 12. Twinkle 13. Lava Lamp 14. Custom (VM) 15. Lever (Minecraft)

All animations run at 60 FPS with brightness control via the boot button. The brightness has 5 levels (13, 38, 64, 89, 100 on a 0-255 scale) because the LEDs were simply too bright at higher values—keeping the maximum at 100/255 prevents retinal damage while maintaining vibrant colors.

Interactive Shell

After entering the correct password, users get access to an interactive shell with 20+ commands:

> led <mode>        # Switch LED patterns
> pattern <cmd>     # Program custom patterns
> wifi start/join   # WiFi management
> minecraft start   # Launch Minecraft server
> su                # Escalate to root (requires 2nd password)
> uptime            # Show uptime
> reboot            # Restart badge

The shell is implemented over USB Serial JTAG, providing a Unix-like command-line experience.

Memory-Constrained Design

Every byte counts when working with embedded hardware. Key optimizations:

  • 72KB heap for entire application
  • 2KB TCP buffers (rx/tx)
  • 512 bytes max per LED pattern program
  • Static lifetimes and stack allocation where possible
  • Zero-copy packet handling
  • Compile-time code generation instead of runtime parsing

The Tech Stack

Hardware: ESP32-C3 (RISC-V 32-bit, 160MHz, 4MB flash)

Rust Ecosystem:

  • #![no_std] bare-metal Rust
  • esp-hal 1.0.0-rc.1 - Hardware Abstraction Layer
  • Embassy - async runtime
  • Target: riscv32imc-unknown-none-elf

Key Crates:

  • esp-radio: WiFi with embassy support
  • embassy-net: Async TCP/IP stack
  • embedded-graphics: 2D/3D graphics
  • ssd1306: OLED driver
  • smart-leds: WS2812 LED control via RMT
  • esp-storage: Flash with wear leveling
  • edge-dhcp: DHCP server

Code Metrics

The project is substantial for an embedded system:

  • ~250KB total source code across 21 files
  • Longest files:
    • commands.rs: 38KB (comprehensive shell)
    • minecraft_server_simple.rs: 24KB (full protocol)
    • pattern_vm.rs: 21KB (complete VM)
    • led_patterns.rs: 14KB (15 animations)
  • 4.9MB packet capture processed at build time
  • 51 dependencies (including patches)
  • Size-optimized builds (opt-level = "s")

What Makes This Impressive

This badge showcases several advanced embedded systems concepts:

  1. Bare-metal async: Sophisticated concurrent programming without an OS
  2. Compile-time code generation: Converting 4.9MB of data into efficient constants
  3. Protocol implementation: Full Minecraft protocol and WebSocket from scratch
  4. Memory management: Complex functionality in 72KB heap
  5. 3D graphics: Real-time rendering on an 8-bit display buffer
  6. Security: Multiple layers of obfuscation and CTF challenges
  7. User programmability: Custom VM with persistent storage

Lessons for Embedded Rust Developers

This project demonstrates several best practices:

  • Use Embassy for async: Much cleaner than manual state machines
  • Leverage compile-time computation: Move complexity to build time
  • Profile memory carefully: Every allocation matters in no_std
  • Implement protocols correctly: WebSocket, DHCP, Minecraft—all RFC-compliant
  • Test on real hardware: Timing issues that don't appear in simulation

Conclusion

The ChCon Badge 2025 represents a masterclass in embedded systems programming. By combining networking, graphics, gaming, and security challenges into a single microcontroller, it pushes the boundaries of what's possible with bare-metal Rust. The attention to detail in areas like compile-time code generation, memory optimization, and multi-tasking architecture makes it an excellent case study for advanced embedded development.

Whether you're interested in embedded systems, Rust, game servers, or CTF challenges, this badge offers fascinating insights into modern embedded development techniques.


The ChCon Badge 2025 source code is located at /Users/jeremystott/projects/esp32/rustforgeconf/chcon-badge-2025-src

Table of Contents