DEV Community

aaannvv
aaannvv

Posted on

No Lab, No Exploit Server: Proving a Bug From Source Alone

You have the code. You don't have the target. Maybe the product only exists inside enterprises you have no permission to touch. Maybe the finding is still unreported, and standing up a full exploit environment means manufacturing artifacts — payloads, captured screenshots, exfiltrated rows — that become liabilities the moment they exist. Maybe it's plainer than that: a faithful environment for this bug means a BI platform deployment with an Elasticsearch cluster behind it, three evenings of plumbing to screenshot something the code already tells you. Here is the thing most auditors learn late: for the largest class of findings, you don't need any of it.

The question a finding actually turns on is usually narrow: does this construction, given hostile input, emit a query fragment with injected semantics — yes or no? You can answer it without a laboratory, in two moves: close the chain statically until every link is a citable claim, then run a small local simulation that turns those claims into output a maintainer can re-run. The worked example throughout is public — an identifier-position SQL injection in DataEase, an open-source BI platform, at tags v2.10.28 and v3.2.1.

Method one: close the chain before you claim anything

A dangerous function is not a vulnerability; a chain is. "Closed" means every link between attacker input and the dangerous construction is written down with code-level evidence at a pinned release tag. A walk ending at "this value is probably used somewhere in the query builder" has produced a lead — and leads are cheap precisely because they aren't checkable claims.

The biggest decision is the direction of the walk, and public write-ups teach it backwards. They show you the dangerous function because that's where the story's climax sits, but as a method, sink-first grep is a noise machine: search a mature middleware project for Runtime.exec or readObject and you get four hundred hits, almost all guarded or unreachable, each demanding expensive reachability analysis to dismiss. The grep returns in seconds and costs days.

Walk the other way. The route table is the product's own confession of where input enters, and every framework declares it greppably:

# Spring-family controllers + authorization annotations
rg -n "@(Request|Get|Post|Put|Delete|Patch)Mapping" --type java

# Flask / FastAPI registrations
rg -n "@app\.(route|get|post)|@router\." --type py
Enter fullscreen mode Exit fullscreen mode

