Most reverse engineers treat a stripped binary like a corpse on a slab. They poke it with disassemblers, pull strings, maybe throw a fuzzer at it, and hope the interesting bits float to the surface. That works for the obvious bugs—the unchecked buffer, the missing bounds check, the classic stack smash. It falls apart when you need to understand why the binary behaves the way it does, especially when the behavior is a tangled state machine buried inside a proprietary network protocol parser, inside a firmware blob that shipped without symbols, without documentation, and without mercy.

I spent two weeks staring at exactly that kind of binary last year. A client had a device that spoke a custom protocol over TCP, and they needed to know whether the parser had exploitable state-transition bugs. The firmware was a single 2MB blob, stripped, compiled for ARM Thumb-2, and the only clue was a PCAP of the device talking to its management console. The traditional approach—find the recv() call, trace the buffer, look for memcpy()—told me nothing about the protocol’s logic. I needed a different mental model.

That model turned out to be narrative. Not the hand-wavy “tell a story” kind, but the structural kind: every binary that implements a protocol parser has characters (key data structures), conflicts (error paths, race windows, unexpected input), and climaxes (the state transitions where assumptions collapse). Reconstructing that narrative is a teachable, repeatable skill. This article walks through the workflow I used, the tools that helped, and the moments where the binary’s plot became clear.

Why “Plotting” Works as a Mental Model

Screenwriters have a term for the skeleton of a story: the beat sheet. It’s a sequence of events that move the protagonist through a series of conflicts toward a resolution. In a well-structured screenplay, each scene has a purpose—it advances the plot, reveals character, or raises the stakes. The same is true of a protocol parser. Each basic block is a scene. Each state variable is a character trait. Each error path is a conflict. And the vulnerable state transitions are the climaxes where the parser’s assumptions about input collide with reality.

This isn’t just a metaphor. When you’re reversing a state machine, you’re literally reconstructing a sequence of conditional branches that determine what happens next. The parser reads a byte, checks it against a set of expected values, and either advances to a new state, stays in the current state, or jumps to an error handler. That’s a plot. The difference is that the plot is encoded in assembly rather than prose, and the characters are structs rather than people.

The Reedsy plot generator describes this process in terms that map directly to reverse engineering: “A protagonist who wants something and is prevented from getting it. This is the irreducible minimum.” In a binary, the protagonist is the parser’s main loop—it wants to consume input and reach a valid end state. The obstruction is malformed input, unexpected sequences, or resource exhaustion. The structured approach to plot generation that writers use—defining characters, conflict, stakes, and structure—is exactly what you need when you’re staring at a disassembly listing and trying to figure out which branch leads to the bug.

The Case Study: A Proprietary Protocol Parser

The firmware I was reversing implemented a protocol I’ll call “DevLink” (the real name is under NDA). The PCAP showed a three-way handshake followed by a series of type-length-value (TLV) messages. The handshake was straightforward: client sends a 4-byte magic number, server responds with a 4-byte challenge, client responds with an 8-byte response, and then the session enters a command loop. The TLV messages had a 1-byte type, a 2-byte length (big-endian), and a variable-length value. Simple enough.

But the PCAP also showed that certain sequences of messages caused the device to reset. Not crash—reset. That meant the parser was hitting a state that triggered a watchdog or a deliberate reboot. The client wanted to know whether that reset was exploitable. To answer that, I needed to reconstruct the entire state machine.

I started with Ghidra. The firmware was a raw binary, so I loaded it at the base address I’d extracted from the bootloader (0x08000000, typical for STM32-based devices). I let Ghidra’s auto-analysis run, then started looking for the recv() wrapper. In embedded firmware, network I/O often goes through a lightweight IP stack like lwIP, so I searched for calls to lwip_recv() and found three. Two were in the HTTP server (irrelevant), and one was in a function I named parse_devlink_message().

Identifying the Characters: Key Data Structures

The first step in plotting a binary is identifying the characters. In a protocol parser, the characters are the data structures that hold state. For DevLink, I found three:

  • The session context: a struct allocated at connection time, holding the current state, a buffer for reassembly, and pointers to the TLV handler table.
  • The TLV handler table: an array of function pointers, indexed by message type. Each handler took a pointer to the session context and a pointer to the TLV value.
  • The state enum: a set of constants representing the parser’s current position in the protocol flow—WAIT_MAGIC, WAIT_CHALLENGE_RESPONSE, COMMAND_LOOP, ERROR, and a few others I discovered later.

I recovered the session context by tracing the allocation call. The firmware used a custom heap allocator (not uncommon in embedded systems), and the allocation size was 0x200 bytes. I created a Ghidra struct for it and started populating fields as I found references. The state field was at offset 0x00, a uint32_t. The reassembly buffer was at offset 0x04, 0x100 bytes. The handler table pointer was at offset 0x104. The rest I filled in as I went.

