all writing
security//3 min read

On Reading Code Like an Attacker

Two mental models for reading a codebase. One is what the code does. The other is where it fails.

Most code review is about correctness. Does it do what it's supposed to? Is it maintainable? Are there bugs? These are the right questions for most purposes. They are not the right questions if you want to know whether code is exploitable.

Exploitability is a different frame. It asks: given that something goes wrong, how bad can it get? That question points at different parts of the code than correctness does.

Two Reading Modes

Correctness reading follows the happy path. You trace the call stack from the entry point, verify that data flows correctly, check edge cases the developer might have missed, confirm that the output matches the intent.

Attack reading looks for where the model breaks. Where does the code make an assumption about its inputs? Where does control flow depend on state that an external actor can influence? Where does a type boundary get crossed without validation?

These modes highlight different lines. A function that does exactly what it says it does is fine under correctness reading. Under attack reading, the interesting question is who calls it and with what.

Following Trust

The most reliable attack-reading heuristic: follow the trust boundary.

Every system has a line where external data becomes internal state. That line is where most vulnerabilities live. Not because the code on the other side is wrong, but because the transition was trusted without verification.

def process_upload(request):
    filename = request.form['filename']
    path = os.path.join(UPLOAD_DIR, filename)
    # everything from here is "internal"
    with open(path, 'wb') as f:
        f.write(request.data)

Correctness reading says: saves the upload to a file. Attack reading says: filename comes from the user, os.path.join does not strip ../, and you can write to any path the process can reach.

The code is not wrong in an obvious sense. The trust boundary is in the wrong place.

Reading for Assumptions

Every function has a mental model of its inputs. Find that model, then find where it breaks.

The model is often implicit. It lives in the parameter names, the type annotations, the validation code (or the absence of it), and the error handling. Read all of those as documentation of what the developer expected, then ask what happens when those expectations are violated.

func parseAge(s string) int {
    n, _ := strconv.Atoi(s)
    return n
}

The developer's model: s is a string representation of a small non-negative integer. The function ignores the error, returns zero on failure, and has no upper bound. None of that is the developer's fault; it's a utility function. But downstream, if parseAge feeds an index or an allocation size, the model matters.

What This Doesn't Mean

Attack reading is not paranoia. Not every implicit assumption is a vulnerability. Not every trust boundary crossing is exploitable.

The skill is knowing when the stakes are high enough to matter. Input that feeds a log message is less interesting than input that feeds a query. A broken assumption in a test fixture is less interesting than a broken assumption in an auth handler.

Read the code like an attacker at the surfaces that actually matter. Spend the rest of the time reading for correctness.

Practical Habit

When reviewing unfamiliar code, I do one pass for orientation (what does this do?) and one pass for attack surface (where does external data go?). The second pass follows data backward from the interesting sinks: file operations, network calls, subprocess invocations, memory allocations, anything that affects state visible outside the process.

Most code is fine. The pass is fast. When something pulls at your attention, it usually deserves it.