Share this
Reading Code the Way an Attacker Would: An Introduction to Security Auditing and Program Analysis
by Reflare Research Team on Sep 21, 2026, 7:22:37 AM
Reviewing code for correctness and reviewing it for security are different jobs. The first asks what the program does with the input it expects. The second asks what it does with input chosen by someone who wants it to fail.
.jpg?width=1200&height=800&name=Reading%20Code%20the%20Way%20an%20Attacker%20Would%20(1200).jpg)
“Peek-a-boo.”
Every non-trivial program spends much of its life handling data it did not create. It parses unexpected requests, opens files whose contents it cannot vouch for, deserialises objects assembled by other machines, and reads the environment set by whoever launched the process. Some of that data may be influenced by an attacker; some may simply be malformed or surprising. Security auditing is the discipline of finding where hostile or unexpected behaviour can violate a security property, and program analysis is the body of theory and techniques that helps us do this systematically instead of by luck.
If you already write and read code for a living, you have most of the prerequisites. What this article adds is a small vocabulary, a specific mental model, and the habit of tracing data instead of only reading control flow. It introduces all three with code you can run in your head.
The core mental model: sources, sinks, and barriers
A large share of code-centric security auditing can be organised around one question: can attacker-controlled data reach a security-sensitive operation without an adequate, context-specific defence? To answer it, we assign three roles to values and operations in the program.
A source is a point where data enters from outside the trust boundary being analyzed: somewhere an attacker can influence what comes in. HTTP query parameters, request bodies, and headers are common sources. So are command-line arguments, environment variables, uploaded files, bytes from a network socket, messages from another process, and rows read from a database that an attacker could have poisoned earlier. None of these is automatically hostile; whether it is a source depends on the system's trust boundaries and threat model.
A sink is an operation whose security depends on receiving suitably constrained data. A function that executes SQL text is a sink. So is one that invokes a shell, inserts data into an executable browser context, opens a file by path, follows a URL, calls eval, deserialises bytes into live objects, or copies into a fixed-size buffer in a memory-unsafe language such as C. A sink is not inherently a bug; it is a place where a bug can occur.
A barrier is a sink-specific defence that blocks a dangerous interpretation or establishes that the value satisfies the required policy. Depending on the sink, that may be parameterisation, context-aware encoding, strict validation, an allowlist, a capability-safe API, or a structural design that keeps data and code separate.
A candidate vulnerability is a feasible path from a source to a sink on which the relevant security property is not enforced. Here is that path in a small form:
# VULNERABLE: Python / Flask from flask import request import sqlite3 def get_user(): username = request.args.get("username", "") # SOURCE conn = sqlite3.connect("app.db") query = "SELECT * FROM users WHERE name = '" + username + "'" # taint enters SQL text return conn.execute(query).fetchall() # SINK: SQL executed
request.args.get is the source, string concatenation carries the untrusted value into the SQL text, and conn.execute executes it. The path is strongly suspicious, but an audit still has to confirm details such as reachability, database behaviour, response handling, authorisation, and impact before stating the exact finding.
Confirming it: evidence and PoCs
A candidate path is a hypothesis. A proof of concept (PoC) is one strong form of evidence that can confirm the behaviour. For the snippet above, the decoded parameter value can be:
' OR '1'='1' --
A URL-encoded query string carrying that value is:
?username=%27%20OR%20%271%27%3D%271%27%20--
The server builds:
SELECT * FROM users WHERE name = '' OR '1'='1' --'
OR '1'='1' makes the predicate true, and SQLite treats -- as the start of a line comment, so the trailing quote is ignored. In this isolated example, the SQL query returns every row in users. If the endpoint exposes those rows to a caller who is not authorised to see them, the result is unauthorised data disclosure; if the same construction is used in an authentication lookup, it may also enable an authentication bypass.
A working PoC is compelling, but it is not the only acceptable evidence. Clear code-level reasoning, a regression test, a safe local reproduction, or a formal counterexample can be enough, and active exploitation may be unnecessary, unsafe, or outside the authorised scope. Use the least invasive evidence that establishes the issue. Either way, auditing becomes a concrete search: identify the security properties and trust boundaries, enumerate the relevant sources and sinks, find feasible paths, and gather enough evidence to assess the paths that matter.
Taint: following the data
The way human auditors and many tools reason about these paths is called taint analysis. Data from a source is marked tainted: untrusted for the property being checked. A typical explicit-flow analysis propagates that mark along modelled data dependencies: if x is tainted and the program assigns y = x, then y becomes tainted; concatenate a tainted string into a larger one and the result becomes tainted; pass tainted data into a function and an interprocedural analysis may propagate it into the callee. If tainted data reaches a sink without an adequate barrier, you have a candidate vulnerability.
Taint is an abstraction, not a runtime substance. Any analysis has to decide which assignments, calls, containers, fields, and implicit flows actually propagate it, and different analyses make different choices, so the same code can yield different results depending on what the analysis models.
In the first example the flow is obvious. Real code hides it across function boundaries, which is why manual review is tiring and why inter-procedural analysis matters:
def handler(): raw = request.args.get("q", "") # SOURCE return {"results": search(raw)} def search(term): sql = build_query(term) return db.execute(sql).fetchall() # SINK: the source is two calls away def build_query(term): return "SELECT id, title FROM docs WHERE title LIKE '%" + term + "%'"
Look only inside search and you see two local variables with no obvious source; an intra-procedural analysis confined to one function cannot establish that term came from an HTTP request. To see the bug you need the call graph: taint originates in handler, flows as an argument into search, flows again into build_query, becomes part of the SQL string, and returns to the sink. Many exploitable bugs have this shape: the source and the sink sit in different functions, files, or components, and only the data flow connects them.
Barriers and structural fixes: breaking the chain
Between a source and a sink, the program gets its chance to defend. A sanitiser transforms a value to make it safe for a particular use; a validator establishes that it belongs to an allowed set; an encoder represents it safely for a particular output context; and a structural fix stops data from being interpreted as code in the first place. Whatever the form, the guarantee is always sink-specific.
For SQL, the right fix is not to escape quotes by hand but to stop mixing data into SQL text at all. Parameterise the value:
# FIXED: the value is bound separately from the SQL text def get_user(): username = request.args.get("username", "") conn = sqlite3.connect("app.db") query = "SELECT * FROM users WHERE name = ?" return conn.execute(query, (username,)).fetchall()
Now username is passed as a bound value, so SQLite does not parse its characters as SQL syntax, and ' OR '1'='1' -- is looked up as a literal username. Note carefully what this does not do: it does not make username trusted for any other use, and placeholders bind values, not identifiers. Dynamic table names, column names, and sort directions cannot be parameterised this way and normally require selection from a strict allowlist.
That "for a particular use" is where an enormous amount of real-world security goes wrong. Encoding for an HTML text node does nothing for SQL and is not automatically safe in a JavaScript, CSS, URL, or HTML-attribute context. Parameterising a query defeats SQL injection but leaves the same string just as dangerous if it is later handed to a shell. That is a different sink with a different structural fix:
// VULNERABLE: Node.js command injection on a POSIX-like system
const { exec } = require("node:child_process");
app.get("/ping", (req, res) => {
const host = req.query.host; // SOURCE
exec(`ping -c 1 ${host}`, (error, stdout) => { // SINK: shell
if (error) return res.status(502).send("ping failed");
res.type("text/plain").send(stdout);
});
});
PoC: ?host=127.0.0.1%3Bid decodes to host = "127.0.0.1;id", producing the shell command ping -c 1 127.0.0.1;id, so the shell runs id after the ping. A safer fix for a POSIX-like endpoint that accepts IPv4-address literals removes the shell, passes arguments structurally, validates the input syntax, and sets an execution timeout:
// SAFER: for an endpoint that accepts IPv4 literals only
const { execFile } = require("node:child_process");
const net = require("node:net");
app.get("/ping", (req, res) => {
const host = req.query.host;
if (typeof host !== "string" || !net.isIPv4(host)) {
return res.status(400).send("invalid IPv4 address");
}
execFile(
"ping",
["-c", "1", host],
{ shell: false, timeout: 5_000 },
(error, stdout) => {
if (error) return res.status(502).send("ping failed");
res.type("text/plain").send(stdout);
},
);
});
Avoiding the shell prevents shell metacharacters in host from being interpreted as shell syntax. Validation still matters: a structured process API does not, by itself, prevent option injection, dangerous behaviour in the child program, requests to forbidden targets, or resource abuse. In this example, net.isIPv4 rejects option-like strings, but a real service still needs a destination policy: for example, whether loopback, private, link-local, multicast, or broadcast addresses are allowed.
Many exploitable bugs are not simply "no defence." They are "the wrong defence," "the right defence on only some paths," "a validator whose accepted set is broader than intended," "a parser mismatch between two components," or "sanitise, then reintroduce the danger downstream." When you audit, ask whether the required property holds on every feasible path reaching that particular sink.
The same source → sink → barrier pattern covers many vulnerability classes; only the operands change:
| Vulnerability | Typical source | Security-sensitive sink | Typical primary defence |
|---|---|---|---|
| SQL injection | Request parameter | SQL-text argument | Parameterised values; allowlist dynamic identifiers |
| Command injection | Request parameter | Shell invocation / process construction | Fixed executable; no shell; argument vector; validate options and targets |
| Cross-site scripting | Request or stored content | HTML, DOM, JS, CSS, or URL context | Context-aware encoding; safe DOM APIs; sanitise intentional HTML |
| Path traversal | Filename / path parameter | File operation | Prefer opaque IDs; otherwise use constrained filesystem APIs, verify containment, and handle symlinks and races |
| Server-side request forgery | URL / host parameter | Outbound network client | Allowlist scheme and destination; revalidate redirects and resolved addresses; enforce egress controls |
| Insecure deserialisation | Request body / message | Native object deserialiser | Avoid for untrusted data; data-only formats; constrain types |
This model is powerful, but it is not a complete theory of security. Broken authorisation, business-logic flaws, cryptographic misuse, race conditions, side channels, resource exhaustion, insecure defaults, dependency risk, and deployment misconfiguration often do not reduce to a tainted value reaching a sink. A good audit uses source-and-sink reasoning where it fits and switches models when the property demands it.
How the machine reasons
Strip a simple explicit-flow taint tracker down to its core and it does four things: it marks values from sources, propagates those marks along modelled data flows, blocks or refines them at modelled barriers, and reports when a marked value reaches a sink. In language-neutral pseudocode:
tainted = set() # values currently considered untrusted
for stmt in program, in an abstract flow order:
if stmt is `lhs = rhs`:
if is_barrier(rhs):
tainted.discard(lhs) # the modeled property now holds for lhs
elif is_source(rhs) or any(v in tainted for v in reads(rhs)):
tainted.add(lhs) # taint propagates
else:
tainted.discard(lhs) # a clean reassignment overwrites old taint
if is_sink(stmt) and any(v in tainted for v in sink_inputs(stmt)):
report(stmt) # candidate: tainted value reaches a sink
That sketch is deliberately naive, and the gap between it and a usable analysis is where the real engineering lives: branches and loops, aliasing and shared references, struct and object fields, values flowing through collections, implicit flows where a tainted value steers a branch that sets another value, framework APIs whose behaviour is not visible in the code, and propagation across function boundaries, as the two-call example showed. Real analyses must also decide what counts as a sink input: an argument, a receiver object, a property, or even a control decision.
The machinery underneath tries to model those problems. A control-flow graph represents what may execute after what. Data-flow analysis approximates which values or definitions can reach a given program point. A call graph approximates which functions can call which and supports interprocedural analysis. Analyses also trade precision against cost: a flow-sensitive analysis respects statement order; a path-sensitive one keeps branch conditions separate instead of immediately merging them; a context-sensitive one distinguishes different calling contexts of the same function. Greater sensitivity can reduce spurious results, but it usually costs more time and memory and does not guarantee exact answers for arbitrary programs.
Static versus dynamic analysis
There are two broad families, and mature auditing uses both.
Static analysis examines source, bytecode, or binaries without executing the target on a particular concrete input. It can inspect paths that tests never exercise and reason about families of executions at once. It does not automatically cover every possible execution: unsupported language features, missing library models, deliberate approximations, and time or memory limits all bound what it sees. It reads the map without taking one particular journey.
Dynamic analysis does the opposite: it runs the program and observes concrete executions. Instrumentation, runtime assertions, sanitisers, tracing, and fuzzing, which automatically generates or mutates inputs to exercise behaviour and trigger failures, all live here. A coverage-guided fuzz target can be tiny; you hand it the fuzzer's bytes and let runtime checks turn memory errors on executed paths into visible failures:
// Coverage-guided fuzz target. Example build (Clang toolchain): // clang -g -O1 -fsanitize=fuzzer,address harness.c parser.c -o fuzz_parser #include <stddef.h> #include <stdint.h> extern int parse_packet(const uint8_t *data, size_t len); // target under test int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { parse_packet(data, size); return 0; }
The fuzzer mutates inputs to reach new behaviour; crashes, sanitiser reports, failed assertions, timeouts, and leaks can expose bugs. A good harness is fast, deterministic, tolerant of arbitrary input, and careful to reset or avoid persistent state that would make results depend on test order. Dynamic evidence is concrete for the runs observed, but paths never exercised remain unknown, and some failures surface only when an oracle recognises an incorrect result. Static analysis suggests where problems may exist; dynamic analysis shows what happened on particular executions; neither should be mistaken for complete coverage.
Two techniques blur the line. Symbolic execution explores paths with symbolic rather than concrete inputs, accumulates the logical constraints along each path, and asks a solver for an input that satisfies them. For an integer x, the condition if (x > 10 && x < 20 && x % 7 == 0) crash(); admits x == 14 as a concrete witness. Practical limits include path explosion, complex environments and system interactions, unsupported operations, and solver capacity. Concolic execution interleaves concrete runs with symbolic reasoning to steer towards new paths.
The unavoidable trade-off: decidability, soundness, precision
Here is the fact that shapes the field. Rice's theorem says that every non-trivial property that depends only on the function or language computed by an arbitrary program is undecidable in general. A general-purpose analyser must therefore approximate, restrict the language or property, bound its search, rely on specifications, or sometimes answer "unknown." The chosen approximation is one important determinant of how the analysis can fail.
Two independent questions matter. First, does the analysis cover every real instance within its stated scope, so that it has no false negatives? A sound over-approximation aims for that guarantee, but infeasible behaviour can then appear as false positives: "if I cannot rule it out, I will report it." Second, does every report correspond to a real instance, so that there are no false positives? Under-approximation or a required concrete witness can improve that guarantee, but may miss real bugs: "everything I report is real, but I may stay silent." The words sound and complete are overloaded across logic, verification, and bug finding, so the important question is the guarantee an analysis actually states. In practice, unsupported constructs, incomplete models, heuristics, and timeouts can undermine either guarantee.
Practical analysers make separate trade-offs in coverage, report accuracy, scalability, and required specifications. Developer-facing bug finders usually prioritise a manageable false-positive rate and accept some misses, because engineers stop trusting a scanner that cries wolf. Verification-oriented analyses often prioritise conservative guarantees and accept unresolved proof obligations or noisy reports. The operational lesson is blunt: a clean run does not mean the code is safe. It means that this analysis, with this configuration, model, scope, and budget, found nothing reportable. False-positive fatigue is real, and triage discipline matters as much as the scanner.
What a real audit looks like
Putting it together, an audit follows a rhythm. Start by stating the security properties, assets, trust boundaries, and attacker capabilities. Map the entry points and the privileged operations. Enumerate the likely sources and sinks, often by searching for security-sensitive APIs: eval, shell execution, native deserialisation, raw SQL construction, unsafe HTML insertion, file operations, URL fetches, and memory-unsafe copy functions.
For each dangerous sink, trace backward: can attacker-influenced data reach it, under what conditions, and does every feasible path enforce the right property? For each important source, trace forward to where the data is stored, transformed, authorised, rendered, executed, or discarded. Review authorisation and state transitions explicitly rather than assuming taint tracking will capture them.
Combine manual review with tooling suited to the job: pattern scanners for broad passes, data-flow engines for cross-function tracing, language-specific linters, dependency and configuration analysis, and fuzzers aimed at parsers, protocol handlers, and native code. Confirm impact with the least invasive evidence that is sufficient and authorised. A code path may already justify a finding, and a PoC earns its keep only when exploitability or severity is genuinely uncertain and testing it is safe and in scope.
Authorisation is not a footnote. Test only systems and data you have explicit permission to test, stay within the agreed scope, and avoid unnecessary impact. Professional security work is defined not only by technical skill but by consent, restraint, and accurate reporting.
The mindset
Program analysis is, in the end, the practice of reasoning about what a program might do, with special attention to behaviour its authors never intended. Source-and-sink reasoning scales from a quick search for dangerous APIs to sophisticated information-flow analyses: ask where untrusted data originates, how it propagates, which policy must hold at the destination, and whether every feasible path enforces it. It is one model among several, not a substitute for reasoning about authorisation, cryptography, concurrency, availability, or system design. Learn it well, learn its limits, and tools become what they should be: accelerators for reasoning you could, in principle, do by hand.
From here, make it concrete. Take the vulnerable snippets above and reproduce the PoCs against a deliberately vulnerable application you are authorised to test. Then implement a deliberately limited taint checker over a small abstract syntax tree or intermediate representation, using the pseudocode above as a guide, and compare its results with the flow you traced by hand. Read the CWE catalogue to see how recurring weaknesses are named and related. The theory sticks once you have followed one real issue from trust boundary to security impact and then detected it again automatically.
Share this
- August 2026 (1)
- June 2026 (1)
- May 2026 (1)
- April 2026 (1)
- March 2026 (1)
- February 2026 (1)
- January 2026 (1)
- December 2025 (1)
- November 2025 (1)
- October 2025 (1)
- September 2025 (1)
- August 2025 (1)
- July 2025 (1)
- June 2025 (1)
- May 2025 (1)
- April 2025 (1)
- March 2025 (1)
- February 2025 (1)
- January 2025 (1)
- December 2024 (1)
- November 2024 (1)
- October 2024 (1)
- September 2024 (1)
- August 2024 (1)
- July 2024 (1)
- June 2024 (1)
- April 2024 (2)
- February 2024 (1)
- January 2024 (1)
- December 2023 (1)
- November 2023 (1)
- October 2023 (1)
- September 2023 (1)
- August 2023 (1)
- July 2023 (1)
- June 2023 (2)
- May 2023 (2)
- April 2023 (3)
- March 2023 (4)
- February 2023 (3)
- January 2023 (5)
- December 2022 (1)
- November 2022 (2)
- October 2022 (1)
- September 2022 (11)
- August 2022 (5)
- July 2022 (1)
- May 2022 (3)
- April 2022 (1)
- February 2022 (4)
- January 2022 (3)
- December 2021 (2)
- November 2021 (3)
- October 2021 (2)
- September 2021 (1)
- August 2021 (1)
- June 2021 (1)
- May 2021 (14)
- February 2021 (1)
- October 2020 (1)
- September 2020 (1)
- July 2020 (1)
- June 2020 (1)
- May 2020 (1)
- April 2020 (2)
- March 2020 (1)
- February 2020 (1)
- January 2020 (3)
- December 2019 (1)
- November 2019 (2)
- October 2019 (3)
- September 2019 (5)
- August 2019 (2)
- July 2019 (3)
- June 2019 (3)
- May 2019 (2)
- April 2019 (3)
- March 2019 (2)
- February 2019 (3)
- January 2019 (1)
- December 2018 (3)
- November 2018 (5)
- October 2018 (4)
- September 2018 (3)
- August 2018 (3)
- July 2018 (4)
- June 2018 (4)
- May 2018 (2)
- April 2018 (4)
- March 2018 (5)
- February 2018 (3)
- January 2018 (3)
- December 2017 (2)
- November 2017 (4)
- October 2017 (3)
- September 2017 (5)
- August 2017 (3)
- July 2017 (3)
- June 2017 (4)
- May 2017 (4)
- April 2017 (2)
- March 2017 (4)
- February 2017 (2)
- January 2017 (1)
- December 2016 (1)
- November 2016 (4)
- October 2016 (2)
- September 2016 (4)
- August 2016 (5)
- July 2016 (3)
- June 2016 (5)
- May 2016 (3)
- April 2016 (4)
- March 2016 (5)
- February 2016 (4)