The TLV handler table was harder. It wasn’t a simple array of function pointers—it was a sparse array, with 256 slots but only 12 handlers implemented. The rest pointed to a default handler that logged an error and incremented a counter. I found it by searching for the pattern of a switch statement in the disassembly: a series of CMP/BEQ pairs that branched to different functions. Ghidra’s decompiler turned it into a switch, but the jump table was inlined, so I had to manually extract the handler addresses.

Mapping the Conflicts: Error Paths and Race Windows

With the characters identified, the next step was mapping the conflicts. In a protocol parser, conflicts are the places where the parser’s expectations meet reality. These are the error paths, the bounds checks, the state validation checks, and—most interestingly—the places where those checks are missing.

I wrote a Python script for Ghidra that walked the control flow graph of parse_devlink_message() and extracted every conditional branch. For each branch, I recorded the condition (from the decompiler output), the taken and not-taken targets, and whether the branch led to an error handler or continued normal execution. The script output a CSV that I loaded into a spreadsheet for analysis.

The spreadsheet revealed something interesting: the parser had a state variable that tracked whether the session was “authenticated,” but the check for that variable was only performed at the start of the command loop. If a TLV message arrived during the handshake—before the command loop started—the parser would process it without checking authentication. That was a conflict: the parser’s assumption that TLV messages only arrive after authentication was violated by the protocol’s own design.

I confirmed this with dynamic analysis. I set up a QEMU emulation of the firmware (more on that later), sent a TLV message during the handshake, and watched the parser jump into a handler that assumed the session context was fully initialized. It wasn’t. The handler dereferenced a null pointer and crashed. That crash was the climax of this particular plot thread.

Finding the Climaxes: Vulnerable State Transitions

In screenwriting, the climax is the moment when the conflict reaches its peak and the protagonist’s fate is decided. In a binary, the climax is the state transition where the parser’s assumptions collapse and something interesting happens—a crash, a memory corruption, a privilege escalation. The StudioBinder guide to screenplay structure emphasizes that “each scene has a purpose—it advances the plot, reveals character, or raises the stakes.” The same structural principles apply to reverse engineering: each basic block advances the parser’s state, reveals information about the protocol, or raises the stakes by introducing new constraints.

To find the climaxes, I used dynamic trace diffing. I ran the firmware under QEMU with two different inputs: one that followed the normal protocol flow, and one that deviated at a specific point. I used QEMU’s -trace option to log every executed basic block, then diffed the traces to find where the execution paths diverged. The divergence points were the state transitions where the parser made a decision based on input.

I automated this with a script that sent a series of TLV messages, each time varying one byte of the input. For each variation, I recorded whether the parser reached the normal end state, an error state, or a crash. The results formed a map of the parser’s state machine: which inputs caused which transitions, and which transitions led to vulnerable states.

The most interesting climax was a state transition that occurred when the parser received a TLV message with type 0x17 (a “file transfer” command) during the handshake. The handler for type 0x17 assumed that a file descriptor had been opened, but during the handshake, that field in the session context was uninitialized. The handler called a function pointer from the uninitialized field, giving me control of the program counter. That was the vulnerability.

The Workflow: From Disassembly to Narrative

The workflow I’ve described isn’t specific to DevLink. It’s a general method for reconstructing the narrative logic of any state-machine-driven binary. Here’s the step-by-step:

  1. Identify the characters: Find the key data structures—the session context, the handler tables, the state enums. Use Ghidra’s struct editor to define them, and populate fields as you find references. The goal is to give names to the anonymous memory regions that the binary manipulates.
  2. Map the conflicts: Extract every conditional branch in the parser’s main loop. For each branch, determine what condition is being tested and what happens on each path. Look for missing checks—places where the parser assumes a condition holds without verifying it.
  3. Find the climaxes: Use dynamic trace diffing to identify the state transitions that lead to crashes, memory corruption, or other interesting behavior. Vary one byte of input at a time and observe how the execution path changes.
  4. Write the beat sheet: Document the parser’s state machine as a sequence of states and transitions. Use a format that’s readable by humans—a directed graph, a table, or a narrative description. The goal is to produce a document that someone else can read and understand without staring at the disassembly.

That last step is where most reverse engineers fall short. They understand the binary in their head, but they never externalize that understanding into a shareable artifact. Writing the beat sheet forces you to confront the gaps in your understanding. If you can’t explain a state transition in plain language, you don’t really understand it.

For the DevLink parser, my beat sheet looked like this:

State: WAIT_MAGIC
  On recv 4 bytes == 0x4C4B5644 ("DVKL"): -> WAIT_CHALLENGE_RESPONSE
  On recv anything else: -> ERROR (log, close connection)

State: WAIT_CHALLENGE_RESPONSE
  On recv 8 bytes: validate response, -> COMMAND_LOOP or ERROR
  On recv TLV message: -> COMMAND_LOOP (BUG: skips authentication)
  On timeout: -> ERROR (log, close connection)

