<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Keir's Notes]]></title><description><![CDATA[Keir's Notes]]></description><link>https://keirsalterego.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/67a1a5dea3a6d0d61bef4b3c/b9e45942-c003-4822-9064-cd05efcd999d.jpg</url><title>Keir&apos;s Notes</title><link>https://keirsalterego.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 21 Sep 2026 19:39:45 GMT</lastBuildDate><atom:link href="https://keirsalterego.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Every bug was in a sentence I had already written]]></title><description><![CDATA[stranger reads a project's lockfiles off disk and tells you which of its
dependencies you have never met. Hallucinated names, code that runs at install
time, the same package resolved at four versions]]></description><link>https://keirsalterego.hashnode.dev/every-bug-was-in-a-sentence-i-had-already-written</link><guid isPermaLink="true">https://keirsalterego.hashnode.dev/every-bug-was-in-a-sentence-i-had-already-written</guid><category><![CDATA[Rust]]></category><category><![CDATA[Security]]></category><category><![CDATA[Testing]]></category><category><![CDATA[OpenSource Journey]]></category><category><![CDATA[stranger]]></category><category><![CDATA[hackathon]]></category><dc:creator><![CDATA[Manish]]></dc:creator><pubDate>Thu, 03 Sep 2026 18:13:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67a1a5dea3a6d0d61bef4b3c/473d2f13-700c-4b3a-b73e-a6215e747b4d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><code>stranger</code> reads a project's lockfiles off disk and tells you which of its
dependencies you have never met. Hallucinated names, code that runs at install
time, the same package resolved at four versions. It installs nothing, resolves
nothing, and makes no network request. It was written for the Zero Dependency
hackathon in Rust with the standard library and nothing else, so <code>cargo tree</code> is
one line and <code>Cargo.lock</code> holds exactly one <code>[[package]]</code> block.</p>
<p>The premise of the tool is that a lockfile is a document written by strangers and
you should not take its word for anything.</p>
<p>I found five bugs in three days. Every one of them was in a place where I had
taken my own word for something. Not in the code I had not got to yet, but in the
sentences I had already written down as true, in doc comments and design notes
and a test name. That is the post. The tool is on GitHub. The interesting part is
the five.</p>
<h2>1. A lockfile that erases the findings about itself</h2>
<p>Package names and versions came out of the audited file and went to stdout with
no filtering at all. Plant this as a version string.</p>
<pre><code>1.0.0\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\r
</code></pre>
<p>Now the report renders like this.</p>
<pre><code>  package-lock.json        1 packages   (1 direct · 0 transitive)

     not in corpus · d=1 from "lodash" · root-only, no parent

  risk 77/100    20ms    third-party deps used to compute this: 0
