Control Flow Graphs Explained

Control Flow Graphs Explained

A control flow graph is the map an analyzer uses to reason about the order your code can run in. It splits a function into straight line chunks and draws arrows for every branch, loop, and return. Once you can read that map, a lot of security analysis stops looking like magic and starts looking like tracing arrows from the front door to a dangerous room.

What a control flow graph actually is

Take one function. A control flow graph breaks it into basic blocks and connects them with edges. A basic block is a run of straight line code with no jumps in the middle: execution enters at the top, runs every line in order, and leaves at the bottom. The moment the code can go two ways, an if, a loop test, an early return, that block ends and edges carry you to the possible next blocks.

Every graph has one entry where the function starts and at least one exit where it ends. The edges encode the rule “after this block finishes, execution can go here.” That is the whole idea: not what the data means, just the order things can happen in.

A small function and its blocks

Here is a function that decides whether to charge a customer.

def checkout(user, cart):        # B1
    if cart.is_empty():          # B1
        return "empty"           # B2
    total = price(cart)          # B3
    if user.has_discount:        # B3
        total = total * 0.9      # B4
    charge(user, total)          # B5
    return "ok"                  # B5

The comments mark which basic block each line belongs to. The first block runs the empty check. If the cart is empty we jump to the return. Otherwise we compute a price, maybe apply a discount, then charge and finish. Drawn as a control flow graph it looks like this:

        [B1: enter, cart.is_empty?]
           /                  \
      true                    false
        |                       |
   [B2: return "empty"]   [B3: total=price; has_discount?]
        |                    /            \
        |                true            false
        |                 |                |
        |            [B4: total*=0.9]      |
        |                 \               /
        |                  \             /
        |                 [B5: charge; return "ok"]
        |                       |
         \                     /
          -----> [exit] <------

Notice the shape. B1 forks into two edges because of the if. B4 is a small detour that only exists on one route. B3 always reaches B5, whether or not the discount block ran. Two blocks lead to the exit, B2 and B5, because the function has two ways to end.

Paths: why the control flow graph matters for bugs

A path is one full route from entry to exit. In the checkout graph there are three of them:

  • B1 -> B2 -> exit: the empty cart, nothing gets charged.
  • B1 -> B3 -> B5 -> exit: a normal customer, full price.
  • B1 -> B3 -> B4 -> B5 -> exit: a discount customer.

This is the key point for security work. A bug often lives on only one path. Suppose the discount math had a rounding flaw that let total go negative. The empty cart path never touches it. The full price path never touches it. Only the third route, the one through B4, hits the bug. If your test data never included a discount user, you never ran the block where it lives.

A vulnerability is not a property of a function. It is a property of a path through that function, and a scanner that never walks that path will swear the code is clean.

Branches, loops, and returns as edges

Three code shapes make almost every edge you will see:

  • Branches. An if, switch, or ternary splits one block into two or more outgoing edges, one per case.
  • Returns and breaks. A return, break, or continue ends a block early and sends an edge to the exit or back to a loop header, skipping whatever came after it.
  • Loops. A while or for adds an edge that points backward, from the end of the loop body up to the loop test. That back edge is what makes a graph a loop instead of a straight line.

Why loops force analysis to approximate

That back edge has a big consequence. Look at this:

while queue:                 # L1
    item = queue.pop()       # L2
    process(item)            # L2

How many paths run through here? If the loop can run zero times, once, twice, or a thousand times, then the number of distinct paths through the function grows without limit. A control flow graph with a single loop already describes an unbounded set of paths. You cannot list them all, so no analyzer tries to.

Instead tools approximate. They summarize what is true no matter how many times the loop spins, rather than walking every possible count. That is why static analysis talks about what "can" reach a sink instead of exactly which run does. The graph is precise about order; the counting is deliberately loose because it has to be.

Feasible and infeasible paths

Not every route the graph allows can actually run. Consider two branches on the same flag:

if admin:            # B1
    role = "root"    # B2
if not admin:        # B3
    role = "guest"   # B4

The graph draws an edge into B2 and an edge into B4. On paper a path exists that runs both. But admin cannot be true and false in the same call, so the route through B2 and B4 together is an infeasible path. It exists in the graph and can never exist at run time.

A feasible path is one where the branch conditions along it can all hold at once. Telling the two apart is where analyzers earn their keep. Report a bug on an infeasible path and you have a false positive that wastes a developer's afternoon. Deciding feasibility in general is hard, so good tools reason carefully and still admit some uncertainty.

Tying it back to security

Most of the questions that matter for a vulnerability are control flow questions in disguise:

  • Is the check on every path? If an authorization check sits in B2 but one branch routes around it straight to the sensitive action, the graph shows the gap as an edge that skips the check.
  • Is the sink reachable only when a flag is set? A dangerous eval guarded by if debug_mode lives on a path that only opens when that flag is true. Whether an attacker can set the flag is the real question.
  • Does an early return leave state half done? A return edge that jumps out before a cleanup block runs is a missing step you can see in the graph.

Here is a concrete miss. Imagine an API handler that validates ownership in one branch but has an early return for cached responses that skips straight to serving data. The cache path never touches the ownership check. Reading the code top to bottom, the check looks present. Reading the control flow graph, you see one edge that reaches the sink without passing through the guard. That single edge is the bug.

Control flow answers "in what order can this run." The next question, "what value flows along this edge," is data flow, and it deserves its own treatment; this post stays on order of execution. If you want the reachability angle, whether a risky line can be reached at all under real conditions, see our piece on reachability analysis. And the control flow graph is only one layer of the bigger structure analyzers build, the code property graph, which stitches control flow, data flow, and syntax into one queryable model.

Why this feeds better research

Reading a control flow graph is the difference between a scanner that pattern matches on text and a researcher that understands which routes actually run, a theme we keep returning to in scanners vs research. UnboundCompute is an autonomous researcher built to reason about exactly these paths, asking not just whether a check exists but whether every route reaches it. Our about page explains how we think.

Frequently asked questions

What is a control flow graph?

A control flow graph splits a function into basic blocks, which are runs of straight line code with no jumps in the middle, and connects them with edges for every branch, loop, and return. It models the order your code can run in, not the values it computes. Analyzers use it to reason about which routes through a function are possible.

What is a basic block?

A basic block is a straight line sequence of code with a single entry at the top and a single exit at the bottom. Execution runs every line in order with no branches in the middle. As soon as the code can go two ways, such as at an if or a loop test, the block ends and edges carry control to the possible next blocks.

Why do loops make paths unbounded?

A loop adds a back edge from the end of its body up to the loop test. If the loop can run zero, one, or many times, the number of distinct routes through the function grows without limit, so no analyzer lists them all. Instead tools approximate, summarizing what holds no matter how many times the loop spins.

What is the difference between a feasible and an infeasible path?

A feasible path is a route whose branch conditions can all hold at the same time, so it can actually run. An infeasible path exists in the graph but can never run, for example a route that requires a flag to be both true and false. Flagging a bug on an infeasible path produces a false positive, so telling the two apart matters.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.