Angr Issue: How to Tackle State Explosion with State Merging

Posted on Jun 5, 2025

0x00 Intro

Using angr for symbolic execution (symbex) to get assignments done caused anger issues for me and some of my fellows. With due respect, the documentation and existing examples in 2025 are insufficient to quickly teach a newcomer how to correctly tackle state explosion when doing symbex with angr. To fill this niche gap, this post will (1) first dissect an simple example to illustrate why the number of states increases (which will lead to state explosion if not controlled), then (2) try to make sense of and conduct state merging, the trick for tackling state explosion, on the simple example and (3) finally put everything together and try it on a real example that could cause state explosion. Note that it is not guaranteed that the use of angr APIs in this post is ideal. This post intends to provide at least a quick first-aid for those who are stuck with their assignments/CTF challenges with a closing deadline.

0x01 Why the number of states increases

The root cause of state explosion is conditional branches that depend on symbolic conditions. More precisely, if it is feasible with the system of constraints to both take the branch or not to take, Angr will split the current state into two states when executing the branch instruction, corresponding to the two possibilities. Otherwise, angr will be smart enough to not split the state if one of the two possibilities is infeasible.

To show this state-splitting process, I made up a very simple CTF target that expects command line input starting with k.

#include <stdio.h>

int main(int argc, char *argv[]) {

    char *message;

    if (argv[1][0] == 'k') {
        message = "Congrats!\n";
    } else {
        message = "Try again\n";
    }
    
    printf("%s", message);
    
    return 0;
}

For the sake of reproducibility, the compilation command is

gcc -O0 -g -o simple_branch ./simple_branch.c

Looking at the binary with my favorite Binary Ninja, the CFG of main looks like this (rebased to 0x400000 as angr uses this as the base address):

If the state-splitting conjecture is true, then we should see the symbolic execution running with only one state until the end of the basic block at 0x401149 and then splitting into 2 states at 0x40116e and 0x40117b, respectively. The following python script should verify my claim. It is recommended to use Jupyter Notebook to execute the code snippet in cells because I will extend this script later.

# Script 1, Cell 1
import os
import claripy
import angr

# Initialize the symbolic execution
current_dir = os.getcwd()
binary_path = os.path.join(current_dir, "simple_branch")
proj = angr.Project(binary_path, auto_load_libs=False)
sym_var = claripy.BVS("input", 8)
state = proj.factory.entry_state(args=["./simple_branch", sym_var])
simgr = proj.factory.simulation_manager(state)


# Advance till the only state diverges into two
while len(simgr.active) > 0:
    simgr.step() # it will execute the whole basic block
    print("Number of active states:", len(simgr.active))
    for state in simgr.active:
        print("\tstate addr", hex(state.addr))
    if len(simgr.active) == 2:
        break

A side note here, for those who wonder how can I step one instruction at a time instead of the whole basic block with simgr.step(), use num_inst=1 parameter.

The output of the script confirms the conjecture.

Number of active states: 1
	state addr 0x500000
Number of active states: 1
	state addr 0x401149
Number of active states: 2
	state addr 0x40116e
	state addr 0x40117b

Although in this simple example, the number of states ends up at two and is not exploding at all, consider an example where there is an if-statement in a loop with 32 iterations. In the first iteration, it splits into two states, say taken-1 and not-taken-1. Then in the second iteration, taken-1 will split into two and so does not-taken-1. It is easy to see where this is going to, namely 2^32 states.

0x02 State splitting: A closer examination (contd)

For the sake of completeness, it would be nice to also look at what the constraints look like after the splitting and run the execution all the way till the end.

The script for doing so:

# Script 1, Cell 2
print("\n-- Divergence point reached, checking the constraints --")
# Let's check what the constraints are in the two states
for state in simgr.active:
    print("State addr:", hex(state.addr))
    print("\tConstraints on sym_var:")
    for constraint in state.solver.constraints:
        print(f"\t\t{constraint}") 