</code></pre>
<p>The <code>⚠ HALLUCINATION RISK</code> heading is gone. So is the package name. <code>\x1b[2J</code>
clears the screen outright. The exit code still says 1, so a CI gate still fails,
but a human reading the terminal sees a finding with no subject.</p>
<p>What makes it a story rather than a bug report is that the JSON writer had been
escaping control characters since the first commit. One program, one author, one
weekend, two output paths, and only one of them treated the input as hostile. I
applied the rule where escaping is obviously required, in machine readable output
that something downstream will parse, and forgot it where it is actually
dangerous.</p>
<p>There was also a test named <code>piped_output_carries_no_escape_codes</code>, and it had
been passing the entire time. It passed because no fixture contained an escape
sequence. A test that asserts a property over inputs which cannot violate it is
not a test, it is a sentence about what you believe.</p>
<p>The fix replaces control characters with U+FFFD rather than dropping them, so the
cell still takes a column and you can see that something was there. Nothing real
is lost. npm permits only URL-safe characters in a name, PyPI normalises to
<code>[a-z0-9.-]</code>, crates.io to <code>[A-Za-z0-9_-]</code>, and no version scheme has a control
character in it either.</p>
<h2>2. A legal reformat that switches the auditor off</h2>
<p>This one is worse, because it is evasion rather than defacement.</p>
<p>pnpm writes integrity inline.</p>
<pre><code class="language-yaml">lodahs@1.0.0:
  resolution: {integrity: sha512-AAAA}
</code></pre>
<p>YAML also permits the flow mapping on the line below its key, and dumpers emit it
that way.</p>
<pre><code class="language-yaml">lodahs@1.0.0:
  resolution:
    {integrity: sha512-AAAA}
</code></pre>
<p>The second form parsed to a mapping whose key was the literal text <code>{integrity</code>.
My <code>block()</code> only ever dispatched to <code>mapping</code> or <code>sequence</code>, so a line opening
with <code>{</code> fell through to the plain-scalar key scanner, which stopped at the first
<code>: </code> and handed back nonsense. <code>has_integrity</code> went false, the package's origin
moved from <code>Registry</code> to <code>Elsewhere</code>, and the slopsquat rule skips every
<code>Elsewhere</code> package on the entirely correct grounds that a corpus sampled from one
registry cannot speak about a package that never came from it.</p>
<pre><code>inline form:    HALLUCINATION RISK  lodahs@1.0.0   risk 77/100
own-line form:  no findings                        risk 0/100
</code></pre>
<p>Same packages. Same file. Legal YAML both ways, no error either way. A malicious
package evades a supply-chain auditor by pressing enter.</p>
<p>The cause is one missing call. Four scanners in <code>yaml.rs</code> handle unquoted text.
Three of them call <code>reject_indicator()</code> and <code>flow_key</code> did not. The module doc
comment claimed all four did. I wrote that doc, and I believed it, because the
subset had been designed correctly and I had checked three of the four places the
design was enforced.</p>
<p>The lesson is specifically about subset parsers. A subset parser is a promise with
two halves. Parse what you support, and <em>refuse</em> what you do not. The second half
is load-bearing and it is invisible to every test that uses a real fixture,
because a real fixture never contains the thing you refuse. Every single bug I
found in these three hand-written parsers was in the refusal half.</p>
<h2>3. The program crashed freeing what it had successfully built</h2>
<p>This is the one that ate an afternoon.</p>
<p>A TOML table header nested 200,000 segments deep, <code>[a.b.b.b…]</code>, killed the
process at exit 134. With two lockfiles present the scan runs on worker threads
with a 2 MiB stack, and about 30,000 segments, or 70 KB of input, was enough.
<code>panic = "abort"</code> makes it unrecoverable, so one hostile file takes every sibling
lockfile's findings down with it.</p>
<p>The reason it cost an afternoon is that the instrumentation said this.</p>
<pre><code>parse returned in 333ms, ok=true
fatal runtime error: stack overflow
</code></pre>
<p><code>parse</code> returns <code>Ok</code>. The depth guard existed and was correct, and it applied to
arrays and inline tables. Table headers walked straight past it. <code>descend</code> is a
loop, so building a 200,000-deep <code>Value::Table</code> chain was genuinely fine. The
<em>recursive</em> <code>Drop</code> of that chain is what overflowed the stack. The program died
freeing what it had already parsed.</p>
<p>And there was a test enshrining the mistake, with a comment explaining it.</p>
<pre><code class="language-rust">// A long header path is iterative, not recursive, so it has to work.
let header = vec!["a"; 500];
</code></pre>
<p>500 sits comfortably under the threshold. I had reasoned about the parse and
never once about the free, and then I had written the reasoning down where it
would reassure me later.</p>
<h2>4. An argument that had the inequality backwards</h2>
<p>The hallucination rule needs an edit-distance metric. I shipped unrestricted
Damerau-Levenshtein and argued for it in <code>DECISIONS.md</code>, in <code>fixtures/README.md</code>
and in the book. <code>lodahs</code> is a transposition of <code>lodash</code>, Damerau scores that 1
and plain Levenshtein scores it 2, so, I wrote, the Levenshtein threshold that
catches the typo drags in half the registry with it.</p>
<p>Every clause of that is false, and the arithmetic makes it impossible.
Levenshtein is pointwise greater than or equal to Damerau, because Damerau
permits every edit Levenshtein does and adds transposition. So Damerau-at-k is
always the <em>more permissive</em> of the two. It can never be the tighter filter.
Measured against the real 140,066-name npm corpus at the shipped threshold of 2.</p>
<pre><code>lodahs: damerau &lt;= 2  -&gt;  3 names ["loadjs", "lodash", "loodash"]
lodahs: leven   &lt;= 2  -&gt;  1 name  ["lodash"]
</code></pre>
<p>Plain Levenshtein catches the planted name and is strictly more selective. I had
the right intuition about transpositions and attached it to the wrong parameter.</p>
<p>The fix was not to delete the claim. It was to work out what the metric actually
buys, which is three things that all live at k = 1 rather than k = 2. The
<em>reported</em> distance says <code>d=1 from "lodash"</code> instead of <code>d=2</code>, which is the
difference between "somebody fat-fingered lodash" and "this is two edits from
something, like a hundred other names are". The nearest-neighbour tie-break sorts
the real parent to the front. And if the crates.io corpus were ever as complete as
the npm one, k = 1 becomes arguable, and at k = 1 Damerau finds <code>lodash</code> where
Levenshtein finds nothing at all. What it does not buy is extra detections at the
threshold that ships, where both metrics fire on exactly the same set of names
across every fixture. There is a test holding that now, so the claim cannot rot
back into the comfortable version.</p>
<p>Argued confidently, in prose, in three separate files, killed by one measurement
that took four minutes to run.</p>
<h2>5. The false positives I had written up as bad luck</h2>
<p>Two packages had been flagged for most of the weekend, <code>ksni</code> in a <code>Cargo.lock</code>
and <code>taze</code> in a <code>pnpm-lock.yaml</code>. Both are real. I had documented them in README
LIMITS as unlucky near-misses of the popularity cut, real packages that happen to
sit below the top of their registry, and moved on.</p>
<p>Then I measured what clause 2 is worth as a function of name length. Take every
name in a corpus, pretend it is missing (which is exactly what a real package
below the popularity cut looks like), and ask whether the rest of the list offers
it a neighbour within the distance threshold.</p>
<table>
<thead>
<tr>
<th>chars</th>
<th>npm k=1</th>
<th>npm k=2</th>
<th>pypi k=2</th>
<th>crates.io k=2</th>
</tr>
</thead>
<tbody><tr>
<td>3</td>
<td>98.6%</td>
<td>100.0%</td>
<td>100.0%</td>
<td>100.0%</td>
</tr>
<tr>
<td>4</td>
<td>51.9%</td>
<td><strong>100.0%</strong></td>
<td>98.9%</td>
<td>99.1%</td>
</tr>
<tr>
<td>5</td>
<td>40.5%</td>
<td>97.5%</td>
<td>93.8%</td>
<td>78.9%</td>
</tr>
<tr>
<td>8</td>
<td>30.0%</td>
<td>63.0%</td>
<td>35.6%</td>
<td>26.6%</td>
</tr>
<tr>
<td>10</td>
<td>18.8%</td>
<td>46.1%</td>
<td>14.9%</td>
<td>9.5%</td>
</tr>
</tbody></table>
<p>At four characters, on npm, clause 2 passes unconditionally. It is not a filter at
that length, it is a formality, and the rule silently degrades to "not in the
corpus AND in-degree zero", which is a guaranteed CRITICAL for any real package
that happens to be unpopular and short. <code>ksni</code> and <code>taze</code> are both four
characters. They were never unlucky. They were structural, and the write-up
excusing them was wrong.</p>
<p>The fix is an edit budget that scales with length, <code>min(2, len / 5)</code>. The five is
not a taste call. Read the npm column above, where a hit at distance 1 stops being
the likelier outcome at five characters and a hit at distance 2 stops being the
likelier outcome at ten. Below a coin flip is the bar, because a clause that fires
on most inputs is not evidence about any of them.</p>
<table>
<thead>
<tr>
<th>policy</th>
<th>TP</th>
<th>FP</th>
<th>recall</th>
<th>precision</th>
</tr>
</thead>
<tbody><tr>
<td>flat threshold of 2, what shipped for most of the weekend</td>
<td>7</td>
<td>5</td>
<td>1.000</td>
<td>0.583</td>
</tr>
<tr>
<td><strong><code>min(2, len / 5)</code>, what ships now</strong></td>
<td><strong>7</strong></td>
<td><strong>1</strong></td>
<td><strong>1.000</strong></td>
<td><strong>0.875</strong></td>
</tr>
<tr>
<td>a tighter flat threshold of 1</td>
<td>6</td>
<td>4</td>
<td>0.857</td>
<td>0.600</td>
</tr>
</tbody></table>
<p>Every planted name still fires, at the same distance and against the same parent.
The last row is there because "just lower the threshold" is the obvious
alternative and it is worse at both ends. It loses a true positive and it still
keeps four false positives.</p>
<p>The general shape of this one is worth more than the fix. I had an aggregate
number that said the rule worked, and the aggregate averaged over a regime where
one of its clauses was vacuous.</p>
<h2>The idea that survived</h2>
<p>Edit distance alone does not work, which is the reason all of the above exists.
<code>lodash.assign</code> is two edits from <code>lodash.assignin</code> and both are real. Any
threshold loose enough to catch a typo catches legitimate siblings.</p>
<p>The clause that separates them is not about spelling.</p>
<blockquote>
<p>A hallucinated package is a <strong>root</strong> dependency. Nothing depends on it, because
nothing real has ever heard of it. A model put it in your manifest. No
maintainer ever put it in theirs.</p>
</blockquote>
<p>So the rule is a conjunction. Not in a corpus of known-real names, and within a
length-scaled edit budget of a name that is, and in-degree zero in the lockfile
graph.</p>
<p>Against the full corpus that third clause is worth exactly nothing, 1.000
precision and 1.000 recall with it and without it. That result is at the top of my
own table because a measurement that undercuts my idea is the one most worth
publishing. It measures nothing because the corpus contains every package in every
fixture, so clause 1 alone suffices and no other clause can show a difference. No
real corpus is ever that. npm accepts thousands of names a day, and mine is a
snapshot from one afternoon, so a package published after the snapshot is
indistinguishable from a package that does not exist.</p>
<p>So the experiment deletes part of the corpus and watches which clause holds the
rule up.</p>
<table>
<thead>
<tr>
<th>corpus kept</th>
<th>clause 3</th>
<th>TP</th>
<th>FP</th>
<th>precision</th>
<th>recall</th>
</tr>
</thead>
<tbody><tr>
<td>100% (140,066)</td>
<td>on</td>
<td>3</td>
<td>0</td>
<td>1.000</td>
<td>1.000</td>
</tr>
<tr>
<td>100% (140,066)</td>
<td>off</td>
<td>3</td>
<td>0</td>
<td>1.000</td>
<td>1.000</td>
</tr>
<tr>
<td>90% (126,004)</td>
<td><strong>on</strong></td>
<td>3</td>
<td><strong>1</strong></td>
<td><strong>0.750</strong></td>
<td>1.000</td>
</tr>
<tr>
<td>90% (126,004)</td>
<td>off</td>
<td>3</td>
<td>36</td>
<td>0.077</td>
<td>1.000</td>
</tr>
<tr>
<td>70% (98,197)</td>
<td><strong>on</strong></td>
<td>2</td>
<td><strong>6</strong></td>
<td><strong>0.250</strong></td>
<td>0.667</td>
</tr>
<tr>
<td>70% (98,197)</td>
<td>off</td>
<td>2</td>
<td>127</td>
<td>0.016</td>
<td>0.667</td>
</tr>
<tr>
<td>50% (69,897)</td>
<td><strong>on</strong></td>
<td>1</td>
<td><strong>8</strong></td>
<td><strong>0.111</strong></td>
<td>0.333</td>
</tr>
<tr>
<td>50% (69,897)</td>
<td>off</td>
<td>1</td>
<td>175</td>
<td>0.006</td>
<td>0.333</td>
</tr>
<tr>
<td>25% (35,134)</td>
<td><strong>on</strong></td>
<td>1</td>
<td><strong>5</strong></td>
<td><strong>0.167</strong></td>
<td>0.333</td>
</tr>
<tr>
<td>25% (35,134)</td>
<td>off</td>
<td>1</td>
<td>177</td>
<td>0.006</td>
<td>0.333</td>
</tr>
</tbody></table>
<p>The 90% row is the realistic one. Ten percent missing is roughly what a few months
of registry growth looks like. False positives go from 36 to 1 and recall does not
move. Recall falling at 70% is a real cost rather than an artefact, and it is not
clause 3 failing. The thinning deletes the real <code>express</code> that <code>expres</code> needed as
its neighbour, so clause 2 has nothing left to match against.</p>
<p>Across every fixture, with a full corpus and the length budget in place, one false
positive is left, and it is the honest one. <code>tensorflow-gpu</code> is fourteen
characters and one edit from <code>tensorflow-cpu</code>. No length policy reaches it,
because at fourteen characters a near-miss really is evidence. It is a real,
deprecated PyPI package that fell out of a top-15,000 list, it came through the
registry the corpus samples so the origin check has nothing to say, and it sits in
a <code>requirements.txt</code>, which records no dependency edges, so there is no clause 3
to save it. It stays in the fixtures.</p>
<p>And the ceiling on all of it, stated plainly. I ran the rule over every lockfile
on this machine, 1,477 files and 106,673 packages that nobody planted anything in.
It produced 28 findings. All 28 are false positives, not by my judgement but by
construction, because each one sits beside a registry URL that resolved. That is
about one package in 3,800. The true-positive count on those 1,477 files is zero,
because there is no hallucinated dependency on this disk to find. Every true
positive this tool has ever demonstrated is one of the names I planted myself. A
sample of three is a sample of three.</p>
<h2>What the standard library actually made hard</h2>
<p>I expected to say <code>\uXXXX</code> surrogate pairing, and it is fiddly. The honest answer
is different. Nothing in std made the parsers hard. The hard part was knowing what
to refuse, and that is a design problem no library helps with.</p>
<p>Two constraints did bite, and both are in the README rather than buried.</p>
<ul>
<li>There is no cryptography in the Rust standard library, at all. Every npm entry
carries a <code>sha512-…</code> integrity field. <code>stranger</code> reports whether that field is
<em>present</em> and never whether it is <em>correct</em>. That is the constraint biting in
public, which is the entire subject of the event.</li>
<li>There is no RNG. The corpus-thinning ablation has to be reproducible, so it uses
a seeded xorshift built from <code>SystemTime</code> nanos. Five lines, and it kills
<code>rand</code>, all 1.6 billion downloads of it, which is a satisfying trade for five
lines.</li>
</ul>
<h2>The package I made unnecessary</h2>
<p><code>serde_json</code> has 1,227,048,507 all-time downloads and 288,758,389 in the last
ninety days, measured 2026-08-28. Almost every Rust program that reads a
<code>package-lock.json</code> reaches for it.</p>
<p><code>rand</code> is bigger, at 1,605,926,795, and it is deliberately not the nomination.
Five lines of xorshift is not a case for anything. The claim should go to the
substitution with the most work behind it, not to the largest number available.</p>
<p>"I wrote a JSON parser" is not a case for anything. Anyone can write one that
reads the happy path. The case is two commands. The first is 29 conformance tests,
each citing the RFC 8259 section it comes from, walking the grammar production by
production. The second generates and mutates two million inputs and puts my parser
next to CPython's <code>json</code>, comparing the accept/reject decision and the parsed
value.</p>
<p>1,997,016 agreed. The 2,984 that did not fall into four classes, and every one is
a place where RFC 8259 permits both answers, a leading byte order mark and three
flavours of unpaired surrogate. None was about a value. Every time both accepted,
both built the same thing down to the IEEE-754 bits.</p>
<p>The campaign found no defect. That is a result reported rather than a claim made,
which after the five bugs above feels like the only way to say anything.</p>
<h2>What I cut, and the two cuts that came back</h2>
<p>The plan had a hard cut line at hour 30. Whatever is below it and unstarted gets
cut, decided by clock rather than by feeling. Two things went below it, <code>yarn.lock</code>
support and a <code>stranger diff</code> subcommand, and I wrote both up as named
limitations, because a named limitation reads as judgement where a hidden gap
reads as an oversight.</p>
<p>Both came back after the verify pass came back clean, and both cuts turned out to
have been justified with a false reason.</p>
<p><code>yarn.lock</code> was cut partly because I had no yarn fixture on disk. I did. Real yarn
lockfiles ship inside npm tarballs, and there were three sitting in a bun cache.
The fixture paid for itself in the first hour, because yarn resolves dependency
edges by <em>specifier</em> rather than by resolved version. Matching on the version,
which is the obvious implementation, returns a complete package list with an empty
edge set. No error. Just a tree where every package has in-degree 0, which is the
exact shape the slopsquat rule fires on.</p>
<p><code>stranger diff</code> was cut on the reasoning that two shell redirections and <code>diff</code>
already do it. They do not, because <code>scan</code> and <code>diff</code> answer different questions.
<code>scan --fail-on</code> gates on the state of the tree, so a repository that already has
211 trivial packages fails on every pull request until somebody turns the gate off.
<code>diff --fail-on</code> gates on what the change <em>introduced</em>. Reversing the two arguments
exits 0 where <code>scan</code> returns 1 both ways, and that asymmetry is the whole reason
the subcommand exists.</p>
<p>Eight lockfile formats and three subcommands shipped. The cut line ended up empty,
which I am recording as luck rather than planning.</p>
<h2>Closing</h2>
<p>The tool's premise is that you should not take a document's word for anything.
Every defect I found was in a place where I had taken my own word for something. A
doc comment, a design note, a test name, a limitation I had already excused in
public. The parsers were fine. The prose about the parsers was where the bugs
lived, and prose does not fail CI.</p>
<p>The one habit that actually caught them was to make the assumption a parameter and
measure it. The corpus is a parameter of the slopsquat rule rather than a global,
which is the only reason the ablation table can exist. An assumption you cannot
vary is one you cannot measure, and an assumption you cannot measure is a sentence
you will eventually believe.</p>
<ul>
<li>The repository is at <a href="https://github.com/keirsalterego/stranger">https://github.com/keirsalterego/stranger</a>, MIT licensed,
and the submission is the tag <code>v0.1.0-submission</code></li>
<li>The cookbook is at <a href="https://keir.is-a.dev/stranger/">https://keir.is-a.dev/stranger/</a>, including <a href="https://keir.is-a.dev/stranger/detection/ablation.html">the full
ablation table</a></li>
</ul>
<p>Built for Zero Dependency 2026, run by Hackathon Raptors. Track A, Rust, standard
library only. <code>cargo tree</code> is one line and every number above regenerates from the
repository with <code>make ablation</code>, <code>make sweep</code> and <code>make bench</code>.</p>
]]></content:encoded></item><item><title><![CDATA[Teaching a Dumb Script to Find XSS]]></title><description><![CDATA[Cross-site scripting is the bug that refuses to die, and the reason is simple: it is not one bug, it is a thousand tiny variations of the same betrayal. The application takes something you typed and p]]></description><link>https://keirsalterego.hashnode.dev/teaching-a-dumb-script-to-find-xss</link><guid isPermaLink="true">https://keirsalterego.hashnode.dev/teaching-a-dumb-script-to-find-xss</guid><dc:creator><![CDATA[Manish]]></dc:creator><pubDate>Wed, 25 Jun 2025 05:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Cross-site scripting is the bug that refuses to die, and the reason is simple: it is not one bug, it is a thousand tiny variations of the same betrayal. The application takes something you typed and puts it back on the page without checking whether what you typed was words or weapons.</p>
<p><code>xss-probe</code> is my reflected-XSS detector, and the hard part was never finding reflections. The hard part is making a script smart about context, because a payload that works in one place is harmless in another.</p>
<p>This is the part most naive scanners get wrong. When your input shows up in the page, it lands somewhere specific. Maybe inside the HTML body. Maybe inside an attribute, wrapped in quotes. Maybe inside a <code>&lt;script&gt;</code> block where it is already being treated as code. Each of those needs a completely different payload to break out and execute. A scanner that fires the same <code>&lt;script&gt;alert(1)&lt;/script&gt;</code> everywhere misses three quarters of the real bugs because it never asked "where did my input actually land?"</p>
<p>So <code>xss-probe</code> looks first. It injects a harmless unique marker, finds where the marker is reflected, works out the context it landed in, and only then picks a payload designed to escape that specific context. It is the difference between a battering ram and a lockpick. The battering ram is louder and works less often.</p>
<p>Tested against DVWA and Juice Shop, because those are built to be abused and nobody calls a lawyer. It catches the obvious cases cleanly, and I document the ones it misses, because a tool that lies about its false negatives is worse than no tool at all.</p>
<p>The lesson that is bigger than XSS: the quality of an attack is mostly the quality of your attention. Look at where your input goes before you decide what to send.</p>
]]></content:encoded></item><item><title><![CDATA[Recon Is Stalking With Extra Steps]]></title><description><![CDATA[Let me describe what I built this month in the most unflattering terms possible, because honesty is a feature. I built a tool whose entire purpose is to find every door and window on a building the ow]]></description><link>https://keirsalterego.hashnode.dev/recon-is-stalking-with-extra-steps</link><guid isPermaLink="true">https://keirsalterego.hashnode.dev/recon-is-stalking-with-extra-steps</guid><dc:creator><![CDATA[Manish]]></dc:creator><pubDate>Wed, 28 May 2025 05:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Let me describe what I built this month in the most unflattering terms possible, because honesty is a feature. I built a tool whose entire purpose is to find every door and window on a building the owner forgot they had. It is called <code>subhunter</code>, it enumerates subdomains, and yes, this is just stalking with a JSON output.</p>
<p>The legal-and-also-correct version: reconnaissance is where you map the real attack surface, which is almost always bigger than the target thinks it is. Companies spin up <code>staging.</code>, <code>dev.</code>, <code>old-admin.</code>, <code>that-thing-marketing-needed-in-2019.</code> and then forget. Those forgotten corners are where the soft spots live.</p>
<p><code>subhunter</code> does it two ways. Passively, it queries Certificate Transparency logs through crt.sh, which is a genuinely beautiful trick: every time someone gets a TLS certificate it is logged publicly, forever, so the CT log is basically a confession of every subdomain a company has ever bothered to secure. Actively, it brute-forces names against a wordlist and resolves whatever answers to find the hosts that are actually alive.</p>
<p>The active brute-forcing only ever runs against my own lab; the passive crt.sh lookups against <code>example.com</code>, because there is a bright legal line between "querying a public log" and "throwing ten thousand DNS requests at someone else's infrastructure." I intend to stay on the correct side of that line for the rest of my life.</p>
<p>The satisfying part: I wrapped <code>pyscan</code> and <code>subhunter</code> into one thin CLI, <code>recon-suite</code>. One command, point it at a domain, get a combined picture back. It is small. I do not care that it is small. It composes two things I built into one thing that does more than either, and that is the whole point of building your own tools instead of renting someone else's.</p>
<p>Find the doors first. You cannot pick a lock you never knew was there.</p>
]]></content:encoded></item><item><title><![CDATA[I Wrote a Port Scanner to Feel Something]]></title><description><![CDATA[There is a specific kind of joy in writing the tool instead of running it. I have run nmap plenty. But I wrote my own scanner, pyscan, and although it is slower and dumber than nmap in every measurabl]]></description><link>https://keirsalterego.hashnode.dev/i-wrote-a-port-scanner-to-feel-something</link><guid isPermaLink="true">https://keirsalterego.hashnode.dev/i-wrote-a-port-scanner-to-feel-something</guid><dc:creator><![CDATA[Manish]]></dc:creator><pubDate>Tue, 22 Apr 2025 14:36:00 GMT</pubDate><content:encoded><![CDATA[<p>There is a specific kind of joy in writing the tool instead of running it. I have run <code>nmap</code> plenty. But I wrote my own scanner, <code>pyscan</code>, and although it is slower and dumber than nmap in every measurable way, it is mine, and I understand every line, which nmap has never once let me say about itself.</p>
<p>I started it synchronous, the obvious way: loop over the ports, try to connect to each, write down which ones answer. It works. It is also painfully slow, because most of the time it is just standing there waiting for a port to respond or time out, doing nothing, like me in a queue.</p>
<p>Then <code>asyncio</code>, which is the whole point. Scanning a port is almost entirely waiting, and waiting is exactly the thing you should never do one at a time. Fire off hundreds of connection attempts and let them all wait together, and a scan that took minutes takes seconds. Watching it tear through a <code>/24</code> in under a minute still produces a noise I am not proud of.</p>
<p>It does banner grabbing too. Connect, stay quiet for a beat, and a lot of services will introduce themselves, version number and all. Services are so eager to tell you who they are. It is almost rude not to write it down.</p>
<p>I test it the responsible way: against <code>127.0.0.1</code> with <code>nc</code> listeners I spin up myself, then against Metasploitable 2 on its isolated network. Then nmap against the same box, diffed, partly to check my work, partly to stay honest about what mine misses.</p>
<p>I did not write <code>pyscan</code> to beat nmap. I wrote it to stop treating recon as magic. The map of a network is not handed to you. You build it, one open port at a time, and now I have the thing that builds the map.</p>
]]></content:encoded></item><item><title><![CDATA[Reading Packets Like Tea Leaves]]></title><description><![CDATA[You cannot attack what you cannot read, and the network is the loudest thing in the room once you learn its language. It is just a chatty crowd of machines talking over each other in a protocol that, ]]></description><link>https://keirsalterego.hashnode.dev/reading-packets-like-tea-leaves</link><guid isPermaLink="true">https://keirsalterego.hashnode.dev/reading-packets-like-tea-leaves</guid><dc:creator><![CDATA[Manish]]></dc:creator><pubDate>Tue, 25 Feb 2025 19:32:00 GMT</pubDate><content:encoded><![CDATA[<p>You cannot attack what you cannot read, and the network is the loudest thing in the room once you learn its language. It is just a chatty crowd of machines talking over each other in a protocol that, once you hear it, you cannot unhear.</p>
<p>The fastest way back to first principles is to stop watching diagrams and start watching packets. <code>tcpdump -i any -c 20</code> on my own machine, twenty packets, read every one. <code>curl -v</code> against a handful of sites, reading the headers as they scroll. The TCP three-way handshake stops being a slide and becomes a thing you watch happen: SYN, SYN-ACK, ACK, hello, we are now talking. A polite little ritual for something I spend a lot of time abusing.</p>
<p>What matters for offense is how much a machine tells you without meaning to. A banner here, a header there, a TTL that hints at the operating system, a response time that quietly admits "I am behind a load balancer." None of it is secret. All of it is sitting in the open, waiting for someone patient enough to read it.</p>
<p>That is the recon mindset in one sentence: the network is not hiding from you, it is gossiping constantly, and the job is to eavesdrop well enough to build the map nobody handed you.</p>
]]></content:encoded></item><item><title><![CDATA[Building a Lab Nobody Can See]]></title><description><![CDATA[Rule number one of offensive work without ruining your life: you do not practice on the internet. You practice in a box the internet cannot reach and that cannot reach the internet. Most of what I do ]]></description><link>https://keirsalterego.hashnode.dev/building-a-lab-nobody-can-see</link><guid isPermaLink="true">https://keirsalterego.hashnode.dev/building-a-lab-nobody-can-see</guid><dc:creator><![CDATA[Manish]]></dc:creator><pubDate>Sun, 19 Jan 2025 23:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Rule number one of offensive work without ruining your life: you do not practice on the internet. You practice in a box the internet cannot reach and that cannot reach the internet. Most of what I do in here is illegal the moment it points at something I do not own, so before anything else I built a place where there is nothing to break the law against.</p>
<p>The setup took a weekend and a lot of <code>usermod -aG</code>.</p>
<p>Kali, fully updated, because a half-updated Kali is its own kind of haunted. KVM with libvirt and virt-manager for fast native virtualization, VirtualBox and Vagrant on the side specifically because the Active Directory lab insists on it, and Docker for the throwaway vulnerable apps. The part that actually matters is the network: an isolated host-only network with no NAT and no egress. The vulnerable machines live there. They can talk to my attacker box and to each other. They cannot talk to anything else, and nothing else can talk to them.</p>
<p>Then the single most important habit in the whole craft: snapshot every VM the moment it is clean. When an experiment turns the machine into a smoking crater, and it will, you restore in ten seconds instead of rebuilding for an hour. I have paid for skipping that. I do not skip it twice.</p>
<p>It is a strange thing to build a playground whose entire design goal is being sealed off from everything. But that seal is the difference between "security researcher" and "defendant," and I intend to stay the former.</p>
<p>Lab is up. <code>kvm-ok</code> says OK. <code>docker run hello-world</code> runs without sudo. Time to start breaking things that were built to be broken.</p>
]]></content:encoded></item><item><title><![CDATA[Mark My Words]]></title><description><![CDATA[I keep starting this site and not finishing it. This is attempt number whatever. The difference this time is that I finally have something worth writing down that is not "look, I rebuilt my homepage a]]></description><link>https://keirsalterego.hashnode.dev/mark-my-words</link><guid isPermaLink="true">https://keirsalterego.hashnode.dev/mark-my-words</guid><dc:creator><![CDATA[Manish]]></dc:creator><pubDate>Mon, 12 Feb 2024 17:00:00 GMT</pubDate><content:encoded><![CDATA[<p>I keep starting this site and not finishing it. This is attempt number whatever. The difference this time is that I finally have something worth writing down that is not "look, I rebuilt my homepage again."</p>
<p>Here is the thing I have decided, and I am writing it down so I cannot quietly back out later.</p>
<p>I am going to do the work in public. Web application testing, Active Directory, network attack paths, and the tooling I build to do all of it, written up here, in the open, while it is still fresh enough that I have to be honest about it.</p>
<p>People will tell me to stay broad. To hedge. To keep my options open. I am not going to. Broad is how you stay shallow forever. I would rather own one attack surface completely than wave at ten, and I would rather ship one small tool that actually works than collect ten browser tabs of things I mean to read someday.</p>
<p>So, mark my words. If you are reading this and the site is full of writeups about web bugs and Kerberos and boxes I took apart, it worked. If it is empty, I talked myself out of writing again.</p>
<p>Let us find out which one of us I am.</p>
]]></content:encoded></item></channel></rss>