Every window of traffic, per credential, this system computes about twenty
features. Six of them drive decisions. The rest are computed, stored, displayed,
and deliberately never allowed to escalate anything.
That split is the most useful thing in this article, so let's get to why.
The trap
You want to detect someone reading your whole user table. The obvious signals:
-
distinct_resource_ids, how many distinct objects were touched -
novelty_ratio, what share had never been touched by this client before -
repeat_ratio, what share had been
An enumerator maxes all three: thousands of objects, nearly all new, almost no
repeats. It looks damning.
Now run a legitimate post-deploy backfill through the same features. Thousands of
objects. Nearly all new. Almost no repeats.
They are identical. Not similar, identical in shape, because both are "read a
large number of records this client has not read before". Cardinality is a measure
of how much was read, and how much is exactly the thing the two have in common.
I tried it anyway, with corroboration from other signals, because it felt like it
should be salvageable. It fired on backfill windows whenever the timing jitter
happened to dip. There is no threshold that separates them, because the
distributions overlap by construction.
The reframe
Stop asking how much was read. Ask how it was accessed.
- A backfill reads records in its own key order, whatever order its job queue produced. An enumerator walks the ID space.
- A backfill reads records that exist; it has a list. An enumerator guesses, and guesses wrong most of the time.
- A backfill is driven by a system with queues and retries, so its timing has jitter. A tight loop does not.
- A backfill runs from one place. A credential being replayed doesn't.
- A backfill ends.
Each of those is a feature, and each carries the distinction that cardinality
doesn't.
id_sequentiality
The fraction of consecutive requested IDs whose absolute gap is ≤ 2, in arrival
order.
export function idSequentiality(idsInOrder: string[]): number {
const nums: number[] = [];
for (const id of idsInOrder) if (/^\d+$/.test(id)) nums.push(Number(id));
if (nums.length < 2) return 0;
let adjacent = 0;
for (let i = 1; i < nums.length; i++) {
if (Math.abs(nums[i]! - nums[i - 1]!) <= 2) adjacent++;
}
return adjacent / (nums.length - 1);
}
Near 1.0 for an ID walk, near 0 for random access or hot-object reads. Note it
only applies to integer-like IDs, which is the whole argument for opaque
identifiers, and why the demo's dataset deliberately has both integer (users,
orders) and UUID (documents) resource types.
miss_ratio and not_found_ratio
Share of responses ≥ 400, and share that are 404. An integration reading records
it has references to has a miss ratio near zero. A guesser's is near one. This is
the single most reliable signal in the system and it's almost embarrassingly
simple.
interarrival_cv
Coefficient of variation (σ/μ) of the gaps between requests. Low CV means
machine-regular. Legitimate traffic sits around 0.5–0.7; a tight loop is
essentially 0.
Note the direction is inverted here: low is suspicious. That's worth
flagging because most anomaly-detection plumbing assumes high-is-bad, and this
feature is a reminder to make direction an explicit per-feature property rather
than an assumption.
distinct_src_ips / distinct_asns
One credential appearing from many origins. Catches the distributed profile,
where per-origin rate limiting sees nothing because each IP issues a trickle.
endpoint_kl_divergence
Kullback–Leibler divergence between this window's route mix and the client's own
trailing route mix, in bits. A dictionary attack pivots traffic onto the search
endpoint, and KL measures exactly that pivot.
The implementation detail that matters is smoothing. A route the client has
never used before would produce infinite divergence, which would swamp every
other feature forever. Additive smoothing on the baseline makes it large but
finite:
const alpha = 0.5;
const qTotal = [...q.values()].reduce((a, b) => a + b, 0) + alpha * keys.size;
An integration hitting a brand-new endpoint should register as surprising, not
as an unbounded outlier.
subject_mismatch_ratio
The one that catches an attack nothing else sees.
Consider a compromised integration holding a genuine delegation for one user.
Every request carries a valid token, valid scopes, and a subject it really is
entitled to act for. Per-request authorization has nothing to object to: a token
that may act for Alice is acting for Alice.
What it cannot see is that Alice's delegation is being used to read everyone
else's records.
So the resource API reports who owns each record, the gateway records that beside
the subject, and the feature is the share of delegated requests where the two
differ. An honest on-behalf-of integration reads the record of the person it's
acting for, so this sits at zero.
Measured on the first window of the obo-sweep profile: miss_ratio 0,
subject_mismatch_ratio 1.0, caught on subject_mismatch_ratio (z=20.0, +72)
alone, with no other signal contributing anything.
One implementation note with a security edge: the owner header is set on hits
and misses, empty when there's nothing to report. Present only on hits, it
would turn the response shape into an existence oracle.
novelty_run_length, the feature about time
This is the one that finally caught mimicry, and it's the most interesting
feature in the system because it isn't about the shape of a window at all.
Mimicry hides enumeration inside replayed legitimate traffic. Normal rate, normal
timing, valid IDs, no ordering, miss ratio diluted below every threshold. Against
the feature set above it scored zero on every window, because every one of
those features describes a single window, and in any single window mimicry is
legitimate traffic.
What separates it from a benign bulk read is not shape but duration. A
backfill ends. An ongoing compromise does not.
So novelty_run_length counts consecutive windows in which the client's working
set kept growing, and a guardrail fires at 20: far beyond any plausible backfill
episode. Detection is correspondingly slow, and the project claims no latency
bound for it.
Two design details worth arguing with:
- It is deliberately not corroborated by a low repeat ratio. Mimicry's method is to keep re-reading the hot set as cover, which holds repeat ratio high; requiring a low one would exclude precisely the traffic it exists to catch. (I had this wrong first, and the guardrail excluded its own target.)
- It is evaluated only on the 1-minute window. Over longer windows a legitimate client's occasional stale references accumulate, each is an ID never seen before, until its novelty ratio never returns to baseline and the run stops distinguishing anything.
The honest limitation: an integration whose legitimate job is to page through an
ever-growing corpus trips this, and needs an explicit exemption rather than a
cleverer threshold.
The features that are computed and never scored
This is the part I'd most like to convince you of.
Several features are computed every window, written to ClickHouse, exported in
the training set, and shown in the attribution breakdown, but are excluded from
the score. That is a design decision, not an unfinished TODO.
distinct_resource_ids, novelty_ratio, repeat_ratio, the trap from the
top of this article. They establish that a bulk access is happening, which is
useful context for a human, and they cannot separate the two hypotheses.
hour_of_day_zscore, how unusual this window's volume is for this client at
this time of week. Tempting, and wrong: an off-schedule volume spike is precisely
what a legitimate post-deploy backfill looks like. Weighting it would buy nothing
but false positives on the one case that most needs to be right.
distinct_subjects: how many principals a credential acted for. Saturates
for any legitimate bulk job, exactly as cardinality does.
replayed_jti: the share of traffic riding a token seen from more than one
host. This is the shape of a stolen bearer token, and I very much wanted it to
work. It's excluded because I measured it: weighted at all, a perfectly benign
client, one that mints a single token and shares it across its own workers,
scored 100 and was denied, with +53 of that coming from this feature alone.
One token from several hosts is the shape of theft and the shape of a shared
token cache. Separating them needs origin-relationship data (AS ownership,
geography) this system doesn't model. So it's recorded for a human to act on,
which is not the same as the system acting on it.
That last one is the general pattern, and it's worth stating plainly: the
signals that look most damning are often the ones that saturate for some entirely
ordinary behaviour. The only reason this one didn't ship as a false-positive
generator is that the weight was measured against a benign client before being
kept.
Windows
Everything above is computed at 1m, 5m and 1h, per client, in tumbling windows.
Multiple scales are not redundancy. slow-and-low issues about two requests a
minute, so its 1m windows fall below the minimum request count and score nothing
at all, its signal only exists at 5m and 1h. Conversely novelty_run_length is
only meaningful at 1m.
Different attacks are visible at different scales, and that is the entire
premise of the pipeline. It's also the premise that a bug quietly violated for
months, which is article 8.
Next: three scoring layers, why they combine by maximum rather than sum, and
why per-feature attribution is the point rather than a nice-to-have.
Top comments (0)