Skip to main content
  1. ctf-writeups/

BYUCTF 2025

·4 mins
llir
  • This CTF was hosted by BYU Cyberia, the official CTF team of Brigham Young University. Although I joined the event late and only had time to solve one challenge, it featured a well-constructed reverse engineering task that provided an engaging technical exercise. This writeup documents my solution.

LLIR #

llir

Challenge Overview #

  • We were given a file named checker.ll, containing a program written in LLVM Intermediate Representation (LLVM IR).

  • The challenge title Checker? I hardly know her!, hints at the presence of a function with a similar name, which turns out to be the core of the validation logic.

  • Our goal was to reverse this function and determine the correct input that satisfies its internal checks.

What is LLVM IR? #

  • LLVM IR is a low-level, assembly-like language that is more readable than raw assembly but not as intuitive as C. It strikes a balance between human readability and machine-level detail.

  • It acts as a universal intermediate form used by the LLVM compiler framework, sitting between source code and machine code.

  • Multiple languages (e.g., C, C++) compile down to LLVM IR, enabling consistent analysis and optimization across different frontends.

  • Its structure and transparency make it a valuable target for reverse engineering, especially when source or binaries are unavailable.

Initial Analysis #

Key Strings in the IR #

@.str = private unnamed_addr constant [7 x i8] c"byuctf\00", align 1
@.str.1 = private unnamed_addr constant [46 x i8] c"Welcome to this totally normal flag checkern\0A\00", align 1
@.str.2 = private unnamed_addr constant [61 x i8] c"We're going to use a little bit of a different compiler tho\0A\00", align 1
@.str.3 = private unnamed_addr constant [56 x i8] c"Ever heard of clang? What makes it different than gcc?\0A\00", align 1
@stdin = external dso_local global ptr, align 8
@.str.4 = private unnamed_addr constant [11 x i8] c"You win!!\0A\00", align 1
@.str.5 = private unnamed_addr constant [11 x i8] c"Womp womp\0A\00", align 1
  • These embedded strings offer valuable hints about the program’s logic and output behavior:
  1. byuctf appears to be a prefix for the flag format.

  2. "You win!!" and "Womp womp" suggest success/failure messages, indicating which execution path corresponds to a valid flag.

Analyzing the Flag Validation Function #

  • The main function of interest is:
@checker_i_hardly_know_her
  • This function is responsible for validating user input against a series of byte-wise constraints and comparisons. By analyzing these checks, we can attempt to reconstruct the correct input (i.e., the flag). Below is an excerpt from the @checker_i_hardly_know_her function:

    llir

  • This function validates the input through a series of byte-level comparisons and control-flow branches. The logic is dense, relying heavily on pointer arithmetic and conditionals.

Leveraging Symbolic Execution #

  • To simplify analysis, I compiled the LLVM IR to a native binary and used symbolic execution to explore the input space. The goal is to find a flag that causes the program to print "You win!!"

Compiling LLVM IR to a Binary #

# Step 1: Compile LLVM IR to bitcode
clang -O2 -emit-llvm -c checker.ll -o checker.bc

# Step 2: Generate a native executable
clang checker.bc -o executable
  • With the binary in hand, we can use angr to perform symbolic execution..

Solving with angr #

Setting Up angr #

  • angr is an open-source binary analysis platform for Python. It combines both static and dynamic symbolic (“concolic”) analysis, providing tools to solve a variety of tasks.
  • We are going to use it to define a symbolic variable (flag) and automatically explore code paths to meet certain conditions.
import angr
import claripy

Declaring the Symbolic Flag #

  • We know from reversing (or from inspecting the IR structure) that the expected input length is 37 bytes
FLAG_LEN = 37`

flag_chars = [claripy.BVS(f'flag_{i}', 8) for i in range(FLAG_LEN)]
flag = claripy.Concat(*flag_chars + [claripy.BVV(b'\n')])

Load the binary into angr #

proj = angr.Project('./executable', auto_load_libs=False)

Adding Constraints #

  • We assume that the flag consists of printable ASCII characters, so we add constraints to ensure that each byte of the flag is within the ASCII printable range (0x20 to 0x7e).
  • We also add a known prefix byuctf{ since it’s the standard flag format.
for c in flag_chars:
    state.solver.add(c >= 0x20)  # Printable characters start at 0x20 (space)
    state.solver.add(c <= 0x7e)  # Printable characters end at 0x7e (~)

# Add known flag prefix
prefix = b"byuctf{"
for i, byte in enumerate(prefix):
    state.solver.add(flag_chars[i] == byte)

Exploring with angr #

  • Once we have the symbolic flag and constraints set up, we create an initial state where the flag is fed in as stdin. We then instruct angr to explore all paths and look for one where the program prints “You win!!”:
# Initialize the state
state = proj.factory.full_init_state(stdin=flag)

Set up the simulation manager #

simgr = proj.factory.simulation_manager(state)

Define success condition: when we find “You win!!” in stdout #

def is_successful(state):
    return b"You win!!" in state.posix.dumps(1)

Start exploring paths #

simgr.explore(find=is_successful)
  • If angr finds a path where the success condition is met, we extract the flag.
# If a successful path was found, extract the flag
if simgr.found:
    found = simgr.found[0]
    flag_val = found.solver.eval(flag, cast_to=bytes)
    print("[+] Found flag:", flag_val.decode(errors='ignore'))
else:
    print("[-] No path found.")

The Final angr script #

import angr
import claripy
# Flag is 37 bytes based on earlier info
FLAG_LEN = 37
# Create symbolic flag
flag_chars = [claripy.BVS(f'flag_{i}', 8) for i in range(FLAG_LEN)]
flag = claripy.Concat(*flag_chars + [claripy.BVV(b'\n')]) # fgets includes newline
# Load the binary
proj = angr.Project('./executable', auto_load_libs=False)
# Start state with symbolic stdin
state = proj.factory.full_init_state(stdin=flag)
# Add constraints for printable ASCII
for c in flag_chars:
state.solver.add(c >= 0x20)
state.solver.add(c <= 0x7e)
# Optional: Add known prefix if known
prefix = b"byuctf{"
for i, byte in enumerate(prefix):
state.solver.add(flag_chars[i] == byte)
# Set up simulation manager
simgr = proj.factory.simulation_manager(state)
# Define success condition
def is_successful(state):
output = state.posix.dumps(1)
return b"You win!!" in output
# Explore until we hit success
simgr.explore(find=is_successful)
# If we found a successful path, extract flag
if simgr.found:
found = simgr.found[0]
flag_val = found.solver.eval(flag, cast_to=bytes)
print("[+] Found flag:", flag_val.decode(errors='ignore'))
else:
print("[-] No path found.")
view raw angr_sol.py hosted with ❤ by GitHub

The Result #

llir
  • After running the symbolic execution, angr found the correct input that triggered the “You win!!” output. The flag was automatically extracted: Flag: byuctf{lL1r_not_str41ght_to_4sm_458d}

Conclusion #

  • This challenge provided a valuable opportunity to work with LLVM IR and leverage symbolic execution using angr to automate the reverse-engineering process.
  • I hope this walkthrough provided useful insights, and I look forward to sharing more in the next blog!