print("\n-- Keep going until the result is printed --")
# 1) One possible way of doing this
# simgr.explore(find=0x4011a1) # states reaching the address will be moved to the 'found' stash

# 2) An alternative way without hardcoding the address:
def until_something_is_printed(sigmr: angr.SimulationManager):
    for s in sigmr.active:
        if s.posix.dumps(1):
            return True
    for s in sigmr.deadended:
        if s.posix.dumps(1):
            return True
    return False

# advance states till something is printed in some state(s), then pick them out
# do this until we have no state active
while len(simgr.active) > 0:
    simgr.run(stash="active", until=until_something_is_printed)
    simgr.move(from_stash="active", to_stash="found", filter_func=lambda s: s.posix.dumps(1))

for state in simgr.found:
    print("State advanced to:", hex(state.addr))
    print("\tConstraints on sym_var:")
    for constraint in state.solver.constraints:
        print(f"\t\t{constraint}")
    print("\tExpected output:", state.posix.dumps(1).decode('utf-8'))

I would like to take a little detour here to discuss a bit about the APIs for those who are new to angr.

One may notice the similar pair simgr.run and simgr.explore in the example. A very important difference in their behavior is that explore will stash states it found into different stashes immediately, whereas run only put states into deadended stash if there is no next instruction to be executed. I personally use explore when I want to know the path that leads to a certain instruction and run if I want to single step till some condition is satisfied.

The output of the cell perfectly aligns with our expectation. If the input is k we enter the branch that leads to the flag and if not we miss the flag.

-- Divergence point reached, checking the constraints --
State addr: 0x40116e
	Constraints on sym_var:
		<Bool input_2_8 == 107>
State addr: 0x40117b
	Constraints on sym_var:
		<Bool input_2_8 != 107>

-- Keep going until the result is printed --
State advanced to: 0x4011a1
	Constraints on sym_var:
		<Bool input_2_8 != 107>
	Expected output: Try again

State advanced to: 0x4011a1
	Constraints on sym_var:
		<Bool input_2_8 == 107>
	Expected output: Congrats!

0x03 Merging the states

If we have too many states, just merge them. What we can do is that after each conditional branch, merging the two states into one so that throughout the execution there will be only one state. But wait a minute, what does it mean by merging the states? An intuitive answer is that a merged state is as if it were the superposition of the two possibilities when going through that conditional branch because the branch depends on some unspecified symbolic values. When it is provided with concrete values or more constraints for those symbolic values, the superposition will suddenly collapse into one, eliminating the other possibility.

If the intuitive answer turns out to be confusing, the following script that observes the constraints after state-merging will hopefully serve as a more explanatory version. The script is still analyzing the simple binary from section 0x01.

# Script 2, cell 1
import os
import claripy
import angr

# Initialize the symbolic execution
current_dir = os.getcwd()
binary_path = os.path.join(current_dir, "simple_branch")
proj = angr.Project(binary_path, auto_load_libs=False)
sym_var = claripy.BVS("input", 8)
state = proj.factory.entry_state(args=["./simple_branch", sym_var],
                                add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,
                                            angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS})
simgr = proj.factory.simulation_manager(state)


# Advance till the first instruction after the divergence
simgr.explore(find=0x401186)

# Merge the two states
after_merge = simgr.found[0]
for state in simgr.found[1:]:
    after_merge,_ ,_ = after_merge.merge(state)
simgr = proj.factory.simulation_manager(after_merge)
assert len(simgr.active) == 1

# print the constraints
for c in simgr.active[0].solver.constraints:
    print(c)

The output of the script is below. state_merge_2_8_16 is a new symbolic variable that describes whether the conditional branch has been taken or not. If it is taken then the input does not starts with k, otherwise it is a fall-through and the input starts with k.

<Bool state_merge_2_8_16 == 0x0 || state_merge_2_8_16 == 0x1>
<Bool state_merge_2_8_16 == 0x0 && input_6_8 == 107 || state_merge_2_8_16 == 0x1 && input_6_8 != 107>