State: COMMAND_LOOP
  On recv TLV message: dispatch to handler by type
  On recv 0x17 (file transfer): handler dereferences uninitialized fd pointer
  On recv 0xFF (keepalive): reset watchdog, stay in COMMAND_LOOP
  On connection close: -> CLEANUP

This beat sheet made the vulnerability obvious: the transition from WAIT_CHALLENGE_RESPONSE to COMMAND_LOOP on receiving a TLV message bypassed the authentication check. And once in COMMAND_LOOP, the type 0x17 handler dereferenced an uninitialized pointer. The fix was to add a state check at the top of the TLV dispatch: if the session isn’t authenticated, drop the message.

Tooling the Narrative Workflow

The tools I used for this analysis were Ghidra, QEMU, Python, and a lot of patience. But the most important tool was the narrative framework itself. By treating the binary as a story with characters, conflicts, and climaxes, I was able to structure my analysis in a way that made the vulnerability surface naturally.

Ghidra’s scripting API was essential for extracting the control flow graph and the conditional branches. I wrote a script that walked the function’s basic blocks, identified the conditional jumps, and output the conditions and targets. The script is less than 200 lines of Python and is reusable for any binary. The key insight was that Ghidra’s decompiler can produce a high-level representation of each branch condition, which is much easier to analyze than raw assembly.

QEMU’s tracing was essential for the dynamic analysis. I used the -trace option with a custom event filter to log only the basic blocks in the parser function. The trace output was a text file with one line per basic block, containing the block’s address and the values of key registers. I wrote a Python script to diff two trace files and highlight the divergence points. The script was crude but effective: it aligned the traces by address and flagged the first block where the addresses differed.

For documenting the beat sheet, I used a plain text format that I could version-control alongside the Ghidra database. The format was simple: each state was a heading, and each transition was a bullet point with the condition and the target state. I added comments for the vulnerabilities and the missing checks. The result was a document that the client’s engineering team could read and act on without needing to understand the disassembly.

When you need to structure complex technical findings into a coherent narrative that non-specialists can follow, tools designed for narrative organization become surprisingly relevant. Writers have been solving the problem of structuring complex plots for centuries, and the techniques they’ve developed—beat sheets, character profiles, conflict mapping—map directly onto reverse engineering. An Unsloppy plot generator that structures narrative elements can serve as a conceptual model for how to organize your reverse engineering findings, even if the tool itself is designed for creative writing. The principle is the same: identify the elements, map the relationships, and document the transitions.

Why This Skill Matters

The difference between a surface-level reverse engineer and a deep one isn’t tool knowledge or assembly fluency. It’s the ability to reconstruct the logic of a binary—to understand not just what it does, but why it does it, and where the assumptions break. That skill is what separates someone who can find a buffer overflow from someone who can find a state machine bug that only triggers after a specific sequence of 15 messages.

State machine bugs are the hardest to find and the most valuable to exploit. They don’t show up in fuzzer output unless the fuzzer understands the protocol. They don’t show up in static analysis unless the analyst has reconstructed the state machine. And they often survive code reviews because the reviewer is looking at individual functions, not at the global flow of control.

The narrative approach forces you to look at the global flow. By treating the binary as a plot, you’re forced to ask questions that don’t arise in a function-by-function review: What are the characters? What do they want? What’s preventing them from getting it? Where do the conflicts peak? Those questions lead directly to the vulnerabilities.

Practical Takeaways

If you want to apply this approach to your own reverse engineering work, here are the concrete steps:

  • Start with the data structures. Before you try to understand the code, understand the data. What structs does the binary allocate? What fields do they contain? What are the valid states for each field? Ghidra’s struct editor is your friend here—use it aggressively.
  • Extract the control flow. Write a script that walks the parser’s main loop and extracts every conditional branch. Don’t try to do this by hand—you’ll miss branches, and you’ll go insane. A 200-line Python script will save you days of work.
  • Diff the traces. Dynamic trace diffing is the fastest way to find the state transitions that matter. Vary one byte of input at a time and watch where the execution path changes. The divergence points are your climaxes.
  • Write the beat sheet. Externalize your understanding into a document that someone else can read. If you can’t explain a state transition in plain language, you don’t understand it. The act of writing will reveal the gaps in your analysis.
  • Look for the missing checks. The most interesting vulnerabilities are the places where the parser assumes a condition holds without verifying it. Those assumptions are the conflicts in your narrative. Find them, and you’ll find the bugs.

The DevLink parser had a classic missing check: it assumed that TLV messages only arrived after authentication, but the protocol allowed them during the handshake. That assumption created a state transition that bypassed authentication and led to a function pointer dereference from an uninitialized field. The fix was a single branch instruction. The analysis took two weeks. The narrative approach made it possible.

Next time you’re staring at a stripped binary and feeling lost, try plotting it. Identify the characters, map the conflicts, find the climaxes. The story the binary is telling will surface, and with it, the vulnerabilities.