← Back to Writeups
CUSTOMMediumReversing

Lemballovo

XESXOR9/10/20266 min read
#reversing#custom#medium#cd_check_nop#conditional_jump_bypass#registry_check_nop#result_system_reversing#sound_error_bypass#state_machine_patching#vsr_directory_entry_swap#win_condition_forcing#wine_runtime_emulation

Lemballovo

Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Windows | Author: XESXOR | Date: 2026-07-03 | Status: Solved Techniques: cd_check_nop, conditional_jump_bypass, registry_check_nop, result_system_reversing, sound_error_bypass, state_machine_patching, vsr_directory_entry_swap, win_condition_forcing, wine_runtime_emulation

Summary

Task: a 1996 Windows game 'Lemmings Paintball' (PE32 x86) won't start due to install/CD/sound checks; must play a custom-injected level to capture the flag. Solution: patch conditional jumps to bypass startup gates, run in Wine 5.0 via Docker, swap VSR directory entries to reach the locked level, then force the win condition by patching the result-system state machine.

Lemballovo — spbctf

Description

Я хотел захватить флаг, вот так: [GIF геймплея]. Но игра выдаёт ошибки и не запускается :( Поиграй в игру и захвати тот флаг!

English summary: A ZIP archive contains "Lemmings Paintball" (1996, Visual Sciences) — a PE32 x86 Windows game with DirectDraw rendering. The game refuses to start due to multiple DRM/environment checks. A GIF shows the "Flag On A Hill" level demo. The goal is to patch the game, run it, find the organizer's custom-injected level, and win it to reveal the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Initial reconnaissance

$ file LEMBALL.EXE
LEMBALL.EXE: PE32 executable (GUI) Intel 80386, for MS Windows

$ strings LEMBALL.EXE | grep -i "install\|cd\|unable\|paintball"
Paintball Not Installed
Unable to find CD
Please insert the Paintball CD
HKLM\SOFTWARE\Visual Sciences\Lemmings Paintball

The archive contains:

  • LEMBALL.EXE — main game executable (PE32 x86, ~300KB)
  • PBAIMOG.VSR — CRID resource archive (levels, bitmaps, sounds)
  • DSPDIB16.DLL, DSPDIB32.DLL — DisplayDib rendering libraries ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Four startup gates blocking execution

  1. Install check (VA 0x406e31): Calls 0x45ecb0 which reads registry key HKLM\SOFTWARE\Visual Sciences\Lemmings Paintball. If not found → MessageBox "Paintball Not Installed" → exit.

  2. CD check (VA 0x406e66): Calls 0x45eda0("vsmem.dll") which scans all drives via GetLogicalDrives/GetDriveTypeA looking for a CD containing vsmem.dll. If not found → "Unable to find CD" retry loop.

  3. Sound error (VA 0x47c71f): waveOutOpen fails without a sound device → blocking dialog "Error! Sound System cannot open Wave Device!" with Abort/Retry/Ignore. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  4. Missing intro: Game tries to open lemball\Intro.avi (intro movie from CD) → "Cannot find the specified file" dialog.

Finding the custom level

The VSR archive uses the CRID format with hierarchical directories. Scanning all 114 level NAME chunks reveals exactly ONE non-standard name: "Solve,solve like the wind!" at TAXING pack level #18 (0-based entry 27 in the TAXING category directory). All other 113 level names are genuine Lemmings Paintball originals.

The level's FSDG tilemap is a 64×64 spiral maze (6-byte cells, byte index 4: 0=floor, 16=wall). The lemming starts at the center on a checkered flag with a 60-second timer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Level lock mechanism

The game locks levels sequentially — only level 1 of each pack (Fun/Tricky/Taxing/Mayhem) is initially available. Reaching TAXING #18 requires either:

  • Reversing the password/unlock algorithm (complex)
  • Swapping VSR directory entries (simple)

Result system internals

The level-end result system uses a state pointer at [0x49cb68]:

  • [state+0xc] = result code (2 = WIN, 1/3/4/5 = various lose conditions)
  • Formatter function at VA 0x450160 reads the result code and selects a message table:
    • Win table at 0x49fb38 (index 2 → "You collected all the flags!")
    • Lose table at 0x49fb78 (index 3 → eliminated, index 4 → ran out of time)
  • [obj+0x518] = winner flag (nonzero → win display path)
  • State machine at 0x411c40 writes result codes on level-end events ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Patch startup checks (LEMBALL_patched.EXE)

Three binary patches to bypass the startup gates:

PurposeFile offsetVABeforeAfterEffect
Install check bypass0x62380x406e3875 17 (jne)EB 17 (jmp)Always skip "Not Installed" branch
CD check bypass0x626d0x406e6d75 17 (jne)EB 17 (jmp)Always skip "Unable to find CD" branch
Sound error bypass0x7bb1f0x47c71f74 42 (je)EB 42 (jmp)Skip waveOutOpen error dialog
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 2: Set up Wine 5.0 runtime

Wine 11 crashes on Apple Silicon (macOS ARM) due to a 16K-vs-4K page-size assertion in ntdll. Wine 5.0 on Ubuntu 20.04 in Docker (--platform linux/amd64) works:

dockerfile
FROM --platform=linux/amd64 ubuntu:20.04
RUN dpkg --add-architecture i386 && apt-get update && \
    apt-get install -y wine wine32:i386 xvfb pulseaudio imagemagick xdotool

Runtime setup:

  • PulseAudio with null-sink provides a dummy audio device so waveOutOpen succeeds
  • Registry: set Audio=pulse before wineboot; set SrcDisk and Installed under the Lemmings Paintball key
  • Intro.avi: generate a valid 1-second black AVI with ffmpeg (-c:v msvideo1) to avoid the missing-file dialog
  • Display: Xvfb :0 -screen 0 800x600x16 for headless rendering ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 3: Swap VSR directory entries to reach the locked level

Since levels are locked sequentially, swap the TAXING directory entries in PBAIMOG.VSR so selecting "Taxing level 1" (unlocked) loads the "Solve,solve like the wind!" maze:

python
#!/usr/bin/env python3
"""Swap TAXING directory entries 10 (Gatecrash=lvl1) <-> 27 (Solve=lvl18)"""
import struct, shutil

shutil.copy("PBAIMOG.VSR", "PBAIMOG_solve.vsr")
data = bytearray(open("PBAIMOG_solve.vsr", "rb").read())

# TAXING child directory at VSR offset 0x7d2f24
# Each entry is 0x24 bytes; entries contain offset + size fields
taxing_dir = 0x7d2f24
entry_size = 0x24
e10_off = taxing_dir + 10 * entry_size  # Gatecrash Boogie (level 1, unlocked)
e27_off = taxing_dir + 27 * entry_size  # Solve,solve like the wind! (level 18, locked)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Swap the two 0x24-byte directory entries
e10_data = data[e10_off:e10_off + entry_size]
e27_data = data[e27_off:e27_off + entry_size]
data[e10_off:e10_off + entry_size] = e27_data
data[e27_off:e27_off + entry_size] = e10_data

with open("PBAIMOG_solve.vsr", "wb") as f:
    f.write(data)

Verified: the briefing screen shows "Taxing / Solve,solve like the wind!".

Step 4: Force WIN via result-system patches (LEMBALL_win.EXE)

Since the spiral maze is impractical to solve via automated screenshot-based gameplay, force the win condition by patching the result system: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
"""Apply win-forcing patches to LEMBALL_patched.EXE -> LEMBALL_win.EXE"""
import shutil

shutil.copy("LEMBALL_patched.EXE", "LEMBALL_win.EXE")
data = bytearray(open("LEMBALL_win.EXE", "rb").read())

patches = [
    # Startup bypasses (inherited)
    (0x6238,  bytes([0xEB])),          # install check: jne -> jmp
    (0x626d,  bytes([0xEB])),          # CD check: jne -> jmp
    (0x7bb1f, bytes([0xEB])),          # sound error: je -> jmp

    # Force win message table (VA 0x450186)
    (0x4f586, b"\xb9\x38\xfb\x49\x00" + b"\x90" * 8),  # mov ecx, 0x49fb38 + NOPs
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # Force result index = 2 (WIN) (VA 0x4501b7)
    (0x4f5b7, b"\x6a\x02\x5a"),       # push 2; pop edx

    # Force winner-flag = 1 (VA 0x4500a5)
    (0x4f4a5, b"\x6a\x01\x58\x90"),   # push 1; pop eax; nop

    # Always play win sound (VA 0x450b6d)
    (0x4ff6d, b"\x90\x90"),            # nop out je (lose-sfx branch)

    # State machine: all result codes -> WIN(2)
    (0x1113f, bytes([0x02])),  # was 03
    (0x11156, bytes([0x02])),  # was 05
    (0x111f3, bytes([0x02])),  # was 03
    (0x1120a, bytes([0x02])),  # was 05 (in mov eax,5)
    (0x11232, bytes([0x02])),  # was 04
    (0x1124f, bytes([0x02])),  # was 04
    (0x1127b, bytes([0x02])),  # was 01
    (0x11298, bytes([0x02])),  # was 01
    (0x112ae, bytes([0x02])),  # was 04
    (0x11389, bytes([0x02])),  # was 04
    (0x12074, bytes([0x02])),  # was 05
]
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

for offset, patch_bytes in patches:
    data[offset:offset + len(patch_bytes)] = patch_bytes

with open("LEMBALL_win.EXE", "wb") as f:
    f.write(data)

Step 5: Capture the flag

Running LEMBALL_win.EXE with the swapped VSR in Wine 5.0:

  1. Start the game → select TAXING pack → level 1 (now loads "Solve,solve like the wind!")
  2. The spiral maze level begins with a 60-second timer
  3. When the level ends (timeout/death), the forced WIN path triggers
  4. A congratulations dialog appears with the flag: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Congratulations! Your flag: spbctf{REDACTED} Enjoy the game :-) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