BYUCTF 2025
Table of Contents

- 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 #

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:
byuctf
appears to be a prefix for the flag format."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: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.") |
The Result #

- 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!