<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Adversarial Agents</title>
    <subtitle>Autonomous offense and autonomous defense — what changes in an engagement when both sides are agents.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://adversarialagents.org/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://adversarialagents.org"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-11T00:00:00+00:00</updated>
    <id>https://adversarialagents.org/atom.xml</id>
    <entry xml:lang="en">
        <title>A bit is not a counter</title>
        <published>2026-09-11T00:00:00+00:00</published>
        <updated>2026-09-11T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Matthew J. Harmon
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://adversarialagents.org/posts/a-bit-is-not-a-counter/"/>
        <id>https://adversarialagents.org/posts/a-bit-is-not-a-counter/</id>
        
        <content type="html" xml:base="https://adversarialagents.org/posts/a-bit-is-not-a-counter/">&lt;p&gt;Machine speed is the one thing automation cannot hide. An agent can vary its
timing, randomise its ordering, and spread its work across processes, but when
it does something a million times a second, that rate is the work itself. If
you want an instrument that notices automation without knowing in advance what
the automation is, rate is a good place to point it.&lt;&#x2F;p&gt;
&lt;p&gt;This is an account of building one: a profiler that finds memory changing faster
than the rest of its process, and the code responsible. It is also an account of
three ways the obvious design is wrong, each of which produced confident,
well-formatted, entirely meaningless output before it was caught. The negative
result at the end is the most useful part.&lt;&#x2F;p&gt;
&lt;p&gt;All measurements below are from a single machine — AMD, kernel 7.0.0,
&lt;code&gt;perf_event_paranoid=4&lt;&#x2F;code&gt;, &lt;code&gt;yama&#x2F;ptrace_scope=1&lt;&#x2F;code&gt; — and every number is measured
rather than estimated.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem-with-asking-the-kernel&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-problem-with-asking-the-kernel&quot; aria-label=&quot;Anchor link for: the-problem-with-asking-the-kernel&quot;&gt;🔗&lt;&#x2F;a&gt;The problem with asking the kernel&lt;&#x2F;h2&gt;
&lt;p&gt;Linux offers a cheap way to learn which pages a process has written. Write &lt;code&gt;4&lt;&#x2F;code&gt;
to &lt;code&gt;&#x2F;proc&#x2F;PID&#x2F;clear_refs&lt;&#x2F;code&gt; and the kernel clears a per-page &lt;em&gt;soft-dirty&lt;&#x2F;em&gt; bit and
write-protects the pages; the next write to each page faults, sets the bit, and
proceeds. Read &lt;code&gt;&#x2F;proc&#x2F;PID&#x2F;pagemap&lt;&#x2F;code&gt; and bit 55 of each 8-byte entry tells you
whether that page was written since the reset.&lt;&#x2F;p&gt;
&lt;p&gt;It is almost free. It costs no memory reads at all, so the observer barely
perturbs the observed — which matters when the thing you are looking for is
defined by its speed.&lt;&#x2F;p&gt;
&lt;p&gt;The obvious instrument writes itself. Clear the bits, wait, read the bitmap,
count. Sample a process every 50 ms for eight seconds and rank pages by how
often they came back dirty.&lt;&#x2F;p&gt;
&lt;p&gt;Here is what that produced against a synthetic target with a deliberately
planted hot region:&lt;&#x2F;p&gt;
&lt;pre class=&quot;z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;  pages written: 84   median hits&#x2F;page: 156   MAD: 1
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;           address   rate   hits       z  kind       region
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;          0xb49000   100%    156     0.0  file-data  rw-p python3.12
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;          0xb8a000   100%    156     0.0  file-data  rw-p python3.12
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;         0x28073000  100%    156     0.0  heap       rw-p [heap]
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;      0x7bc9bf92c000 100%    156     0.0  anon       rw-p [anon]
&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every page. One hundred percent. One hundred and fifty-six hits out of one
hundred and fifty-six samples, a median of 156, a median absolute deviation of
1, and therefore a z-score of exactly 0.0 for every page in the process. The
planted hot region is in that list, indistinguishable from the interpreter’s own
bookkeeping.&lt;&#x2F;p&gt;
&lt;p&gt;The failure is not a bug. &lt;strong&gt;Soft-dirty is a bit, not a counter.&lt;&#x2F;strong&gt; It records
that a page &lt;em&gt;was&lt;&#x2F;em&gt; written since the last reset, never how many times. At a 50 ms
window, any page touched at all comes back dirty, so a page written a hundred
times a second and a page written ten million times a second produce identical
output. The metric saturates, and saturation is invisible: the numbers look
fine, the table sorts, the z-scores compute. Nothing about the output announces
that the instrument has stopped measuring.&lt;&#x2F;p&gt;
&lt;p&gt;This is worth dwelling on, because it is the general shape of the problem.
Binary-per-interval observations look like measurements and are not.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sweeping-the-timescale&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#sweeping-the-timescale&quot; aria-label=&quot;Anchor link for: sweeping-the-timescale&quot;&gt;🔗&lt;&#x2F;a&gt;Sweeping the timescale&lt;&#x2F;h2&gt;
&lt;p&gt;The information the bit loses within one window can be recovered by varying the
window. For a page written at frequency &lt;em&gt;f&lt;&#x2F;em&gt;, sampled with interval &lt;em&gt;T&lt;&#x2F;em&gt;:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;writer&lt;&#x2F;th&gt;&lt;th&gt;probability the page is dirty&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;periodic&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;f · T&lt;&#x2F;code&gt;, until it saturates at 1&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;randomly timed&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;1 − exp(−f · T)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Both say the same thing. The interval at which a page’s hit rate &lt;em&gt;starts to fall
off&lt;&#x2F;em&gt; is its write frequency. So sample the same process at 20 ms, 5 ms, 1 ms and
0.25 ms, and read the frequency off the rungs where it has not yet saturated.&lt;&#x2F;p&gt;
&lt;p&gt;A page still pinned at 100% in the shortest window is being written faster than
the instrument can resolve. That is not a failure — it is the finding, reported
as a floor rather than a number.&lt;&#x2F;p&gt;
&lt;p&gt;Frequency, unlike a hit rate, does not saturate, and it is comparable across
processes and across machines.&lt;&#x2F;p&gt;
&lt;p&gt;Two details in the inversion turned out to matter more than they look.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Read the frequency off the largest unsaturated window, not the smallest.&lt;&#x2F;strong&gt;
The shortest window has the fewest writes per sample and so the most
discretization error. For a tier written at a true 200 Hz, the 0.25 ms rung read
6% and the 1 ms rung read 22%; the latter is the better-conditioned measurement.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Invert as periodic, not Poisson.&lt;&#x2F;strong&gt; Using &lt;code&gt;−ln(1−r)&#x2F;T&lt;&#x2F;code&gt; on a periodic writer
overestimates it by about a third — it read 268 Hz against a ground truth of
200. The linear form &lt;code&gt;r&#x2F;T&lt;&#x2F;code&gt; is also the conservative choice: for a bursty writer
it reads low, so a reported frequency is a floor rather than an inflated claim.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;sampling-is-not-free-and-the-difference-is-not-small&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#sampling-is-not-free-and-the-difference-is-not-small&quot; aria-label=&quot;Anchor link for: sampling-is-not-free-and-the-difference-is-not-small&quot;&gt;🔗&lt;&#x2F;a&gt;Sampling is not free, and the difference is not small&lt;&#x2F;h3&gt;
&lt;p&gt;The sweep sleeps for the interval it wants. It also has to do work — a pagemap
read, a &lt;code&gt;clear_refs&lt;&#x2F;code&gt; write — and that work is not instantaneous. Feeding the
&lt;em&gt;requested&lt;&#x2F;em&gt; interval into the frequency math scales every estimate by the error.&lt;&#x2F;p&gt;
&lt;p&gt;Measured on the synthetic target:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;requested&lt;&#x2F;th&gt;&lt;th&gt;actual&lt;&#x2F;th&gt;&lt;th&gt;overhead&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;0.25 ms&lt;&#x2F;td&gt;&lt;td&gt;0.36 ms&lt;&#x2F;td&gt;&lt;td&gt;44%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;1.00 ms&lt;&#x2F;td&gt;&lt;td&gt;1.12 ms&lt;&#x2F;td&gt;&lt;td&gt;12%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;5.00 ms&lt;&#x2F;td&gt;&lt;td&gt;5.19 ms&lt;&#x2F;td&gt;&lt;td&gt;4%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;20.0 ms&lt;&#x2F;td&gt;&lt;td&gt;21.2 ms&lt;&#x2F;td&gt;&lt;td&gt;6%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Correcting the math to use measured intervals moved a 200 Hz tier from 215 Hz to
196 Hz. For the portable collector described below, where a single sample costs
tens of milliseconds, the same error would have been an order of magnitude.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-model-can-check-itself&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-model-can-check-itself&quot; aria-label=&quot;Anchor link for: the-model-can-check-itself&quot;&gt;🔗&lt;&#x2F;a&gt;The model can check itself&lt;&#x2F;h3&gt;
&lt;p&gt;Below saturation, &lt;code&gt;r&#x2F;T&lt;&#x2F;code&gt; should be the same at every rung. That is a testable
claim about each page, and pages that fail it are not measuring a frequency.&lt;&#x2F;p&gt;
&lt;p&gt;A page whose hit rate stays &lt;em&gt;flat&lt;&#x2F;em&gt; as the window shrinks — 88%, 88%, 89%, 90% —
is violating the model, and the instrument says so with a &lt;code&gt;?&lt;&#x2F;code&gt; rather than
printing a confident number. In practice this catches a specific blind spot,
described next.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-collectors-one-analysis&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#two-collectors-one-analysis&quot; aria-label=&quot;Anchor link for: two-collectors-one-analysis&quot;&gt;🔗&lt;&#x2F;a&gt;Two collectors, one analysis&lt;&#x2F;h2&gt;
&lt;p&gt;Soft-dirty is Linux-only. The analysis is not, so the collector sits behind a
two-method interface — &lt;code&gt;reset()&lt;&#x2F;code&gt; and &lt;code&gt;sample(spans)&lt;&#x2F;code&gt; — with two implementations.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;softdirty&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; asks the kernel, as above. No memory reads, roughly 0.13 ms per
sample on a small process.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;hashdiff&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; reads each page and hashes it, comparing against the previous
sample. A page whose hash changed was written. This works anywhere memory can be
read — &lt;code&gt;&#x2F;proc&#x2F;PID&#x2F;mem&lt;&#x2F;code&gt; on Linux, &lt;code&gt;vm_read&lt;&#x2F;code&gt; on macOS, &lt;code&gt;ReadProcessMemory&lt;&#x2F;code&gt; on
Windows — because the only platform-specific part is the read.&lt;&#x2F;p&gt;
&lt;p&gt;It has a real blind spot: a write that restores a previous value leaves the
content unchanged, so the hash cannot see it. Soft-dirty sees the write
regardless of the value. That blind spot is exactly what the model-fit check
flags — in the validation run, six pages came back &lt;code&gt;?&lt;&#x2F;code&gt;, and all six were pages
&lt;code&gt;softdirty&lt;&#x2F;code&gt; independently showed as saturated.&lt;&#x2F;p&gt;
&lt;p&gt;Two mechanisms that share only the analysis, disagreeing in a predictable
direction, is a more useful arrangement than either alone.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;validation&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#validation&quot; aria-label=&quot;Anchor link for: validation&quot;&gt;🔗&lt;&#x2F;a&gt;Validation&lt;&#x2F;h2&gt;
&lt;p&gt;A synthetic target writes four populations at &lt;strong&gt;time-paced&lt;&#x2F;strong&gt; frequencies. The
pacing matters, and getting it wrong wasted a full cycle: an earlier version
paced by loop count, and “every 50 iterations” in a Python loop turns out to be
roughly 60 kHz. Nothing in that workload was slow, so nothing discriminated, and
the tool looked broken when it was reporting correctly.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;tier&lt;&#x2F;th&gt;&lt;th&gt;pages&lt;&#x2F;th&gt;&lt;th&gt;ground truth&lt;&#x2F;th&gt;&lt;th&gt;&lt;code&gt;softdirty&lt;&#x2F;code&gt;&lt;&#x2F;th&gt;&lt;th&gt;&lt;code&gt;hashdiff&lt;&#x2F;code&gt;&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;hot&lt;&#x2F;td&gt;&lt;td&gt;4&lt;&#x2F;td&gt;&lt;td&gt;as fast as the CPU allows&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;&amp;gt; ceiling&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;&amp;gt; ceiling&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;warm&lt;&#x2F;td&gt;&lt;td&gt;8&lt;&#x2F;td&gt;&lt;td&gt;200 Hz&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;196 Hz&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;198 Hz&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;cool&lt;&#x2F;td&gt;&lt;td&gt;8&lt;&#x2F;td&gt;&lt;td&gt;5 Hz&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;5 Hz&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;5 Hz&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;cold&lt;&#x2F;td&gt;&lt;td&gt;2048&lt;&#x2F;td&gt;&lt;td&gt;written once at startup&lt;&#x2F;td&gt;&lt;td&gt;absent&lt;&#x2F;td&gt;&lt;td&gt;absent&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Both tiers were recovered as contiguous eight-page runs at the right addresses.
The 2,048 cold pages, written once and then left alone, correctly never appear.&lt;&#x2F;p&gt;
&lt;p&gt;Two independent mechanisms converging on ground truth is the point. They share
the inversion and the fit check; they share no part of the measurement.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-normal-has-to-mean&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-normal-has-to-mean&quot; aria-label=&quot;Anchor link for: what-normal-has-to-mean&quot;&gt;🔗&lt;&#x2F;a&gt;What “normal” has to mean&lt;&#x2F;h2&gt;
&lt;p&gt;Finding the fastest page in a process is not the same as finding one that is
&lt;em&gt;faster than it should be&lt;&#x2F;em&gt;. The second needs a baseline, and a baseline for
memory runs into a hard constraint immediately.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;ASLR relocates every mapping on every run.&lt;&#x2F;strong&gt; The address &lt;code&gt;0x7d34c29b3000&lt;&#x2F;code&gt; in
today’s process is unrelated to the same number tomorrow. A profile cannot store
addresses.&lt;&#x2F;p&gt;
&lt;p&gt;What survives a restart:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;File-backed pages&lt;&#x2F;strong&gt; have a stable identity. &lt;code&gt;libxul.so+0xa000&lt;&#x2F;code&gt; names the same
data on every run, on every machine with that build.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Anonymous pages&lt;&#x2F;strong&gt; have none. Heap and allocator arenas are laid out
differently each time.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In a browser this bites hard. Of 1,170 written pages in a Firefox content
process, &lt;strong&gt;nine had stable identity&lt;&#x2F;strong&gt;. Almost all write activity is anonymous,
so anonymous memory cannot be treated as the leftover case.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-percentile-trap&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-percentile-trap&quot; aria-label=&quot;Anchor link for: the-percentile-trap&quot;&gt;🔗&lt;&#x2F;a&gt;The percentile trap&lt;&#x2F;h3&gt;
&lt;p&gt;The first attempt scored each anonymous page against the 95th percentile of the
baseline’s pooled frequencies. It produced 29 findings on a process compared
against its own baseline.&lt;&#x2F;p&gt;
&lt;p&gt;They were all false. The flagged pages were allocator arenas running at 24–27 Hz
— entirely ordinary behaviour — sitting above a percentile dominated by roughly
1,600 near-idle pages. Comparing individuals against a pooled percentile flags
the top 5% &lt;em&gt;by construction&lt;&#x2F;em&gt;. The test could not not fire.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is to compare &lt;strong&gt;curves rather than points&lt;&#x2F;strong&gt;. Store each baseline run as
its own sorted frequency curve, and judge the live capture’s k-th fastest page
against the fastest k-th page any baseline run produced. A process that is
merely busier shifts the whole curve and is judged as such. A single injected
hot page spikes one rank and stands out.&lt;&#x2F;p&gt;
&lt;p&gt;Same captures, same data: 29 false positives to &lt;strong&gt;zero&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;a-signal-that-survives-the-ceiling&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#a-signal-that-survives-the-ceiling&quot; aria-label=&quot;Anchor link for: a-signal-that-survives-the-ceiling&quot;&gt;🔗&lt;&#x2F;a&gt;A signal that survives the ceiling&lt;&#x2F;h3&gt;
&lt;p&gt;There is one more comparison worth making, because it does not depend on
frequency at all: &lt;strong&gt;how many pages sit above the resolution ceiling&lt;&#x2F;strong&gt;. Unlike a
frequency, that count is not capped by the ceiling, so it keeps working on large
processes where everything interesting saturates.&lt;&#x2F;p&gt;
&lt;p&gt;It was exactly 17 in both baseline runs of the synthetic target, and 18 with an
injection present.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;detection-with-a-control&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#detection-with-a-control&quot; aria-label=&quot;Anchor link for: detection-with-a-control&quot;&gt;🔗&lt;&#x2F;a&gt;Detection, with a control&lt;&#x2F;h3&gt;
&lt;p&gt;The test workload gained two pages written at 800 Hz — a small, fast region
against a busy background, which is the shape of the thing worth finding.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;run&lt;&#x2F;th&gt;&lt;th&gt;novel pages&lt;&#x2F;th&gt;&lt;th&gt;over baseline&lt;&#x2F;th&gt;&lt;th&gt;ceiling population&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;control&lt;&#x2F;strong&gt; — clean vs its own baseline&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;17 (baseline 17–17)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;detection&lt;&#x2F;strong&gt; — injected vs clean baseline&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;1, at 14×&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;18 (outside 17–17)&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The injected region was caught at rank 8, 2,769 Hz against a 192 Hz baseline,
and independently by the ceiling population. The control reported &lt;em&gt;nothing
outside the baseline&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Repeating the detection against a freshly started process — new PID, new ASLR
layout, the hot region at a completely different address — reproduced both
signals. The baseline is genuinely address-independent.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;profiling-by-library-and-function&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#profiling-by-library-and-function&quot; aria-label=&quot;Anchor link for: profiling-by-library-and-function&quot;&gt;🔗&lt;&#x2F;a&gt;Profiling by library and function&lt;&#x2F;h2&gt;
&lt;p&gt;Addresses are unstable and anonymous memory is anonymous, but &lt;strong&gt;code has stable
names&lt;&#x2F;strong&gt;. A function sits at a fixed offset inside its library, so
&lt;code&gt;libxul.so!AppendElement&lt;&#x2F;code&gt; is the same identity on every run.&lt;&#x2F;p&gt;
&lt;p&gt;Hardware sampling reports the instruction pointer and the data address in the
&lt;em&gt;same sample&lt;&#x2F;em&gt;, which is enough to key a profile on which function touched which
kind of region:&lt;&#x2F;p&gt;
&lt;pre class=&quot;z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;  samples   per sec  region      perms  function
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;      810       202  [stack]     rw-p   python3.12![unknown]
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;      367        92  [stack]     rw-p   python3.12!_PyEval_EvalFrameDefault
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;      361        90  [anon]      rw-p   python3.12!_PyEval_EvalFrameDefault
&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;An anomaly is then a pair the baseline has never seen — some function writing
somewhere it has never written before. That is a far sharper statement than
“page 0x7d34 is busy”, and it is legible without a debugger.&lt;&#x2F;p&gt;
&lt;p&gt;Getting the samples required working around the tooling. On this hardware
&lt;code&gt;perf mem record&lt;&#x2F;code&gt; cannot attach to a running process: it wraps AMD IBS as
&lt;code&gt;ibs_op&#x2F;ldlat=0&#x2F;&lt;&#x2F;code&gt;, which is rejected for an existing PID. The raw event works —
&lt;code&gt;perf record -e ibs_op&#x2F;&#x2F; -d -p PID&lt;&#x2F;code&gt; — and the &lt;code&gt;-d&lt;&#x2F;code&gt; is essential, because without
it every sample carries no data address at all and &lt;code&gt;perf script -F addr&lt;&#x2F;code&gt; refuses
outright. Stores are separated from loads afterwards, from the &lt;code&gt;data_src&lt;&#x2F;code&gt; field.&lt;&#x2F;p&gt;
&lt;p&gt;Kernel-context samples are dropped. Their data addresses are kernel addresses,
absent from the process’s maps, and not a property of the application being
profiled. Leaving them in produced three spurious “never seen before” findings
in the first control run.&lt;&#x2F;p&gt;
&lt;p&gt;Baseline stability is good: independent runs of the same workload produced 37
distinct function-to-region pairs, twice.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-negative-result&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-negative-result&quot; aria-label=&quot;Anchor link for: the-negative-result&quot;&gt;🔗&lt;&#x2F;a&gt;The negative result&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;The symbol profiler did not detect the injection.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This deserves to be stated plainly, because the two instruments look
interchangeable and are not.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th&gt;injected &lt;code&gt;[anon]&lt;&#x2F;code&gt; store rate&lt;&#x2F;th&gt;&lt;th&gt;baseline&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;measured&lt;&#x2F;td&gt;&lt;td&gt;118&#x2F;s&lt;&#x2F;td&gt;&lt;td&gt;112&#x2F;s, 121&#x2F;s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Statistically identical. The control was clean and the detection was clean, and
the second of those is a miss.&lt;&#x2F;p&gt;
&lt;p&gt;The reason is structural. &lt;strong&gt;IBS samples store volume.&lt;&#x2F;strong&gt; Eight hundred stores per
second of injected activity is invisible against millions of interpreter stores
per second — it does not shift the sampled distribution at all. Soft-dirty
counts &lt;strong&gt;pages touched&lt;&#x2F;strong&gt;, and two extra hot pages are obvious against a working
set of thirty-five. Worse for the symbol view, an interpreter funnels everything
through one eval loop, so the function key carries almost no information about
this workload.&lt;&#x2F;p&gt;
&lt;p&gt;So the two instruments answer different questions, and the difference is not
cosmetic:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Page frequency&lt;&#x2F;strong&gt; is sensitive to a &lt;em&gt;small region changing fast&lt;&#x2F;em&gt;. It is the
right first instrument for machine-speed activity.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Symbol sampling&lt;&#x2F;strong&gt; is sensitive to a &lt;em&gt;shift in where the bulk of memory
traffic happens&lt;&#x2F;em&gt;, and to code appearing where it has no business being — a new
library writing somewhere, injected code carrying its own symbol, any function
writing into an executable mapping.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;A detector that had only been tested on the case it catches would have shipped
with the wrong claim attached.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;they-compose-in-one-direction&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#they-compose-in-one-direction&quot; aria-label=&quot;Anchor link for: they-compose-in-one-direction&quot;&gt;🔗&lt;&#x2F;a&gt;They compose in one direction&lt;&#x2F;h3&gt;
&lt;p&gt;The miss points at the fix. Sampling is proportional to traffic volume, so a
low-volume region is invisible in a whole-process profile — but not in a profile
narrowed to that region and sampled hard.&lt;&#x2F;p&gt;
&lt;pre class=&quot;z-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;# 1. where — page frequency finds the anomaly
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;memwatch.py compare myapp cap.json
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;   -&amp;gt; 0x71feaa875000   2,798 Hz   baseline 192 Hz @rank 8
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;# 2. who — hardware sampling attributes it
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;memwatch-sym.py attribute $PID 0x71feaa875000+2 -d 15 --period 2000
&lt;&#x2F;span&gt;&lt;span class=&quot;z-text z-plain&quot;&gt;   -&amp;gt; 8,690 stores   python3.12!_PyEval_EvalFrameDefault
&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Narrowed to two pages, 17,515 of 261,613 addressed samples landed inside the
range — 6.7% of all sampled memory traffic, from a region that was undetectable
in the whole-process view minutes earlier.&lt;&#x2F;p&gt;
&lt;p&gt;Find the &lt;em&gt;where&lt;&#x2F;em&gt; with the instrument that measures rate. Attribute the &lt;em&gt;who&lt;&#x2F;em&gt;
with the instrument that measures volume. Neither substitutes for the other.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-costs-and-where-it-stops&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-it-costs-and-where-it-stops&quot; aria-label=&quot;Anchor link for: what-it-costs-and-where-it-stops&quot;&gt;🔗&lt;&#x2F;a&gt;What it costs, and where it stops&lt;&#x2F;h2&gt;
&lt;p&gt;The cheap path has a scaling property worth knowing before trusting its output.
&lt;strong&gt;&lt;code&gt;clear_refs&lt;&#x2F;code&gt; walks every page-table entry in the process&lt;&#x2F;strong&gt;, so its cost tracks
the whole address space rather than the part under investigation.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;target&lt;&#x2F;th&gt;&lt;th&gt;mapped pages&lt;&#x2F;th&gt;&lt;th&gt;&lt;code&gt;clear_refs&lt;&#x2F;code&gt;&lt;&#x2F;th&gt;&lt;th&gt;full pagemap read&lt;&#x2F;th&gt;&lt;th&gt;usable ceiling&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;synthetic&lt;&#x2F;td&gt;&lt;td&gt;7,309&lt;&#x2F;td&gt;&lt;td&gt;0.06 ms&lt;&#x2F;td&gt;&lt;td&gt;0.07 ms&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;~2,800 Hz&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Firefox&lt;&#x2F;td&gt;&lt;td&gt;~3,666,000&lt;&#x2F;td&gt;&lt;td&gt;5.2 ms&lt;&#x2F;td&gt;&lt;td&gt;38.0 ms&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;~160 Hz&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Narrowing the sweep to candidate pages removes the 38 ms read entirely. It
&lt;strong&gt;cannot touch the 5.2 ms reset.&lt;&#x2F;strong&gt; On a multi-gigabyte process, anything above
roughly 160 Hz reads as “at ceiling”, and going finer requires the hardware
path — which is precisely where the ceiling-population signal earns its place.&lt;&#x2F;p&gt;
&lt;p&gt;On that same Firefox process the instrument still did its job: two anonymous
pages pinned above the ceiling at a robust z-score of 108, standing clear of a
~25 Hz background of allocator and library churn. Saturated, but unambiguous.&lt;&#x2F;p&gt;
&lt;p&gt;The rest of the limits, stated once:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Soft-dirty attributes a write to a page, never to code.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;hashdiff&lt;&#x2F;code&gt; misses writes that restore a previous value, and costs a full read
of the working set per sample. The &lt;code&gt;?&lt;&#x2F;code&gt; flag marks where this has happened.&lt;&#x2F;li&gt;
&lt;li&gt;Hardware sampling is statistical. A rate from it is &lt;em&gt;samples&lt;&#x2F;em&gt; per second, not
stores per second — comparable only against captures taken at the same sample
period.&lt;&#x2F;li&gt;
&lt;li&gt;Both tools observe a running process. Neither is a forensic dump analyser.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;os.access()&lt;&#x2F;code&gt; is not a sufficient permission check for either path. It
consults file permissions only, while &lt;code&gt;pagemap&lt;&#x2F;code&gt; additionally requires
&lt;code&gt;PTRACE_MODE_READ&lt;&#x2F;code&gt; and &lt;code&gt;&#x2F;proc&#x2F;PID&#x2F;mem&lt;&#x2F;code&gt; requires &lt;code&gt;PTRACE_MODE_ATTACH&lt;&#x2F;code&gt;, which
&lt;code&gt;yama&#x2F;ptrace_scope ≥ 1&lt;&#x2F;code&gt; denies for any process that is not a descendant. The
tools probe by opening for real.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;macOS and Windows collectors are not implemented.&lt;&#x2F;strong&gt; The analysis is
platform-independent and &lt;code&gt;hashdiff&lt;&#x2F;code&gt; is the shape they would take, but that
code is not written, and neither platform has a soft-dirty equivalent — both
would run at hashdiff cost with hashdiff’s blind spot.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-generalises&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-generalises&quot; aria-label=&quot;Anchor link for: what-generalises&quot;&gt;🔗&lt;&#x2F;a&gt;What generalises&lt;&#x2F;h2&gt;
&lt;p&gt;Three things here are not about memory.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;A binary observation per interval is not a rate.&lt;&#x2F;strong&gt; It looks like one, it
tabulates like one, and it saturates without saying so. Wherever an instrument
reports whether something happened during a window, the window is part of the
measurement, and one window is not enough.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Sweeping a parameter recovers what a single setting destroys.&lt;&#x2F;strong&gt; The frequency
was never in any individual sample; it was in how the answers changed as the
window shrank.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;A detector should be tested against the case it misses.&lt;&#x2F;strong&gt; The control run and
the detection run together are the experiment — either alone supports a claim
that the other refutes. The symbol profiler’s clean control looked like success
until the injected workload produced the same clean result.&lt;&#x2F;p&gt;
&lt;p&gt;For the adversarial case specifically, the most durable signal turned out to be
the crudest one: not how fast a page changes, which the instrument can only
measure up to its own ceiling, but &lt;strong&gt;how many pages are changing faster than the
instrument can follow&lt;&#x2F;strong&gt;. That count is not capped, it is stable across runs of
the same workload, and it moved the moment something started operating at a
speed the baseline had never contained.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The asymmetry does not survive contact</title>
        <published>2026-09-11T00:00:00+00:00</published>
        <updated>2026-09-11T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Matthew J. Harmon
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://adversarialagents.org/posts/the-asymmetry/"/>
        <id>https://adversarialagents.org/posts/the-asymmetry/</id>
        
        <content type="html" xml:base="https://adversarialagents.org/posts/the-asymmetry/">&lt;p&gt;The oldest line in this trade is that the attacker has to be right once and the