To further illustrate what will happen in the end, we run the simulation to the point right before printf is called. When calling printf("%s",...), RSI should store the pointer to the message. The idea is to print the possibilities of the RSI value to confirm that this is indeed the superposition of the two states (correct input and incorrect input). Finally, a concrete constraint on the input is supplemented and the superposition is expected to collapse into one.

# Script 2, cell 2
# Stop right before the call to printf
simgr.explore(find=0x40119c)
assert len(simgr.found) == 1
print("Address of current state:", hex(simgr.found[0].addr))
state = simgr.found[0]
rsi_val = state.solver.eval(state.regs.rsi)
print(f"RSI is symbolic: {state.regs.rsi.symbolic}")

# check all possible values of RSI
print(f"\n-- Possible messages to be printed --")
possible_rsi_vals = state.solver.eval_upto(state.regs.rsi, 128)
for val in possible_rsi_vals:
    print(f"Possible RSI: {hex(val)}")
    message = state.solver.eval(state.memory.load(val, 8), cast_to=bytes)
    print(f"Message: {message.decode('utf-8', errors='ignore')}")

print("\n-- Adding constraint on input --")
print(f"RSI is symbolic: {state.regs.rsi.symbolic}")
possible_rsi_vals = state.solver.eval_upto(state.regs.rsi, 128, extra_constraints=[sym_var == b'k'])
for val in possible_rsi_vals:
    print(f"Possible RSI: {hex(val)}")
    message = state.solver.eval(state.memory.load(val, 8),
                                cast_to=bytes)
    print(f"Message: {message.decode('utf-8', errors='ignore')}")

print("\n-- Running the simulation till the end --")
state.solver.add(sym_var == b'k')
simgr.run(stash='found')
state = simgr.deadended[0]
print(f"Output: {state.posix.dumps(1).decode('utf-8', errors='ignore')}")

The output is self-explanatory. After adding the constraint that input starts with k, the only possible message to be printed is Congrats, which is the only satisfiable outcome (state_merge_2_8_16==0x0, branch not taken) after solving the system of constraints.

Address of current state: 0x40119c
RSI is symbolic: True

-- Possible messages to be printed --
Possible RSI: 0x40200f
Message: Try agai
Possible RSI: 0x402004
Message: Congrats

-- Adding constraint on input --
RSI is symbolic: True
Possible RSI: 0x402004
Message: Congrats

-- Running the simulation till the end --
Output: Congrats!

0x04 Real example that could cause state explosion

The sample program is a path-finding riddle that takes a string as input. It recognizes only the alphabet “U”, “D”, “L” and “R” (up, down, left and right). Each character moves the position by 1 along a direction and it starts at (0,0).

A loop iterates the input characters and if-statements in the loop set the delta amount for x and y coordinates. The if-statements in the loop will cause state explosion if we go with naive symbex.

The only thing I want to highlight with this example is that: no matter how the angr API changes in the future, the superposition of a state should always be ready to collapse into any of the possible states in real execution. This is the standard for checking if the merge operation is successful nor not. More specifically, if we examine the delta amount along any axis at the end of each iteration, it is exactly one of [0, +1, -1].

// real_example.c
// compile with:
// gcc -O0 -g -o real_example ./real_example.c
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {

    if (argc != 2) {
        printf("Usage: %s <input_string>\n", argv[0]);
        return 1;
    }


    char *input = argv[1];

    if (strlen(input) != 32) {
        printf("Input string must be exactly 32 characters long.\n");
        return 1;
    }

    int x_pos = 0;
    int y_pos = 0;

    for (int i = 0; i < 32; i++) {
        char byte = input[i];
        int x_delta = 0;
        int y_delta = 0;


        if (byte == 'U') {
            y_delta = 1;
        }

        if (byte == 'D') {
            y_delta = -1;
        }

        if (byte == 'L') {
            x_delta = -1;
        }

        if (byte == 'R') {
            x_delta = 1;
        }

        x_pos += x_delta;
        y_pos += y_delta;
    }

    // choosing 0725 bc it is the birthdate of my Oshimen
    if (x_pos == 7 && y_pos == 25) {
        printf("Success: Reached target position (7, 25)\n");
    } else {
        printf("Failed: Current position is (%d, %d), not (7, 25)\n", x_pos, y_pos);
    }

    return 0;
}