Keep that inventory enriched with three properties: the gate (what authorization costs to reach the handler — in declarative codebases it's written next to the mapping), the input shape (scalar, structured, or opaque), and the handler, pinned at a tag. The row that pays: a low-gate endpoint accepting an opaque payload — uploaded, encoded, or template text that becomes structure later.

Then walk the data. Chains die in three habitual places:

  • The persistence breakpoint. The input's dataflow visibly ends at a repository call, and where it re-emerges — a selectById in another module — is connected by nothing grep can follow. Persistence is a relay, not a barrier: stored data is attacker input wearing a database's clothes. Audit the write face (what validation ran before storage?) and the read face (what construction runs after load?) as two independent walks.
  • The copy breakpoint. Mappers and copyProperties preserve data, so the walk continues on the target field. A transformation — replace, strip, encode — must be classified first, and the question isn't "did the value change" but "did the characters I care about survive." If the attack rides on a backtick and the transformation strips whitespace, the backtick survives.
  • The framework breakpoint. Your readable source stops at the ORM or template engine's edge. When the framework is open source, keep walking into it at the version the lockfile names. An auditor who has never read Jackson's binding code will misprice chains that live there.

The DataEase chain, compressed to skeleton — four links, each pinned:

  1. Write face. Field definitions are saved via an endpoint wearing no authorization annotation — in a codebase that restricts by declaration, that means any authenticated user. The service runs one check, a duplicate-name check. originName is stored with no character validation. Classify every check by the question it answers: "is this unique?" says nothing about "is this safe to interpolate into SQL?"
  2. The relay. On chart render, the stored field is loaded with selectById and copied verbatim — no sanitization between the database read and the query construction. Nothing "happens" here, and it matters anyway: it closes the argument that the value at the sink is the attacker's value.
  3. The trigger. The construction fires through an endpoint authorized by public share tokens — self-authorizing, no session. Plant once as a low-privilege user; the detonation half is pre-auth.
  4. The molding. The stored name lands in the identifier slot of a format string — "%s.%s" (sdk/common/SQLConstants.java, line 48, at both tags). A name containing a backtick collides with the builder's own quoting; everything after it escapes into raw SQL.

That last link carries the detail that makes the family click: positional analysis. User-controlled values land in literal positions (inside quotes) or identifier positions (naming the column, table, or sort key). Prepared statements protect literals by construction and cannot protect identifiers, because bind parameters never stand for identifiers — no database accepts SELECT ? FROM t with a table name as the bind. That is the structural reason identifier injection survives in codebases whose literal handling is clean. In DataEase, the literal side of this very construction had already been fixed — the public advisory (CVE-2026-123990 / GHSA-54vg-4hhr-cfwh) reads exactly that way — while the field name rode the identifier slot with nothing in front of it.

On paper, the chain is closed: every link is a paragraph of the report, already banked. And paper — in a triage queue holding forty other reports — is still just an argument.

Method two: simulate the construction, not the deployment

Static-only reports starve for named reasons. A code citation makes the reader do the dataflow — a four-link chain asks them to perform twenty small verifications in their head. Citing code without demonstrating output acquires a vocabulary ("could allow," "may permit") that happens to be the scanner's register, and queues are calibrated to treat it as noise. And a triager deciding duplicate-or-new in ninety seconds resolves doubt against you.

The remedy is not the end-to-end exploit. For findings whose entire semantics live in an emitted string — SQL fragments before a deterministic parser, paths before the filesystem, URLs before a fetch, template text before an evaluator — a faithful local simulation of the construction is evidence aimed at the exact layer where the vulnerability lives. Not a weaker substitute: a more precise one, with a sharper version story. An end-to-end demo proves one deployment; a string-level proof pinned to a tag is a claim about the code, true of every deployment running it.

The tool is a small script with one job: make one construction emit its output under chosen inputs. Four rules keep its evidence admissible.

  1. Source from a CDN at a pinned tag. Fetch the real file from a mirror at an immutable tag (jsDelivr's gh/<org>/<repo>@<tag>/ works), so the transcribed artifact is byte-identical to what any reader can fetch. A script reading a local clone has no provenance.
  2. Transcription, checkable by diff. The script is not a reimplementation from memory — it transcribes the shipping code from input to sink, with comments carrying the original file and line. The reviewer's objection "your harness is not the code" is answered by protocol: fetch at the tag, diff, done.
  3. The control run. Push one benign input through the same construction and assert the ordinary, expected output. A broken script can appear to demonstrate injection while printing garbage; if benign input also yields broken SQL, the script is the vulnerability and the finding is unproven.
  4. The output is the artifact. What the script prints goes into the report verbatim, with the fetch and run commands. Evidence a maintainer can re-run in five minutes survives doubt — they are no longer believing you, they are running you.

A skeleton, in Node because a dependency-free file is the right shape for something meant to be read as much as run:

// sim.mjs — string-level simulation of one construction
const TAG  = "v2.10.28";                      // pin, never a branch
const FILE = "sdk/common/SQLConstants.java";
const res = await fetch(`https://cdn.jsdelivr.net/gh/<org>/<repo>@${TAG}/${FILE}`);
if (!res.ok) throw new Error(`provenance failed: ${res.status}`);

// Upstream (SQLConstants.java, line 48, tag v2.10.28):
//   public static final String FIELD_NAME = "%s.`%s`";
const FIELD_NAME = "%s.`%s`";

// Transcription of the branch under test — every line maps to the
// fetched source; String.format stands in for the Java-side assembly.
const esBranch = (alias, name, literal) =>
  `(( ${String.format(FIELD_NAME, alias, name)} = '${literal}' ))`;

// Control first: assert the benign construction before hostile ones.
const control = esBranch("t", "order", "AUDITED");
if (control !== "(( t.`order` = 'AUDITED' ))")
  throw new Error("CONTROL FAILED — transcription does not reproduce benign output");

// One character, one claim: backtick closes quoting; -- survives to truncate.
console.log("[escape]    ", esBranch("t", "order`--", "AUDITED"));
console.log("[injection] ", esBranch("t", "x` UNION SELECT 1--", "AUDITED"));
Enter fullscreen mode Exit fullscreen mode

Run against the real transcription, the escape case prints, verbatim:

((t_dsv_1696016823.`order`--` = 'AUDITED'))
Enter fullscreen mode Exit fullscreen mode

Read it slowly — both primitives land in eight tokens. The name's backtick closed the identifier quoting the builder opened; the builder's own closing backtick is stranded outside it. The -- survived into emitted SQL, commenting out that stranded backtick and the intended construction, so the filter value now sits inside a comment. The attacker, not the builder, decides where the SQL ends. The injection case carries UNION SELECT 1 past the escaped quoting as live SQL — injection semantics demonstrated at the construction's own layer, with no deployment, no traffic, no exfiltrated rows.

One design decision multiplies the artifact's value: run the neighbors in the same script. Pointed at the value side of the same construction at v3.2.1 — where a sanitizeSqlLiteral call guards the literal position — the same script shows hostile literals neutralized while hostile field names pass untouched. One output block, two facts: the finding is real, and the vendor's prior fix is honored. That sentence kills the duplicate stamp before it's raised, in the same format as the finding evidence, so the triager compares like with like.

Grades: state what the proof is, and what it is not

The discipline compresses to one table. Pick the grade before building evidence:

Finding class Where the vulnerability lives Terminal grade
SQL / identifier / template construction the emitted string simulation
Path assembly, URL assembly the emitted string simulation
Markup before a specific browser parser downstream parsing simulation, then live to close
Deserialization gadget chains classpath resolution simulation for reach, live for claim
Memory safety, races, logic states execution live, no substitute

Two honest moves keep it sharp. Declare the grade in the first paragraph, limits included — the template reads: string-level, construction-faithful, verified against both tags, and not an end-to-end demonstration against a live instance. The strongest sentence in a simulation-grade report is the one saying what it is not. And respect the ceilings: if the vulnerability lives in a specific parser, a classpath, or a race, the simulation carries you to the string and no further — say so, then climb or cap the claim. Credibility spent on overstatement is not refunded when your next report arrives.

The economics, briefly

One finding in this method runs about nine hours: three or four evenings of selection and static closure, one hour for the simulation, the balance in boundary work and writing. The simulation is the cheapest hour of the nine and the one that most changes how the other eight are received — it converts a ninety-second triage decision into a ninety-second verification.

There's a training loop here too. Once a CVE is fully disclosed, walk its chain backward through the layered fix, reading the fix as the chain's negative image — each fix layer names a link the maintainer believed in. ShowDoc's CVE-2026-120203, a template injection reaching eval() in an API-mock feature, shipped a three-layer fix — whitelist before storage, check before every forwarding pass, a client-side mirror — that reconstructs into the same write-face / relay / read-face / sink shape. Public case law is a free textbook if you read it as chains.

If you want the full procedure — ledger templates, the copy/transformation classification, the trust-boundary variant for non-HTTP surfaces, the automation layer that fires these checks across fleets — I wrote a field guide on this practice, available on Leanpub: Auditing Open-Components: A Practitioner's Field Guide.

Top comments (0)