defender has to be right every time. It was never quite true — attackers fail
loudly and often, and most of them are not very good — but it described
something real about the shape of the work. One side searches. The other side
covers.&lt;&#x2F;p&gt;
&lt;p&gt;Autonomy was supposed to close that gap, because the side doing more work
benefits more from doing it faster. That is not what has happened so far, and
the reason is worth being precise about.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-an-attacking-agent-is-actually-good-at&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-an-attacking-agent-is-actually-good-at&quot; aria-label=&quot;Anchor link for: what-an-attacking-agent-is-actually-good-at&quot;&gt;🔗&lt;&#x2F;a&gt;What an attacking agent is actually good at&lt;&#x2F;h2&gt;
&lt;p&gt;Not exploitation. The exploitation step was already automated, and has been for
twenty years — that is what a scanner is.&lt;&#x2F;p&gt;
&lt;p&gt;What changed is the &lt;strong&gt;connective tissue&lt;&#x2F;strong&gt;. An agent will read a certificate
transparency log, notice a hostname nobody meant to publish, guess at what
framework it runs, pull the matching misconfiguration, try it, fail, read the
error, and try the adjacent thing. None of those steps is hard. Chaining them
without a human in the loop is what used to cost time.&lt;&#x2F;p&gt;
&lt;p&gt;That is a search problem, and search is exactly what these systems do well. An
attacking agent can be wrong ninety-nine times in an hour at no cost, because
the ninety-nine failures are free and the hundredth is the whole engagement.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-a-defending-agent-is-asked-to-do&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-a-defending-agent-is-asked-to-do&quot; aria-label=&quot;Anchor link for: what-a-defending-agent-is-asked-to-do&quot;&gt;🔗&lt;&#x2F;a&gt;What a defending agent is asked to do&lt;&#x2F;h2&gt;
&lt;p&gt;Something structurally harder, and it is not “the same thing but blue.”&lt;&#x2F;p&gt;
&lt;p&gt;A defending agent operates where &lt;strong&gt;being wrong is expensive in both
directions&lt;&#x2F;strong&gt;. Miss something and the intrusion continues. Act on something
benign and you have isolated a production host, revoked a working credential,
or paged six people at 03:00 because a backup job looked like exfiltration.&lt;&#x2F;p&gt;
&lt;p&gt;An attacking agent’s false positives cost it nothing. A defending agent’s false
positives are the reason nobody trusts it with anything that matters — which
means it gets deployed in advisory mode, which means it does not act, which
means it is a very expensive alerting system.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;The attacker’s agent is allowed to be stupid ninety-nine times. The
defender’s agent is allowed to be stupid approximately never.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;h2 id=&quot;where-this-actually-bites&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#where-this-actually-bites&quot; aria-label=&quot;Anchor link for: where-this-actually-bites&quot;&gt;🔗&lt;&#x2F;a&gt;Where this actually bites&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Speed is not the differentiator people think.&lt;&#x2F;strong&gt; Defenders already have
machine-speed controls: rate limits, WAF rules, EDR quarantine. Those work
because they are narrow and their failure mode is understood. The slow part of
defense was never detection latency, it was &lt;strong&gt;deciding whether to act&lt;&#x2F;strong&gt; — and
that is precisely the part an agent is least trusted to own.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Autonomy needs reversibility.&lt;&#x2F;strong&gt; The defensive actions safe to automate are
the ones you can undo: isolate a host, hold a message, force reauth. The ones
that matter most — revoke, wipe, disable, block at the edge — are the ones
where being wrong has a cost you cannot take back. An agent that can only take
reversible actions is useful, but it is not symmetric with an attacker that has
no such constraint.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Evidence outlives both.&lt;&#x2F;strong&gt; Whatever an attacking agent does, you will be asked
afterwards what happened. Whatever a defending agent does, you will be asked
the same thing — and “the model decided to” is not an answer that survives a
regulator, an insurer, or a board. The audit trail is now a defensive control
in its own right, and it has to be written somewhere neither agent can reach.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-the-asymmetry-runs-the-other-way&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#where-the-asymmetry-runs-the-other-way&quot; aria-label=&quot;Anchor link for: where-the-asymmetry-runs-the-other-way&quot;&gt;🔗&lt;&#x2F;a&gt;Where the asymmetry runs the other way&lt;&#x2F;h2&gt;
&lt;p&gt;There is one place the defender is structurally ahead, and it falls out of the
same property that makes the attacking agent cheap: &lt;strong&gt;it has to run its loop on
your ground.&lt;&#x2F;strong&gt; The ninety-nine free failures are free to the attacker and
legible to you.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Every one of those ninety-nine failures happens against your hardware, in
your logs, on text that you wrote.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The mistake is trying to detect “an AI.” Nothing in a request reliably says a
language model produced it, and the tools sold on that premise are not
calibrated for anything. What &lt;em&gt;is&lt;&#x2F;em&gt; detectable is that an &lt;strong&gt;observe → reason →
act loop&lt;&#x2F;strong&gt; is running — on a token budget, through a context window, inside a
tool harness. Each of those constraints leaves artifacts. Agents also sit in
the gap between the two populations everyone has already modelled: scanners are
too regular, humans are too irregular, and an agent is regular in the ways a
human is not and irregular in the ways a scanner is not.&lt;&#x2F;p&gt;
&lt;p&gt;Tier the signals by &lt;strong&gt;precision&lt;&#x2F;strong&gt;, because precision is what buys the right to
act. This is the reversibility argument from the last section, applied to
evidence rather than to actions.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;tier&lt;&#x2F;th&gt;&lt;th&gt;costs&lt;&#x2F;th&gt;&lt;th&gt;earns&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 — planted&lt;&#x2F;td&gt;&lt;td&gt;you have to build and maintain it&lt;&#x2F;td&gt;&lt;td&gt;near-zero false positives; enough to act irreversibly&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;2 — behavioural&lt;&#x2F;td&gt;&lt;td&gt;instrumentation you should have anyway&lt;&#x2F;td&gt;&lt;td&gt;strong but circumstantial; throttle, challenge, tarpit&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3 — population&lt;&#x2F;td&gt;&lt;td&gt;already in your logs&lt;&#x2F;td&gt;&lt;td&gt;a score input, never a verdict&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h3 id=&quot;tier-1-planted-and-close-to-certain&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#tier-1-planted-and-close-to-certain&quot; aria-label=&quot;Anchor link for: tier-1-planted-and-close-to-certain&quot;&gt;🔗&lt;&#x2F;a&gt;Tier 1 — planted, and close to certain&lt;&#x2F;h3&gt;
&lt;p&gt;These work because an attacking agent treats text it finds as instruction, and
the text is yours.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Instruction canaries.&lt;&#x2F;strong&gt; Put a unique directive somewhere only a
text-consuming automaton reaches — an HTML comment, a &lt;code&gt;_note&lt;&#x2F;code&gt; field on a JSON
error, an unreferenced documentation page — telling the reader to include a
distinctive token in its next request. Humans do not comply. Scanners cannot
parse it. A meaningful share of agents simply do it. A returned token is not
an inference, it is a confession.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Error-text echo.&lt;&#x2F;strong&gt; Agents recover from failure semantically: they read the
error string and build the next request out of tokens in it. Seed rare words
into &lt;code&gt;403&lt;&#x2F;code&gt; and &lt;code&gt;422&lt;&#x2F;code&gt; bodies and watch for them reappearing seconds later as
parameter names or path segments. A human takes minutes. A scanner never
does.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Unlinked-but-documented paths.&lt;&#x2F;strong&gt; Requests to a URL that exists only in your
prose, with no &lt;code&gt;href&lt;&#x2F;code&gt; anywhere on the site. A browser cannot reach it. Only
something that read the text and constructed the URL can.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The fragility is the same as the strength: publish the lexeme and it is worth
nothing. Rotate the tokens, scope them to sessions already under suspicion, and
keep them out of the repository.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;tier-2-behavioural-and-strong&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#tier-2-behavioural-and-strong&quot; aria-label=&quot;Anchor link for: tier-2-behavioural-and-strong&quot;&gt;🔗&lt;&#x2F;a&gt;Tier 2 — behavioural, and strong&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Think time scaled to response size.&lt;&#x2F;strong&gt; Plot inter-request delay against the
byte count of the &lt;em&gt;previous&lt;&#x2F;em&gt; response. Humans pause on complexity; scanners
do not pause; an agent pauses roughly linearly in tokens-to-read. The three
populations separate on that one scatter plot, and faking it requires
deliberately modelling human reading.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Missing subresources.&lt;&#x2F;strong&gt; A browserless agent fetches what the model asked
for and nothing else — no CSS, no fonts, no favicon. A Chrome user-agent with
zero subresource fetches is a contradiction.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Fingerprint incoherence.&lt;&#x2F;strong&gt; JA4, the HTTP&#x2F;2 &lt;code&gt;SETTINGS&lt;&#x2F;code&gt; and header-order
hash, the user-agent string and the TCP fingerprint should all tell the same
story. The tell is rarely any single layer; it is layer &lt;em&gt;n&lt;&#x2F;em&gt; reporting Chrome
131 while layer &lt;em&gt;m&lt;&#x2F;em&gt; reports Python 3.12.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Library defaults.&lt;&#x2F;strong&gt; Timeouts at exactly 30, 60 or 120 seconds. Three
retries. Concurrency of five or ten. Backoff at exactly 2×. Agents inherit
whatever their tool wrapper shipped with, and almost nobody tunes it.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Amnesia.&lt;&#x2F;strong&gt; Truncated context and non-sharing subagents cause re-fetches of
things already retrieved, with no &lt;code&gt;If-None-Match&lt;&#x2F;code&gt; and no &lt;code&gt;If-Modified-Since&lt;&#x2F;code&gt;.
A human keeps notes. An agent re-derives.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Lexical provenance of guesses.&lt;&#x2F;strong&gt; Where the candidates came from. A
dictionary attack against SMTP &lt;code&gt;RCPT&lt;&#x2F;code&gt; walks a wordlist — &lt;code&gt;admin@&lt;&#x2F;code&gt;, &lt;code&gt;info@&lt;&#x2F;code&gt;,
&lt;code&gt;sales@&lt;&#x2F;code&gt;. An agent guesses names it read off your website: low-frequency,
real, and correlated with public content. The same split shows up in guessed
paths and guessed parameter names.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Enough to throttle, challenge or tarpit. Not enough to block, because a badly
written legitimate client trips several of them at once.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;tier-3-population-and-cheap&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#tier-3-population-and-cheap&quot; aria-label=&quot;Anchor link for: tier-3-population-and-cheap&quot;&gt;🔗&lt;&#x2F;a&gt;Tier 3 — population, and cheap&lt;&#x2F;h3&gt;
&lt;p&gt;ASN and egress reputation. Path-sequence entropy, fitted as an n-gram over
transitions — a human follows the link graph, a scanner follows a wordlist, an
agent sits between them. Natural language turning up in fields that should not
contain any. These are already in the logs and cost nothing to compute; none of
them is a verdict.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;slowing-it-down&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#slowing-it-down&quot; aria-label=&quot;Anchor link for: slowing-it-down&quot;&gt;🔗&lt;&#x2F;a&gt;Slowing it down&lt;&#x2F;h3&gt;
&lt;p&gt;The economics differ from a human adversary. This one is metered: a token
budget and a context window, both finite, both billed.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Semantic denial.&lt;&#x2F;strong&gt; Uniform, low-entropy errors across every failure mode.
The loop runs on your feedback; starve it and it flails. Cheapest measure
here, and it costs only your own debuggability.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Per-session nondeterminism.&lt;&#x2F;strong&gt; Rotate field names, parameter ordering and
endpoint shapes per session so cached reasoning goes stale. Browsers do not
care. Agents spend context re-deriving.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Stateful multi-step flows&lt;&#x2F;strong&gt; with server-side nonces on short TTLs, raising
the number of tool calls per unit of progress — which is where long runs
derail.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Proof of work&lt;&#x2F;strong&gt; at the edge. It will not stop an operator who wants you
specifically; it changes the volume economics for everyone else.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Tarpits&lt;&#x2F;strong&gt; serving endless plausible content, if you will accept the log
pollution and the risk of poisoning your own analytics.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;what-does-not-work&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-does-not-work&quot; aria-label=&quot;Anchor link for: what-does-not-work&quot;&gt;🔗&lt;&#x2F;a&gt;What does not work&lt;&#x2F;h3&gt;
&lt;p&gt;User-agent matching, spoofed in one line. CAPTCHAs, which multimodal models
now beat and which tax your actual users. Generated-text detectors. And
blocking on any single signal, which costs you the observability that
everything above depends on.&lt;&#x2F;p&gt;
&lt;p&gt;None of these rules will last. Agent behaviour moves with every model release,
so build a &lt;strong&gt;durable feature pipeline and disposable rules&lt;&#x2F;strong&gt; — log the
primitives cheaply and forever, and expect to rewrite the scoring quarterly.
Then band the response rather than classifying: observe, tarpit, challenge,
block, in that order, with only Tier 1 permitted the last one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-honest-position&quot;&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-honest-position&quot; aria-label=&quot;Anchor link for: the-honest-position&quot;&gt;🔗&lt;&#x2F;a&gt;The honest position&lt;&#x2F;h2&gt;
&lt;p&gt;Agents shift the attacker’s cost curve more than the defender’s, because the
attacker is doing search and the defender is doing judgement, and only one of
those is cheap to get wrong.&lt;&#x2F;p&gt;
&lt;p&gt;That is not an argument against defensive automation. It is an argument for
being specific about which decisions you are automating, what happens when the
agent is wrong, and who finds out. Most of the answer is old: scope the
credentials, gate the irreversible, keep the log out of reach.&lt;&#x2F;p&gt;
&lt;p&gt;The tooling is new. The posture is not.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