The script takes about a minute to run on my laptop, but it should not ended up with state explosion. Some addresses in the binary are hardcoded in the script, check the comments in the script for more details in case it does not run on your machine and fix them accordingly (note that angr assumes base address of 0x400000, adjust that in your analysis tool like binary ninja). The indication of success is that, at the end of one iteration, the possible values of the partial sum should be [0, 1, -1].

# Script 3, cell 1
import os
import claripy
import angr

# Initialize the symbolic execution
current_dir = os.getcwd()
binary_path = os.path.join(current_dir, "./real_example")
proj = angr.Project(binary_path, auto_load_libs=False)

# In some examples, the symbolic input is initialized as a whole byte chunk,
# but the nature of this input is a string of 32 bytes (null-terminated),
# so one symbolic variable per byte is created.
sym_vars = [claripy.BVS(f"input_{x}", 8) for x in range(33)]
sym_string = claripy.Concat(*sym_vars)
state = proj.factory.entry_state(args=["./real_example", sym_string],
                                add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY,
                                            angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS})

# Constraint: the len of the input must be 32 bytes
for idx, char in enumerate(sym_vars):
    if idx < 32:
        state.solver.add(char >= 0x20)
        state.solver.add(char <= 0x7e)
    else:
        state.solver.add(char == 0x00)

simgr = proj.factory.simulation_manager(state)
# the merge points are the first instruction
# where the two divergent paths resulted from a conditional branch meet

# For example, 0x401240 is the merge point after branching at 0x40123d
#
# 00401239  807de355           cmp     byte ptr [rbp-0x1d {var_25_1}], 0x55
# 0040123d  7507               jnz     0x401246
# 0040123f  c745f401000000     mov     dword ptr [rbp-0xc {var_14_1}], 0x1
# 00401246  807de344           cmp     byte ptr [rbp-0x1d {var_25_1}], 0x44

mergepoints = [
    0x401246,
    0x401253,
    0x401260,
    0x40126d,
]


for i in range(32):
    for mp in mergepoints:
        simgr.explore(find=mp)
        for s in simgr.found:
            assert s.addr == mp
        for s in simgr.active:
            assert s.addr == mp
        simgr.stash(filter_func=None,
                    from_stash='found', to_stash='active')
        first = simgr.active[0]
        the_rest = simgr.active[1:]
        for s in the_rest:
            first = first.merge(s)[0]
        new_simgr = proj.factory.simulation_manager(first)
        simgr = new_simgr
        state = simgr.active[0]

        if i == 0:
            rbp = state.regs.rbp
            rbp_value = state.solver.eval(rbp)
            partial_sum = state.memory.load(rbp_value - 0x10, 4)
            possible_values = state.solver.eval_upto(partial_sum, 1024, cast_to=bytes)
            # usually cast_to=int will work, but in this case since the offset could be -1
            # which will be interpreted as unsigned by default, we need to cast manually
            possible_values = [int.from_bytes(x, 'little', signed=True) for x in possible_values]
            print(f"Possible values: {sorted(possible_values)}")
            print(f'RIP: {hex(state.solver.eval(state.regs.rip))}')

The expected output should is:

Possible values: [0]
RIP: 0x401246
Possible values: [0]
RIP: 0x401253
Possible values: [-1, 0]
RIP: 0x401260
Possible values: [-1, 0, 1]
RIP: 0x40126d

The part for solving for an input instance that solves the riddle is left as an exercise (I know this is irritating, but since this is not a math textbook, I shall probably be pardoned).