{"id":494307,"date":"2026-09-11T05:07:26","date_gmt":"2026-09-11T05:07:26","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=494307"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=494307","title":{"rendered":"Looking for lateral movement with a neural network trained on synthetic data"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>Can you train a cyberattack detector without ever showing it a real cyberattack?<\/p>\n<p>It sounds like a contradiction. If you want a neural network to detect lateral movement, you would expect to show it lateral movement. I did the opposite: I generated an entire corporate network with its login history, staged an attack inside that artificial world, and trained networks on it. Not a single real row in the training data. The whole world is a 135-line config; each network has four thousand parameters and trains in seconds on a laptop, and the best result came from six of them, trained on six different invented worlds.<\/p>\n<p>Then I pointed them at real data: the authentication logs of Los Alamos National Laboratory, 1.65 billion events, with red-team exercises labelled in them.<\/p>\n<p><strong>And it worked.<\/strong> The networks rank 3.6 million windows by suspicion, and the top twenty-three rows of that list hold sixteen real attacks and seven false alarms: all the analyst has to do is open those rows. A threshold counter on the same data needs a hundred and sixty-one thousand false alarms to reach the sixteenth attack. By AUC the synthetic training landed inside the range of published research trained on real labelled data, although the two cannot be compared head-on, and I will explain why.<\/p>\n<p>No miracle happened. Something else did: twice I wrote down an elegant conclusion, and twice the experiment made me take it back. And I found the loop that makes a data generator worth writing at all: the detector\u2019s error points at one specific machine, you work out which phenomenon your invented world lacks, you add two lines to the config, and the error is gone.<\/p>\n<p>The rest, in order: who I am and why I needed this, what lateral movement is, how the invented world is built, what the exam showed, and how much of it is honest. If you do not need the theory, skip ahead to <a href=\"#first-result\" rel=\"noopener nofollow\">\u201cThe first result on real data\u201d<\/a>.<\/p>\n<p><em>I write TDCV2, an open-source constructor for test data, and I wanted to know what it is actually good for. Filling a table with fake Smiths is one thing; producing data that something working can be trained on is another. You can\u2019t check that on a toy, so I took a real information-security problem, trained a classifier on invented data only, and turned it loose on the logs of a real network at Los Alamos National Laboratory.<\/em><\/p>\n<p>Three caveats, so the rest reads the way it should.<\/p>\n<p>I am <strong>not a machine-learning specialist<\/strong>. Everything below is the work of a person who writes a data generator and decided to test it on a serious problem. If it looks to you like I\u2019m reinventing a wheel somewhere, or reaching for the wrong tool &#8212; you\u2019re probably right.<\/p>\n<p>I am <strong>not competing with anyone<\/strong>. The results from research groups further down are there as a coordinate system, so the reader has a sense of scale, not because I\u2019m claiming a place in that row.<\/p>\n<p>I <strong>wasn\u2019t counting on a result<\/strong>. Honestly, I expected the synthetic data to do worse! It went differently, and that\u2019s the interesting part. There was no miracle anyway, but two things happened that are more interesting than a miracle: a measured boundary of a whole class of methods, and a limit on training from generated data that I then retracted myself.<\/p>\n<h3>What lateral movement is<\/h3>\n<p>Since the headline opens with a bit of jargon, let me explain what this beast is first. All the more so because the phenomenon is built in a non-obvious way, and half of your intuition about \u201chacking\u201d doesn\u2019t apply.<\/p>\n<p>There\u2019s a common picture in which the attacker cracks a password and that\u2019s the end of it. In reality, getting a login somewhere is only the beginning, and it is almost never the place they wanted. The first thing to fall is somebody\u2019s workstation: a person opened an attachment, or a service with an old hole was exposed to the outside. There is nothing valuable on that machine. The valuable things are in the database, on the file server, in the domain controller &#8212; and those still have to be reached.<\/p>\n<p>Reaching them is what\u2019s called <strong>lateral movement<\/strong>. In the MITRE ATT&amp;CK classification it is a tactic of its own, TA0008, and its most troublesome technique is T1078, Valid Accounts.<\/p>\n<h4>How it spreads<\/h4>\n<p>The mechanism is simple. A captured machine almost always has somebody else\u2019s credentials lying on it: a cached domain login, a token in memory, a password in a maintenance script, a saved connection. The attacker takes them and logs in to the neighbouring machine &#8212; an ordinary login, done exactly the way a real employee does it. There they collect that machine\u2019s credentials. And round it goes.<\/p>\n<p>After that it\u2019s arithmetic: every step grows both the number of machines and the number of available accounts, and every new account opens new machines. One workstation becomes ten, ten become a hundred.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/6e1\/acb\/eae\/6e1acbeaedaeb6735b2fef9b4d0a6ed4.svg\" alt=\"How this spreads. In our data it was exactly like that: 94% of the labelled events came from a single foothold, and the exercise reached 301 machines in all\" title=\"How this spreads. In our data it was exactly like that: 94% of the labelled events came from a single foothold, and the exercise reached 301 machines in all\" width=\"700\" height=\"360\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/6e1\/acb\/eae\/6e1acbeaedaeb6735b2fef9b4d0a6ed4.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/6e1\/acb\/eae\/6e1acbeaedaeb6735b2fef9b4d0a6ed4.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>How this spreads. In our data it was exactly like that: 94% of the labelled events came from a single foothold, and the exercise reached 301 machines in all<\/em><\/figcaption><\/div>\n<\/figure>\n<p>The speed is nothing like the movies. Each step takes hours, the whole picture unfolds over days and weeks. That, broadly, is the one piece of good news &#8212; there is time to notice.<\/p>\n<h4>Who this affects, and who it doesn\u2019t<\/h4>\n<p>Lateral movement needs somewhere to move, laterally. It lives in large corporate networks: hundreds and thousands of machines, a shared domain, service accounts with access to many places at once, administrators who legitimately go everywhere.<\/p>\n<p>A small business is not threatened by this phenomenon as such &#8212; not because it is safer there, but because there is nowhere to \u201cmove\u201d. If a company is five people, three laptops and all the work in cloud services, then an attacker who has taken the accountant\u2019s laptop is already where the money is. They need no \u201ctactic\u201d, it\u2019s all at hand. Such a company will have other problems, and those are caught differently.<\/p>\n<p>So everything below is about a network with somewhere to go.<\/p>\n<h3>Why it is hard to catch<\/h3>\n<p>An awkward property of this phenomenon. Every single lateral-movement event is <strong>flawless<\/strong>: the password is correct, the answer is \u201csuccess\u201d, the account is genuine, the domain is our own, the time is business hours. There is nothing anomalous in it, because it really is an ordinary login &#8212; just made by the wrong person.<\/p>\n<p>To a signature detector, and to any threshold counter, it is indistinguishable from an employee doing their job.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/da5\/c45\/c63\/da5c45c6337a3c1f79b252e0d1058372.svg\" alt=\"An ordinary login on the left, lateral movement on the right. Every field is valid in both cases; a single event cannot tell them apart\" title=\"An ordinary login on the left, lateral movement on the right. Every field is valid in both cases; a single event cannot tell them apart\" width=\"700\" height=\"300\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/da5\/c45\/c63\/da5c45c6337a3c1f79b252e0d1058372.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/da5\/c45\/c63\/da5c45c6337a3c1f79b252e0d1058372.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>An ordinary login on the left, lateral movement on the right. Every field is valid in both cases; a single event cannot tell them apart<\/em><\/figcaption><\/div>\n<\/figure>\n<h3>Why I needed this particular problem<\/h3>\n<p>Now I can come back to where I started &#8212; the generator.<\/p>\n<p>Testing a data constructor on a problem whose answer is known in advance isn\u2019t interesting. You want one where invented data will either work or fail honestly, and where the outcome can be measured rather than eyeballed. Lateral movement fitted for three reasons, and each of them matters.<\/p>\n<ol>\n<li>\n<p><strong>Nobody has labelled examples.<\/strong> To train a detector the usual way, you need examples of the attack in your own network. And to get them, you first have to be compromised, notice it, and label it. Synthetic data here isn\u2019t a shortcut, it\u2019s the only way &#8212; if it works at all.<\/p>\n<\/li>\n<li>\n<p><strong>The phenomenon has a computable definition.<\/strong> \u201cAn account logs in where it has never been, from a machine where it has never been\u201d is a rule, not a picture. And a rule can be written down as a config without a single real example in front of you.<\/p>\n<\/li>\n<li>\n<p><strong>There is open data from a real network, with labelled ground truth in it.<\/strong> Which means the exam can be taken honestly: train on the invented, check on the live, and not grade your own homework.<\/p>\n<\/li>\n<\/ol>\n<p>The plan followed from that, and it was simple. Show the network not one real row &#8212; not in training, not in validation, not in threshold picking. Write the entire training world as a generator config. And then turn it loose on real logs and see what it finds there.<\/p>\n<p>The exam data here is the open Los Alamos set <a href=\"https:\/\/csr.lanl.gov\/data\/cyber1\/\" rel=\"noopener nofollow\">Comprehensive, Multi-Source Cyber-Security Events<\/a>: 58 days, 17,684 machines, 12,425 users, 1.65 billion events. What interests me is the authentication log.<\/p>\n<p>The labelled truth in it is the record of a red team exercise. And it looks like this:<\/p>\n<blockquote>\n<p>749 exercise events: 104 compromised accounts, 301 destination machines and a total of <strong>4 sources<\/strong>. 94% of the events come from a single foothold.<\/p>\n<\/blockquote>\n<p>One machine. A hundred stolen logins. Three hundred places.<\/p>\n<p>The exercise activity is spread across the days extremely unevenly, and from here on I refer to three sets constantly. Everything below is counted in <strong>windows<\/strong>: a window is one machine for one hour, everything it did in that hour. A labelled window is one that caught at least one exercise event. To keep it straight, let\u2019s fix the names right away:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"134\" width=\"134\">\n<p align=\"left\">set<\/p>\n<\/th>\n<th data-colwidth=\"381\" width=\"381\">\n<p align=\"left\">what it is<\/p>\n<\/th>\n<th data-colwidth=\"119\" width=\"119\">\n<p align=\"left\">windows<\/p>\n<\/th>\n<th>\n<p align=\"left\">labelled<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"134\" width=\"134\">\n<p align=\"left\"><strong>day 8<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"381\" width=\"381\">\n<p align=\"left\">the densest activity; debugging was done on it<\/p>\n<\/td>\n<td data-colwidth=\"119\" width=\"119\">\n<p align=\"left\">231,787<\/p>\n<\/td>\n<td>\n<p align=\"left\">15<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"134\" width=\"134\">\n<p align=\"left\"><strong>day 12<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"381\" width=\"381\">\n<p align=\"left\">second densest, also a working day<\/p>\n<\/td>\n<td data-colwidth=\"119\" width=\"119\">\n<p align=\"left\">223,987<\/p>\n<\/td>\n<td>\n<p align=\"left\">12<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"134\" width=\"134\">\n<p align=\"left\"><strong>held-out set<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"381\" width=\"381\">\n<p align=\"left\">the other sixteen days, never touched<\/p>\n<\/td>\n<td data-colwidth=\"119\" width=\"119\">\n<p align=\"left\">3,600,398<\/p>\n<\/td>\n<td>\n<p align=\"left\">64<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>Day 8 turned out to be easy &#8212; almost any model solves it. Day 12 is hard: on it the models finally differ. The held-out set is the only honest measure, and it will become clear why.<\/p>\n<h3>The rule that saved the project: the exam first<\/h3>\n<p>My first impulse was to sit down and write the generator config. Good thing I didn\u2019t.<\/p>\n<p>I spent half a day opening up and measuring the real data <em>before<\/em> the first line of config. And it turned out the problem was not the one I had pictured: I was thinking about password guessing, and what\u2019s labelled is an entirely different phenomenon. Everything I would have written in advance would have gone in the bin, all of it.<\/p>\n<p>The reconnaissance produced three facts that determined absolutely everything that followed.<\/p>\n<h4>The account name carries no useful signal<\/h4>\n<p>The main \u201cvictim\u201d of the exercise &#8212; the account through which the most labelled events passed &#8212; appears <strong>30,497 times<\/strong> in a clean four-hour slice. That is the busiest regular administrator, and catching them as an anomaly is pointless.<\/p>\n<p>Which means the feature has to be not about <em>whose<\/em> account it is, but about <em>where it came from<\/em>. Not a property, but a relationship.<\/p>\n<h4>The naive counter is fooled by the shape of the network<\/h4>\n<p>The hypothesis \u201cmany different accounts from one machine is suspicious\u201d breaks against the topology:<\/p>\n<pre><code class=\"bash\">2996  2752  2686  2590   domain controllers 252   118   110          file servers   1     2     1          workstations<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The foothold with its hundred accounts falls <em>between<\/em> the controllers and the workstations, and is indistinguishable from a small server. With a counter over destination machines it is exactly the same story.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/250\/3b1\/271\/2503b1271c4745b526de6fbfa013c88a.svg\" alt=\"A threshold that catches the foothold covers half the servers. A threshold that leaves the servers alone misses the foothold\" title=\"A threshold that catches the foothold covers half the servers. A threshold that leaves the servers alone misses the foothold\" width=\"700\" height=\"320\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/250\/3b1\/271\/2503b1271c4745b526de6fbfa013c88a.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/250\/3b1\/271\/2503b1271c4745b526de6fbfa013c88a.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>A threshold that catches the foothold covers half the servers. A threshold that leaves the servers alone misses the foothold<\/em><\/figcaption><\/div>\n<\/figure>\n<h4>The real signal is the novelty of the connection<\/h4>\n<p>Lateral movement produces <strong>new edges<\/strong> in the login graph: the pair \u201caccount &#8212; machine it has never logged in from\u201d, or \u201cmachine &#8212; place it has never reached before\u201d. And here is the measurement that made the reconnaissance worth doing. The share of new edges in normal activity:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"421\" width=\"421\">\n<p align=\"left\">share of new edges in normal activity<\/p>\n<\/th>\n<th>\n<p align=\"left\">value<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"421\" width=\"421\">\n<p align=\"left\">median<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0.0000<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"421\" width=\"421\">\n<p align=\"left\">90th percentile<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0.0000<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"421\" width=\"421\">\n<p align=\"left\">99th percentile<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.5000<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p><strong>For those new to this.<\/strong> A percentile is \u201cthe share of windows whose value is no larger than this\u201d. The 90th percentile being zero means nine windows out of ten have no new edges at all; and only one window in a hundred gets a share of new edges as high as one half.<\/p>\n<p>People walk well-worn routes. The background is almost perfectly clean, and that is exactly what makes the problem solvable in principle.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/aed\/b23\/52e\/aedb2352ed8a8372e2e3584e61ef583d.svg\" alt=\"An ordinary day adds zero new edges to the login graph. Lateral movement consists of almost nothing else\" title=\"An ordinary day adds zero new edges to the login graph. Lateral movement consists of almost nothing else\" width=\"700\" height=\"310\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/aed\/b23\/52e\/aedb2352ed8a8372e2e3584e61ef583d.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/aed\/b23\/52e\/aedb2352ed8a8372e2e3584e61ef583d.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>An ordinary day adds zero new edges to the login graph. Lateral movement consists of almost nothing else<\/em><\/figcaption><\/div>\n<\/figure>\n<h3>A world in a hundred and thirty lines<\/h3>\n<p>Only now can the generator be written &#8212; already knowing exactly what to produce.<\/p>\n<p>The key idea: <strong>don\u2019t imitate lateral movement, produce its definition<\/strong>. The phenomenon has a computable formulation &#8212; \u201can account logs in where it has never been, from a machine where it has never been\u201d. Since the definition is algorithmic, it is synthesizable without losing the essence.<\/p>\n<p>The heart of the config looks like this:<\/p>\n<pre><code class=\"xml\">&lt;pool name=\"Work\" count=\"290\"&gt;  &lt;sequence name=\"hid\"&gt;&lt;gen type=\"increment\" value=\"1\"\/&gt;&lt;\/sequence&gt;  &lt;sequence name=\"role\"&gt;&lt;gen type=\"text\" value=\"0,2,3,4\"                             percent=\"78.8,0.6,20,0.6\"\/&gt;&lt;\/sequence&gt;  &lt;sequence name=\"userBase\"&gt;&lt;gen type=\"formula\" expr=\"hid * 30\"\/&gt;&lt;\/sequence&gt;  &lt;sequence name=\"userSpan\"&gt;&lt;gen type=\"number\" value=\"1..4\"\/&gt;&lt;\/sequence&gt;  &lt;sequence name=\"fgBase\"&gt;&lt;gen type=\"number\" value=\"1..290\"\/&gt;&lt;\/sequence&gt;  &lt;sequence name=\"fgSpan\"&gt;&lt;gen type=\"number\" value=\"2..60\"\/&gt;&lt;\/sequence&gt;  ...&lt;\/pool&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The machines live in a lookup table, each with its own number, its own role and its own circle of accounts: the machine with number <code>hid<\/code> owns accounts from <code>hid*30<\/code> to <code>hid*30+span<\/code>. Separately it has a <strong>compromise circle<\/strong> &#8212; the numbers of <em>other<\/em> machines whose accounts travel through it. The width of that circle runs from 2 to 60, and that isn\u2019t sloppiness but a deliberate spread: the network has to learn the class, not one particular preset.<\/p>\n<p>The account number for an event is then assembled by a formula:<\/p>\n<pre><code class=\"xml\">&lt;sequence name=\"User\"&gt;  &lt;gen type=\"formula\" expr=\"Foreign == 1     ? ((W.fgBase + floor(hash(N, 1) * W.fgSpan)) % 290) * 30       + floor(hash(N, 18) * 2)     : UserBase + floor(hash(N, 1) * UserSpan)\"\/&gt;&lt;\/sequence&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The formula is abridged here: the service-account and churn branches are left out, the full one is in the spoiler below. Here <code>hash(N, salt)<\/code> is a deterministic function of the row number and a salt, giving a reproducible number between zero and one. The whole set is deterministic by seed and rebuilds byte for byte.<\/p>\n<p><strong>For those new to this.<\/strong> A seed is the number the generator counts all its \u201crandomness\u201d from. The same seed gives the same world byte for byte, a different seed gives a different world under the same rules. I\u2019ll use the term in one more sense later: a <em>training seed<\/em> is the same kind of number, but for the network\u2019s random initial state. Different training seeds mean the same network trained several times from different starting points.<\/p>\n<p>The full world is a hundred and thirty lines and <strong>400 thousand events in 9 seconds<\/strong>. There are five kinds of machine in it, and each one earns its place:<\/p>\n<ul>\n<li>\n<p><strong>workstations<\/strong> &#8212; one to four accounts of their own, the bulk of the network;<\/p>\n<\/li>\n<li>\n<p><strong>servers<\/strong> &#8212; hundreds of accounts <em>legitimately<\/em>. Without them the network learns that \u201cmany accounts equals anomaly\u201d and drowns in false alarms;<\/p>\n<\/li>\n<li>\n<p><strong>new machines<\/strong> &#8212; no history, everything about them is legitimately new;<\/p>\n<\/li>\n<li>\n<p><strong>compromised machines<\/strong>;<\/p>\n<\/li>\n<li>\n<p><strong>machines that are new and compromised at once<\/strong> &#8212; otherwise a network trained not to touch legitimate novelty never learns to tell them apart.<\/p>\n<\/li>\n<\/ul>\n<p>Two more mechanisms live on the event rather than on the machine, which is why they aren\u2019t in that list. <strong>Service accounts<\/strong> travel across many machines, but on each one they appear <em>constantly<\/em>, so they create no novelty: an account like that can be used by a machine of any kind. And <strong>churn<\/strong> &#8212; a new employee, a person moving desks, a server that was repurposed. Without it, novelty never occurs in normal activity at all, and the network draws the simple conclusion that \u201cany new event is a compromise\u201d.<\/p>\n<details class=\"spoiler\">\n<summary>The whole base world config, all 135 lines<\/summary>\n<div class=\"spoiler__content\">\n<p>This is <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world.tdc\" rel=\"noopener nofollow\">the working file<\/a> straight from the repository, with nothing cut (a copy with Russian comments lives in <code>ru\/gen\/<\/code>, the same lines in both), together with the comments I wrote for myself as I went. The comments matter more than the code here: almost every one of them answers \u201cwhy does this class exist at all\u201d rather than \u201cwhat does this line do\u201d. Those answers were the actual work &#8212; the syntax took minutes.<\/p>\n<pre><code class=\"xml\">&lt;tdc version=\"0.1\"&gt;  &lt;!-- Synthetic world: a network of machines, each with its own login history.       Two pools, because the load in a network is distributed very unevenly:       a handful of servers sees a huge stream and hundreds of accounts LEGITIMATELY,       while workstations are many and each one is quiet.       Roles of the work machines: 0 - workstation (its own 1..4 accounts), 2 - NEW host       (no history: everything on it is legitimately new), 3 - workstation whose accounts       become foreign in the second period, 4 - a machine that is new AND working under       foreign accounts AT THE SAME TIME. Role 4 exists because a network trained to stay       silent on legitimate novelty has to be taught separately to tell it from foreign       novelty: an honest new host has fresh accounts, this one has accounts moved from       other machines, that is, long known to the network.       Period 0 - history, period 1 - observation. The knobs are spread deliberately       wide within a class: the network must learn the class, not a preset.       The makeup of the world (share of servers, rarity of new hosts, density of the       stream) is calibrated against ROUGH statistics of a real network. The features of       the observed phenomenon itself were not calibrated against anything. --&gt;  &lt;env count=\"400000\" seed=\"world-train-1\"&gt;    &lt;pool name=\"Work\" count=\"290\"&gt;      &lt;sequence name=\"hid\"&gt;&lt;gen type=\"increment\" value=\"1\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"role\"&gt;&lt;gen type=\"text\" value=\"0,2,3,4\" percent=\"78.8,0.6,20,0.6\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"userBase\"&gt;&lt;gen type=\"formula\" expr=\"hid * 30\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"userSpan\"&gt;&lt;gen type=\"number\" value=\"1..4\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"dstBase\"&gt;&lt;gen type=\"number\" value=\"1..4500\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"dstSpan\"&gt;&lt;gen type=\"number\" value=\"2..9\"\/&gt;&lt;\/sequence&gt;      &lt;!-- spread over hours: the wider, the rarer the events in a window; some events           go past the end of observation - that is how both dense and quiet windows are born --&gt;      &lt;sequence name=\"spread\"&gt;&lt;gen type=\"number\" value=\"1..40\"\/&gt;&lt;\/sequence&gt;      &lt;!-- on some machines the makeup does not change for years, on others it flows constantly --&gt;      &lt;sequence name=\"churnMil\"&gt;&lt;gen type=\"number\" value=\"0..8\"\/&gt;&lt;\/sequence&gt;      &lt;!-- foreign activity has its own limited circle of accounts and places: the axis           is spread WIDE (2..60) so that the network does not memorise one size --&gt;      &lt;!-- The SHARE of foreign events among all events of a machine: from 2% to 100%.           In a real journal a foreign event can be one in fifty ordinary ones,           and a share feature drowns it. The network must see the diluted case,           so the axis is spread down to the rarest values. --&gt;      &lt;sequence name=\"fgRateMil\"&gt;&lt;gen type=\"number\" value=\"1000..1000\"\/&gt;&lt;\/sequence&gt;      &lt;!-- the circle of foreign accounts: the NUMBERS OF OTHER MACHINES whose accounts are used --&gt;      &lt;sequence name=\"fgBase\"&gt;&lt;gen type=\"number\" value=\"1..290\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"fgSpan\"&gt;&lt;gen type=\"number\" value=\"2..60\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"fgDstBase\"&gt;&lt;gen type=\"number\" value=\"1..4900\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"fgDstSpan\"&gt;&lt;gen type=\"number\" value=\"2..70\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"failMil\"&gt;&lt;gen type=\"number\" value=\"20..700\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"baseMil\"&gt;&lt;gen type=\"number\" value=\"1..30\"\/&gt;&lt;\/sequence&gt;    &lt;\/pool&gt;    &lt;pool name=\"Serv\" count=\"10\"&gt;      &lt;sequence name=\"sid\"&gt;&lt;gen type=\"increment\" value=\"1\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"userBase\"&gt;&lt;gen type=\"number\" value=\"1..7000\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"userSpan\"&gt;&lt;gen type=\"number\" value=\"60..280\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"dstBase\"&gt;&lt;gen type=\"number\" value=\"1..3000\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"dstSpan\"&gt;&lt;gen type=\"number\" value=\"40..400\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"spread\"&gt;&lt;gen type=\"number\" value=\"15..22\"\/&gt;&lt;\/sequence&gt;      &lt;sequence name=\"baseMil\"&gt;&lt;gen type=\"number\" value=\"1..30\"\/&gt;&lt;\/sequence&gt;    &lt;\/pool&gt;    &lt;sequence name=\"W\"&gt;&lt;gen type=\"pool\" value=\"Work\"\/&gt;&lt;\/sequence&gt;    &lt;sequence name=\"S\"&gt;&lt;gen type=\"pool\" value=\"Serv\"\/&gt;&lt;\/sequence&gt;    &lt;sequence name=\"N\"&gt;&lt;gen type=\"increment\" value=\"1\"\/&gt;&lt;\/sequence&gt;    &lt;!-- a third of the stream goes to the servers: they are few, the stream to each is huge --&gt;    &lt;sequence name=\"IsServ\"&gt;&lt;gen type=\"formula\" expr=\"hash(N, 10) &lt; 0.35 ? 1 : 0\"\/&gt;&lt;\/sequence&gt;    &lt;!-- Service and machine accounts: in a real network they log in everywhere, and         the reach of such an account is thousands of machines. Without them the world         consists of nothing but accounts \"tied to a workplace\", which never happens in life. --&gt;    &lt;!-- Every machine uses the same service accounts (so they create no         novelty), and every such account serves several machines - hence         the wide reach. A small part (tier 9500+) goes everywhere. --&gt;    &lt;sequence name=\"IsSvc\"&gt;&lt;gen type=\"formula\" expr=\"hash(N, 12) &lt; 0.30 ? 1 : 0\"\/&gt;&lt;\/sequence&gt;    &lt;!-- machine name: servers and work machines in different number ranges --&gt;    &lt;sequence name=\"Host\"&gt;&lt;gen type=\"formula\" expr=\"IsServ == 1 ? 9000 + S.sid : W.hid\"\/&gt;&lt;\/sequence&gt;    &lt;sequence name=\"Role\"&gt;&lt;gen type=\"formula\" expr=\"IsServ == 1 ? 1 : W.role\"\/&gt;&lt;\/sequence&gt;    &lt;sequence name=\"Spread\"&gt;&lt;gen type=\"formula\" expr=\"IsServ == 1 ? S.spread : W.spread\"\/&gt;&lt;\/sequence&gt;    &lt;!-- a new host lives only in the second period --&gt;    &lt;sequence name=\"Period\"&gt;      &lt;gen type=\"formula\" expr=\"Role == 2 || Role == 4 ? 1 : (hash(N, 6) &lt; 0.60 ? 0 : 1)\"\/&gt;    &lt;\/sequence&gt;    &lt;sequence name=\"Time\"&gt;      &lt;gen type=\"formula\"           expr=\"Period == 0 ? floor(hash(N, 5) * 28800) : 28800 + floor(hash(N, 3) * Spread) * 3600 + floor(hash(N, 4) * 3600)\"\/&gt;    &lt;\/sequence&gt;    &lt;!-- on this machine the accounts are foreign in the second period --&gt;    &lt;sequence name=\"Foreign\"&gt;      &lt;gen type=\"formula\" expr=\"(Role == 3 || Role == 4) &amp;&amp; Period == 1 &amp;&amp; hash(N, 11) &lt; W.fgRateMil \/ 1000 ? 1 : 0\"\/&gt;    &lt;\/sequence&gt;    &lt;sequence name=\"UserBase\"&gt;&lt;gen type=\"formula\" expr=\"IsServ == 1 ? S.userBase : W.userBase\"\/&gt;&lt;\/sequence&gt;    &lt;sequence name=\"UserSpan\"&gt;&lt;gen type=\"formula\" expr=\"IsServ == 1 ? S.userSpan : W.userSpan\"\/&gt;&lt;\/sequence&gt;    &lt;sequence name=\"DstBase\"&gt;&lt;gen type=\"formula\" expr=\"IsServ == 1 ? S.dstBase : W.dstBase\"\/&gt;&lt;\/sequence&gt;    &lt;sequence name=\"DstSpan\"&gt;&lt;gen type=\"formula\" expr=\"IsServ == 1 ? S.dstSpan : W.dstSpan\"\/&gt;&lt;\/sequence&gt;    &lt;!-- Legitimate change comes in two kinds, and they look different:         a new employee (the account was not in the network at all, range 20000+) and         an existing person moving to another machine. Without both the network will         not learn to tell legitimate novelty from foreign credentials. --&gt;    &lt;sequence name=\"Churn\"&gt;      &lt;gen type=\"formula\" expr=\"Foreign == 0 &amp;&amp; IsSvc == 0 &amp;&amp; hash(N, 14) &lt; W.churnMil \/ 10000 ? 1 : 0\"\/&gt;    &lt;\/sequence&gt;    &lt;sequence name=\"User\"&gt;      &lt;gen type=\"formula\"           expr=\"Foreign == 1 ? ((W.fgBase + floor(hash(N, 1) * W.fgSpan)) % 290) * 30 + floor(hash(N, 18) * 2) : (IsSvc == 1 ? (hash(N, 13) &lt; 0.25 ? 9500 + floor(hash(N, 1) * 20) : 9000 + (Host * 7 + floor(hash(N, 1) * 6)) % 400) : (Churn == 1 ? (hash(N, 17) &lt; 0.5 ? 20000 + floor(hash(N, 15) * 900) : (floor(hash(N, 15) * 290)) * 30 + floor(hash(N, 19) * 4)) : UserBase + floor(hash(N, 1) * UserSpan)))\"\/&gt;    &lt;\/sequence&gt;    &lt;sequence name=\"Dst\"&gt;      &lt;gen type=\"formula\"           expr=\"Foreign == 1 ? 1 + (W.fgDstBase + floor(hash(N, 2) * W.fgDstSpan)) % 4900 : (Churn == 1 ? DstBase + DstSpan + floor(hash(N, 16) * 50) : DstBase + floor(hash(N, 2) * DstSpan))\"\/&gt;    &lt;\/sequence&gt;    &lt;sequence name=\"FailCut\"&gt;      &lt;gen type=\"formula\"           expr=\"Foreign == 1 ? W.failMil \/ 1000 : (IsServ == 1 ? S.baseMil : W.baseMil) \/ 1000\"\/&gt;    &lt;\/sequence&gt;    &lt;sequence name=\"Ok\"&gt;      &lt;gen if=\"hash(N, 8) &lt; FailCut\" type=\"text\" value=\"Fail\"\/&gt;      &lt;gen type=\"text\" value=\"Success\"\/&gt;    &lt;\/sequence&gt;  &lt;\/env&gt;  &lt;block&gt;    &lt;line&gt;&lt;data&gt;${{Time}},U${{User}},C${{Host}},D${{Dst}},${{Ok}},${{Foreign}}&lt;\/data&gt;&lt;\/line&gt;  &lt;\/block&gt;&lt;\/tdc&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>What to look at, if you\u2019re skimming. <strong>Two pools<\/strong> instead of one &#8212; because load in a network is distributed wildly unevenly, and a handful of servers legitimately see hundreds of accounts. <strong>Role 4<\/strong> &#8212; a machine that is new and running under foreign accounts at the same time; it exists precisely so that a network trained to stay quiet on legitimate novelty is separately taught to tell honest novelty from stolen. <strong>The knobs opened wide<\/strong> &#8212; <code>2..60<\/code>, <code>1..40<\/code>, <code>20..700<\/code>: the network has to learn the class, not one particular set of numbers. And <strong>Churn<\/strong> &#8212; legitimate turnover of two kinds, without which novelty never occurs in normal activity and the network concludes that \u201cany new event is a compromise\u201d.<\/p>\n<p>The output line at the very bottom is the entire log format: time, account, machine, destination, success, label. The last field takes no part in training; it is there for the feature builder (the repository calls it the measurer), so it knows the truth.<\/p>\n<p>Two formulas in the file are long and run off to the right &#8212; <code>User<\/code> and <code>Dst<\/code>. There\u2019s no need to scroll through them; they are built the same way, as a ladder of nested conditions: \u201cif foreign, take from the compromise circle; else if a service account, from the service range; else if churn, either fresh or moved in; else its own\u201d. One line instead of four branches on the outside; it reads badly, but it is fixed in one place.<\/p>\n<\/div>\n<\/details>\n<h3>The mistake that cost more than every setting<\/h3>\n<p>For a long time my \u201ccompromised\u201d accounts were <strong>non-existent<\/strong>: the generator drew a number from a range, and no such account existed anywhere in the network. Comparing distributions against reality exposed it instantly:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"425\" width=\"425\">\n<p align=\"left\">in labelled windows<\/p>\n<\/th>\n<th>\n<p align=\"left\">reality<\/p>\n<\/th>\n<th>\n<p align=\"left\">synthetic, before the fix<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"425\" width=\"425\">\n<p align=\"left\">account known to the network, but on this machine for the first time<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>26<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\">2<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"425\" width=\"425\">\n<p align=\"left\">account did not exist in the network at all<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\">11<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>In a real network, lateral movement runs on <em>existing<\/em> accounts &#8212; that is the whole point of stealing them. What I was producing wasn\u2019t theft but invention, and the network was diligently learning to catch an artefact that never occurs in live data.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/3c7\/48b\/22f\/3c748b22fe976df98d294f636bb14f74.svg\" alt=\"On the left, what I was producing at first: the account exists nowhere. On the right, what actually happens: the account is real and works somewhere else\" title=\"On the left, what I was producing at first: the account exists nowhere. On the right, what actually happens: the account is real and works somewhere else\" width=\"700\" height=\"300\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/3c7\/48b\/22f\/3c748b22fe976df98d294f636bb14f74.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/3c7\/48b\/22f\/3c748b22fe976df98d294f636bb14f74.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>On the left, what I was producing at first: the account exists nowhere. On the right, what actually happens: the account is real and works somewhere else<\/em><\/figcaption><\/div>\n<\/figure>\n<p>That one fix reduced false alarms <strong>fourfold<\/strong> at the same recall. More than every network setting put together.<\/p>\n<blockquote>\n<p>Synthetic data has to be right in meaning, not similar in statistics.<\/p>\n<\/blockquote>\n<h3>The network<\/h3>\n<p>The first network is <strong>fully connected<\/strong>, the most ordinary kind: eighteen features in, eight layers of twelve neurons, <strong>1333 weights<\/strong>. Training takes seconds on a laptop, no GPU. In the table below it appears as \u201cdeep [12]x8\u201d; a second one, recurrent, shows up later in the article &#8212; its turn comes after the capacity measurement.<\/p>\n<p>The unit of observation is the window, \u201cone machine for one hour\u201d. The features come in three sorts: volume and variety (how many events, accounts, destinations), shares of novelty, and the context of the machine (is it a server or a quiet workstation). Two features are worth naming separately, because they are the ones that encode the meaning of stolen credentials:<\/p>\n<ul>\n<li>\n<p><strong>moved accounts<\/strong> &#8212; the number of events where the account is known to the network but appears on this machine for the first time. The signature of theft: the account exists and works somewhere else;<\/p>\n<\/li>\n<li>\n<p><strong>fresh accounts<\/strong> &#8212; the number of events where the account did not exist in the network at all. The signature of a new employee.<\/p>\n<\/li>\n<\/ul>\n<p>Without that distinction, a new person legitimately starting work is indistinguishable from a compromise.<\/p>\n<p>The counter features are taken under a logarithm, and that isn\u2019t cosmetic. The synthetic world has 290 machines, the real one has 17,684. A linear feature would push activations far outside the training range, where a network behaves unpredictably; a logarithm turns a difference of scale into a shift.<\/p>\n<p><strong>The same code computes the features for the synthetic data and for reality.<\/strong> That isn\u2019t a declaration: day 8 was recomputed by two independent implementations of the feature builder and compared line by line, and <strong>231,787 lines matched exactly<\/strong>.<\/p>\n<p>On synthetic data with a different seed &#8212; that is, on another instance of the same world &#8212; the network achieves an AUC of 0.99996. That means exactly one thing, and I wrote the conclusion down <em>before<\/em> the exam: the network has learned my generator thoroughly. And nothing else.<\/p>\n<p><strong>For those new to this.<\/strong> AUC is the area under the ROC curve. The easiest way to read it: take one labelled window and one ordinary window at random; AUC is the probability that the model ranks the labelled one higher. 1.0 is a perfect ordering, 0.5 is a coin. There is a whole section later on why AUC lies on this problem, but for now let it be \u201cthe share of correctly ordered pairs\u201d.<\/p>\n<h4>How many weights are actually needed<\/h4>\n<p>I picked the architecture blindly at first &#8212; the classic mistake. Then I measured it: thirty-one configurations, three training seeds each (three trainings from different starting points, so the spread is visible), ninety-three trained networks in <strong>170 seconds<\/strong> on a CPU. The characteristic rows:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"212\" width=\"212\">\n<p align=\"left\">architecture<\/p>\n<\/th>\n<th>\n<p align=\"left\">weights<\/p>\n<\/th>\n<th data-colwidth=\"182\" width=\"182\">\n<p align=\"left\">easy day<\/p>\n<\/th>\n<th>\n<p align=\"left\">hard day<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"212\" width=\"212\">\n<p align=\"left\">flat [4]<\/p>\n<\/td>\n<td>\n<p align=\"left\">81<\/p>\n<\/td>\n<td data-colwidth=\"182\" width=\"182\">\n<p align=\"left\">0.99868<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.94208<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"212\" width=\"212\">\n<p align=\"left\">flat [8]<\/p>\n<\/td>\n<td>\n<p align=\"left\">161<\/p>\n<\/td>\n<td data-colwidth=\"182\" width=\"182\">\n<p align=\"left\">0.99998<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.94151<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"212\" width=\"212\">\n<p align=\"left\">flat [256]<\/p>\n<\/td>\n<td>\n<p align=\"left\">5121<\/p>\n<\/td>\n<td data-colwidth=\"182\" width=\"182\">\n<p align=\"left\">0.99997<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.95646<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"212\" width=\"212\">\n<p align=\"left\">two steps [128,64]<\/p>\n<\/td>\n<td>\n<p align=\"left\">10753<\/p>\n<\/td>\n<td data-colwidth=\"182\" width=\"182\">\n<p align=\"left\">0.99995<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.96318<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"212\" width=\"212\">\n<p align=\"left\"><strong>deep [12]x8<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>1333<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"182\" width=\"182\">\n<p align=\"left\">0.99997<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0.97518<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"212\" width=\"212\">\n<p align=\"left\">deep [12]x12<\/p>\n<\/td>\n<td>\n<p align=\"left\">1957<\/p>\n<\/td>\n<td data-colwidth=\"182\" width=\"182\">\n<p align=\"left\">0.99940<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.94923<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>Three conclusions from that.<\/p>\n<ol>\n<li>\n<p>Performance on the easy day is <strong>saturated<\/strong>: from 161 weights to 10,753 everything sits in the 0.99995-0.99998 range; capacity simply isn\u2019t the constraint there.<\/p>\n<\/li>\n<li>\n<p>Depth <strong>beats width<\/strong>: 1333 weights do better than 10,753.<\/p>\n<\/li>\n<li>\n<p>And too deep is bad too: twelve layers without residual connections drop to 0.949 and spoil even the easy day.<\/p>\n<\/li>\n<\/ol>\n<p>Later I ran nine sequence architectures, the LSTM among them, from dilated convolutions to external memory in the spirit of a neural Turing machine. Half of those names I learned that same evening, so treat this as curiosity rather than a survey. The best turned out to be a plain <strong>recurrent network<\/strong> of 4249 parameters &#8212; an LSTM, a network that reads one machine\u2019s hours in order and remembers what came before. On the hard day it gives <strong>0.990<\/strong> against 0.975 for the best fully connected one, and from here on in the article \u201cthe network\u201d means this one. The worst turned out to be the transformer: self-attention is greedy for data and wants to link distant elements, and our sequences are 24 steps long with nothing in them to link. The full tables are in <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/ARCHITECTURES.md\" rel=\"noopener nofollow\">ARCHITECTURES<\/a> in the repository.<\/p>\n<h3>The first result on real data<\/h3>\n<p>The day with the densest exercise activity: <strong>231,787 windows<\/strong>, 15 of them labelled.<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"554\" width=\"554\">\n<p align=\"left\">measure<\/p>\n<\/th>\n<th>\n<p align=\"left\">value<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"554\" width=\"554\">\n<p align=\"left\">labelled windows found<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>15 of 15<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"554\" width=\"554\">\n<p align=\"left\">false alarms at that point<\/p>\n<\/td>\n<td>\n<p align=\"left\">11<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"554\" width=\"554\">\n<p align=\"left\">positions of the labelled windows in the overall list<\/p>\n<\/td>\n<td>\n<p align=\"left\">all within the first 32<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>And here is the cost of full recall &#8212; how many false alarms you have to look through to reach the last real window:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"476\" width=\"476\">\n<p align=\"left\">method<\/p>\n<\/th>\n<th>\n<p align=\"left\">false alarms<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"476\" width=\"476\">\n<p align=\"left\">counter of \u201caccounts from a machine\u201d<\/p>\n<\/td>\n<td>\n<p align=\"left\">175,904<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"476\" width=\"476\">\n<p align=\"left\">counter of \u201cdestinations\u201d<\/p>\n<\/td>\n<td>\n<p align=\"left\">206,651<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"476\" width=\"476\">\n<p align=\"left\">a four-threshold rule fitted against the answers<\/p>\n<\/td>\n<td>\n<p align=\"left\">never gets there<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"476\" width=\"476\">\n<p align=\"left\"><strong>the network trained on synthetic data<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>10<\/strong><\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>Three numbers about the same day look contradictory, so let me explain: they are three different measures. <strong>11<\/strong> is false alarms at a fixed threshold of 0.99, i.e. if you raise an alarm on everything the model scored above that value. <strong>32<\/strong> is the worst position of a labelled window in a list of 231,787: even the unluckiest one landed in the first three dozen. <strong>10<\/strong> is the cost of full recall, i.e. how many false alarms stand between the top of the list and the last real window. Different measures, same data; don\u2019t mix them up.<\/p>\n<p>The row with the thresholds deserves a word of its own, because it explains why a held-out set is needed at all. I honestly tried to fit a rule while <em>peeking at the answers<\/em>: four thresholds, tuned on the labelled day. It gives 13 windows out of 15 at zero false alarms &#8212; and never reaches full recall, because two quiet windows with three and six events don\u2019t pass the volume thresholds. Optimising on the test produced a locally beautiful and globally useless model.<\/p>\n<p>The numbers are good, and it was too early to enjoy them: they were taken on the very day I had tuned everything on. The real check was still ahead.<\/p>\n<h3>The held-out set said no four times<\/h3>\n<p>I set aside everything except two working days: sixteen days, <strong>3,600,398 windows<\/strong>, 64 labelled. Didn\u2019t look, didn\u2019t touch.<\/p>\n<p>And then I started improving things &#8212; and each time got a gain on day 12:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"460\" width=\"460\">\n<p align=\"left\">improvement<\/p>\n<\/th>\n<th>\n<p align=\"left\">day 12<\/p>\n<\/th>\n<th>\n<p align=\"left\">held-out set<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"460\" width=\"460\">\n<p align=\"left\">a new machine class, \u201cadministrator on rounds\u201d (legitimately logs in to many machines)<\/p>\n<\/td>\n<td>\n<p align=\"left\">better<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>worse<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"460\" width=\"460\">\n<p align=\"left\">an ensemble of three networks trained on one world<\/p>\n<\/td>\n<td>\n<p align=\"left\">better<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>worse<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"460\" width=\"460\">\n<p align=\"left\">a stack over weak learners (a trainable judge over several deliberately weak networks)<\/p>\n<\/td>\n<td>\n<p align=\"left\">a lottery<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>a lottery<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"460\" width=\"460\">\n<p align=\"left\">a rich world: 8 million events, attack stages<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>+1.26 points<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>no change<\/strong><\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p><strong>For those new to this.<\/strong> The \u201crich world\u201d was an attempt to make the attack a process with four stages: quiet establishment, credential harvesting, spreading, and reaching the servers. The early stages are single events, and on them the network learned to fire on any quiet window: on the held-out set the cost of search for the rich world came out a hundred times worse. From here on that phrase appears as the name of a mistake, and \u201cclean world\u201d as the name of a world without those stages.<\/p>\n<p>Four times in a row. In the last case the gain exceeded the spread across training seeds by a factor of three and a half &#8212; and still didn\u2019t transfer.<\/p>\n<p>It took me a while to understand why. I was measuring the spread <em>across training seeds<\/em> and assumed that was enough. But there is a second source of noise, invisible to reseeding: <strong>which twelve windows happen to be labelled<\/strong> on day 12. That is the spread over the sample of labelled truth, and it cannot be estimated by reseeding the model &#8212; it needs another day.<\/p>\n<blockquote>\n<p>Twelve positive examples do not constitute a measure at any spread of seeds.<\/p>\n<\/blockquote>\n<p>Half of my evening comparisons were beyond the resolving power of the data. I was comparing noise with noise and drawing conclusions. That is exactly what a held-out set is for: it said \u201cno\u201d four times where day 12 said \u201cyes\u201d.<\/p>\n<p>Skipping ahead: one row in that table I did eventually take back. Ensembles do transfer &#8212; just not the ensemble I tried then. There\u2019s a section about that later, and the conclusion there comes out the opposite way.<\/p>\n<h3>Why AUC lies on a problem like this<\/h3>\n<p>And here I nearly picked the wrong model.<\/p>\n<p>When I got to gradient boosting &#8212; an ensemble of decision trees, XGBoost, the standard strong method for tabular data &#8212; it beat everything else on AUC: 0.918 against 0.909 for the recurrent network and 0.862 for the fully connected one. Case closed, you\u2019d think. And that\u2019s where I almost drew the wrong conclusion.<\/p>\n<p><strong>AUC is not what an analyst needs.<\/strong> They need a clean top of the list: they open the first twenty windows and see how many of them are real. The right measure is the <strong>cost of search<\/strong>: how many false alarms you have to look through on the way to the Nth window found. Held-out set, 3.6 million windows:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">windows caught<\/p>\n<\/th>\n<th>\n<p align=\"left\">fully connected<\/p>\n<\/th>\n<th>\n<p align=\"left\">boosting<\/p>\n<\/th>\n<th>\n<p align=\"left\">recurrent<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">1<\/p>\n<\/td>\n<td>\n<p align=\"left\">17<\/p>\n<\/td>\n<td>\n<p align=\"left\">7<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>3<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">8<\/p>\n<\/td>\n<td>\n<p align=\"left\">55<\/p>\n<\/td>\n<td>\n<p align=\"left\">8,053<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>102<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><strong>16<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\">245<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>23,781<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>188<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">48<\/p>\n<\/td>\n<td>\n<p align=\"left\">185,613<\/p>\n<\/td>\n<td>\n<p align=\"left\">304,954<\/p>\n<\/td>\n<td>\n<p align=\"left\">137,014<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>Boosting is better <em>only in the tail<\/em> &#8212; on the last windows, which nobody will ever open. In the working region it is a hundred times worse.<\/p>\n<p>The mechanism is simple. There are 64 positive examples and 3.6 million negative ones. AUC is the share of pairs where the positive ranks above the negative, averaged over <em>all<\/em> pairs. A model earns a high score by neatly sorting the hopeless tail while the top of the list is filthy. It\u2019s like praising a search engine for getting results fifty thousand through sixty thousand in the right order.<\/p>\n<p>The most expressive case is the echo state network. It\u2019s an amusing construction: a reservoir of five thousand random weights inside that are <em>never trained<\/em>, and only a linear readout of 65 parameters learns anything.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/3c9\/787\/2e5\/3c97872e5956fd9c5ba245af1a8c6152.svg\" alt=\"Two hundredths of a difference in AUC - and a three-and-a-half-thousandfold difference in what the analyst actually sees\" title=\"Two hundredths of a difference in AUC - and a three-and-a-half-thousandfold difference in what the analyst actually sees\" width=\"700\" height=\"300\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/3c9\/787\/2e5\/3c97872e5956fd9c5ba245af1a8c6152.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/3c9\/787\/2e5\/3c97872e5956fd9c5ba245af1a8c6152.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>Two hundredths of a difference in AUC &#8212; and a three-and-a-half-thousandfold difference in what the analyst actually sees<\/em><\/figcaption><\/div>\n<\/figure>\n<p>At a perfectly respectable AUC of 0.969 on day 12 it requires <strong>3406<\/strong> false alarms before the <em>first<\/em> window found &#8212; against <strong>one<\/strong> for the recurrent network. A factor of three and a half thousand, for a difference of two hundredths in AUC. And it isn\u2019t a curiosity: across a table of five architectures the order by AUC doesn\u2019t match the order by cost of search at all.<\/p>\n<blockquote>\n<p>If you are building a search for rare events and measuring quality by AUC &#8212; you are almost certainly measuring the wrong thing.<\/p>\n<\/blockquote>\n<h3>Finding one: the baseline scissors<\/h3>\n<p>The feature is defined as novelty relative to the <em>machine\u2019s own history<\/em>: the list of accounts and places already seen on it. That accumulated history I\u2019ll call the <strong>baseline<\/strong> from here on. And this is where a trap turns up that <strong>any<\/strong> detector building a behaviour profile falls into.<\/p>\n<h4>The baseline is poisoned by the very thing it catches<\/h4>\n<p>By day 12 the foothold \u201cknows\u201d 84 accounts. Of those, <strong>49 are the very compromised ones that had been travelling through it earlier<\/strong>.<\/p>\n<blockquote>\n<p>58.3% of the machine\u2019s \u201cnormal history\u201d is the observed intrusion itself.<\/p>\n<\/blockquote>\n<p>The mechanism: history accumulates by the fact of observation, so any sufficiently long-running activity <em>enters the norm<\/em> and stops being new. The signal weakens precisely because the attack continues. The median novelty of labelled windows fell from 1.00 on day 8 to 0.57 on day 12.<\/p>\n<p>The cure is obvious: freeze the baseline at an early period. It helps immediately &#8212; 10 windows found out of 12 instead of 5, and every labelled window rises into the first twenty-seven rows of the list instead of scattering out to eight hundred.<\/p>\n<h4>But a frozen baseline goes stale<\/h4>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/4bc\/3da\/216\/4bc3da216293039f1d392a9c0c0e86e3.svg\" alt=\"Two ways to ruin a baseline, and both of them work. The operating point is narrow: no younger than a week and no older than two or three\" title=\"Two ways to ruin a baseline, and both of them work. The operating point is narrow: no younger than a week and no older than two or three\" width=\"700\" height=\"300\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/4bc\/3da\/216\/4bc3da216293039f1d392a9c0c0e86e3.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/4bc\/3da\/216\/4bc3da216293039f1d392a9c0c0e86e3.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>Two ways to ruin a baseline, and both of them work. The operating point is narrow: no younger than a week and no older than two or three<\/em><\/figcaption><\/div>\n<\/figure>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"379\" width=\"379\">\n<p align=\"left\">day<\/p>\n<\/th>\n<th>\n<p align=\"left\">9<\/p>\n<\/th>\n<th>\n<p align=\"left\">15<\/p>\n<\/th>\n<th>\n<p align=\"left\">29<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"379\" width=\"379\">\n<p align=\"left\">machines unknown to the baseline<\/p>\n<\/td>\n<td>\n<p align=\"left\">1.1%<\/p>\n<\/td>\n<td>\n<p align=\"left\">3.3%<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>6.1%<\/strong><\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>The drift is about <strong>0.25% of machines a day<\/strong>. By the end of the observation every sixteenth machine is a stranger to the baseline and therefore looks anomalous: <strong>39% of all false alarms<\/strong> on the held-out set come from machines exactly like that. On day 8 there were none.<\/p>\n<blockquote>\n<p>Refresh the baseline often and it gets poisoned by what you\u2019re observing. Freeze it and it goes stale at a quarter of a percent of machines a day. The operating point: a baseline no younger than a week and no older than two or three weeks.<\/p>\n<\/blockquote>\n<p>This is a property not of my model but of any method that builds a profile from observed history without an external check on credentials.<\/p>\n<h3>Finding two: here I was wrong<\/h3>\n<p>Here I drew a conclusion that pleased me at first: it tidily explained everything at once. And then turned out to be wrong.<\/p>\n<p>Let\u2019s call a <strong>coincidence<\/strong> a normal window that carries the full signature of a compromise: high novelty and a high share of failures at the same time. In reality such windows exist &#8212; <strong>51 out of 231,772<\/strong>, two hundredths of a percent. In my worlds there were <strong>none<\/strong>. Not one, neither in the base world nor in the rich eight-million-event one. And yet each feature on its own was reproduced perfectly well:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">\n<\/th>\n<th>\n<p align=\"left\">high novelty<\/p>\n<\/th>\n<th>\n<p align=\"left\">many failures<\/p>\n<\/th>\n<th>\n<p align=\"left\">both at once<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">reality<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.644%<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.669%<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0.022%<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">my rich world<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.887%<\/p>\n<\/td>\n<td>\n<p align=\"left\">6.933%<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0.000%<\/strong><\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/43c\/e7d\/fe5\/43ce7dfe570abd7d5b1bf27d0ad8f2d4.svg\" alt=\"Each feature on its own landed on reality. Their intersection stayed empty - and I decided that was the limit\" title=\"Each feature on its own landed on reality. Their intersection stayed empty - and I decided that was the limit\" width=\"700\" height=\"320\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/43c\/e7d\/fe5\/43ce7dfe570abd7d5b1bf27d0ad8f2d4.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/43c\/e7d\/fe5\/43ce7dfe570abd7d5b1bf27d0ad8f2d4.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>Each feature on its own landed on reality. Their intersection stayed empty &#8212; and I decided that was the limit<\/em><\/figcaption><\/div>\n<\/figure>\n<p>From that I drew a conclusion that seemed elegant and deep: a generator produces <em>regularities<\/em>, and reality consists of regularities <em>plus coincidences<\/em>. A coincidence is by definition the absence of a mechanism, an unplanned intersection of independent causes. To get them you would have to model every mechanism of the real world together with its links &#8212; that is, model reality in full. Elegant, hopeless and, as it turned out, wrong.<\/p>\n<h4>The control run that cost one launch<\/h4>\n<p>I set out to produce coincidences on purpose: I built a world where new accounts and failures converge on a machine <em>legitimately<\/em>. And alongside it a control: the same worlds with that mechanism switched down to zero.<\/p>\n<p><strong>The control worlds produced coincidences just as well.<\/strong><\/p>\n<p>Which means the mechanism had nothing to do with it. The coincidences appeared because the world had got <em>smaller<\/em> &#8212; a million and a half events across the same machines &#8212; and the windows thinned out. In sparse windows the shares start to swing: in a window of two events the \u201cshare of new accounts\u201d takes the values 0, 0.5 and 1, and half of those look like the signature of an attack. That is exactly how coincidences are built in reality too.<\/p>\n<p>Without the control I would have credited the effect to the mechanism and written something untrue in this article. The control cost one launch.<\/p>\n<h4>The sparse world<\/h4>\n<p>From there it\u2019s clear what to build: a world where events are smeared over time the way they are in a live network. I take the no-stage world, raise it to four million events and add quiet machines. Eighteen per cent of machines are \u201cquiet\u201d, their events stretched over 80-260 hours. For the first time, the shape of the windows sat down on reality:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"258\" width=\"258\">\n<p align=\"left\">events in a window<\/p>\n<\/th>\n<th>\n<p align=\"left\">1st percentile<\/p>\n<\/th>\n<th>\n<p align=\"left\">median<\/p>\n<\/th>\n<th>\n<p align=\"left\">90th percentile<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"258\" width=\"258\">\n<p align=\"left\">LANL reality<\/p>\n<\/td>\n<td>\n<p align=\"left\">2<\/p>\n<\/td>\n<td>\n<p align=\"left\">22<\/p>\n<\/td>\n<td>\n<p align=\"left\">49<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"258\" width=\"258\">\n<p align=\"left\">sparse world<\/p>\n<\/td>\n<td>\n<p align=\"left\">1<\/p>\n<\/td>\n<td>\n<p align=\"left\">19<\/p>\n<\/td>\n<td>\n<p align=\"left\">57<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"258\" width=\"258\">\n<p align=\"left\">previous (base) world<\/p>\n<\/td>\n<td>\n<p align=\"left\">5<\/p>\n<\/td>\n<td>\n<p align=\"left\">16<\/p>\n<\/td>\n<td>\n<p align=\"left\">53<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>The coincidences appeared by themselves, and of the right size, too: real coincidences have a median of two events, mine have four, and previously it was twenty-four. There is still no \u201ccoincidence\u201d mechanism anywhere in the config. There is sparseness, and coincidences follow from it.<\/p>\n<p>Here is everything that produces the sparseness, verbatim from <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-sparse.tdc\" rel=\"noopener nofollow\">world-sparse<\/a>: two sequences in the machine lookup table and one time formula.<\/p>\n<pre><code class=\"xml\">&lt;!-- 18% of machines are \"quiet\": their events smear over 80-260 hours instead of 1-40 --&gt;&lt;sequence name=\"isQuiet\"&gt;&lt;gen type=\"text\" value=\"1,0\" percent=\"18,82\"\/&gt;&lt;\/sequence&gt;&lt;sequence name=\"spread\"&gt;  &lt;gen if=\"isQuiet == 1\" type=\"number\" value=\"80..260\"\/&gt;  &lt;gen type=\"number\" value=\"1..40\"\/&gt;&lt;\/sequence&gt;&lt;!-- hour of the event: the machine's spread sets how many hours its events land on --&gt;&lt;sequence name=\"Time\"&gt;  &lt;gen type=\"formula\"       expr=\"... 28800 + floor(hash(N, 3) * Spread) * 3600 + floor(hash(N, 4) * 3600)\"\/&gt;&lt;\/sequence&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Three stock features of TDCV2 are at work here. <code>percent<\/code> sets a share exactly, not by probability: there will be precisely 18% quiet machines. The <code>if<\/code> attribute picks the generator by a condition: a quiet machine gets a spread of 80-260 hours, everything else 1-40. And the time formula lays the machine\u2019s events out over <code>Spread<\/code> hours. A quiet machine has roughly as many events as any other, they just land one or two per hour &#8212; and the \u201cshare of new accounts\u201d in such a window starts taking the values 0, 1\/2 and 1, half of which look like the signature of an attack. There is no coincidence mechanism here at all: there are quiet machines, and coincidences are their arithmetic consequence.<\/p>\n<blockquote>\n<p>A coincidence is not a mechanism you have to model. It is what you get for free, if the shape of the data is right.<\/p>\n<\/blockquote>\n<h3>Six worlds, six networks<\/h3>\n<p>The second conclusion I had to take back was the one about ensembles.<\/p>\n<p>I measured it honestly and wrote it down: combining networks is pointless, because the spread of their predictions is <strong>0.0002<\/strong>, they make mistakes in the same places, and there is nothing to average. That was true &#8212; but true of networks trained on <em>one<\/em> world.<\/p>\n<p>Whereas if you train six networks on six <em>different<\/em> worlds and average their ranks &#8212; not their scores, their positions: each network arranges all 3.6 million windows into its own list, and a window is assigned its mean position over the six lists &#8212; this is what you get. The worlds here are <em>clean<\/em>: without the attack stages that taught the network in the rich world to fire on quiet windows, and also without sparseness, which is added at the next step. The table shows how many false alarms an analyst looks through on the way to the Nth attack found, on the held-out set, 3.6 million windows:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"335\" width=\"335\">\n<p align=\"left\">what exactly is being checked<\/p>\n<\/th>\n<th>\n<p align=\"left\">to the 1st<\/p>\n<\/th>\n<th>\n<p align=\"left\">to the 8th<\/p>\n<\/th>\n<th>\n<p align=\"left\">to the 16th<\/p>\n<\/th>\n<th>\n<p align=\"left\">to the 24th<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"335\" width=\"335\">\n<p align=\"left\">base world, one network &#8212; where we started<\/p>\n<\/td>\n<td>\n<p align=\"left\">3<\/p>\n<\/td>\n<td>\n<p align=\"left\">102<\/p>\n<\/td>\n<td>\n<p align=\"left\">188<\/p>\n<\/td>\n<td>\n<p align=\"left\">412<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"335\" width=\"335\">\n<p align=\"left\">base world, six seeds, ranks <em>(what averaging alone gives)<\/em><\/p>\n<\/td>\n<td>\n<p align=\"left\">11<\/p>\n<\/td>\n<td>\n<p align=\"left\">80<\/p>\n<\/td>\n<td>\n<p align=\"left\">117<\/p>\n<\/td>\n<td>\n<p align=\"left\">504<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"335\" width=\"335\">\n<p align=\"left\">one clean world, one network <em>(what the clean world alone gives)<\/em><\/p>\n<\/td>\n<td>\n<p align=\"left\">0<\/p>\n<\/td>\n<td>\n<p align=\"left\">2<\/p>\n<\/td>\n<td>\n<p align=\"left\">30<\/p>\n<\/td>\n<td>\n<p align=\"left\">222<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"335\" width=\"335\">\n<p align=\"left\"><strong>six different clean worlds, six networks, ranks<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>3<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>7<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>19<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"335\" width=\"335\">\n<p align=\"left\">the same six worlds, <em>merged into one set<\/em>, one network<\/p>\n<\/td>\n<td>\n<p align=\"left\">814<\/p>\n<\/td>\n<td>\n<p align=\"left\">6,992<\/p>\n<\/td>\n<td>\n<p align=\"left\">26,297<\/p>\n<\/td>\n<td>\n<p align=\"left\">59,973<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>A hundred and eighty-eight false alarms turned into <strong>seven<\/strong>. And neither factor gives that on its own: averaging by itself is 117, the clean world by itself is 30, and together they are 7.<\/p>\n<p>And now the last row, the one the control was set up for. The same six worlds, merged into <em>one training set<\/em> for one network, give 26,297 false alarms instead of 188. A hundred and forty times worse than the base.<\/p>\n<blockquote>\n<p>Variety merged into one set becomes contradiction. Split across separate models, it becomes strength.<\/p>\n<\/blockquote>\n<p>And it comes with a gauge you can use <em>before<\/em> the exam. The spread of opinion between networks &#8212; the mean deviation of scores across windows:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"321\" width=\"321\">\n<p align=\"left\">networks trained<\/p>\n<\/th>\n<th>\n<p align=\"left\">spread of opinion<\/p>\n<\/th>\n<th>\n<p align=\"left\">gain<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"321\" width=\"321\">\n<p align=\"left\">on one set<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.0002<\/p>\n<\/td>\n<td>\n<p align=\"left\">none<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"321\" width=\"321\">\n<p align=\"left\">on one world, different seeds<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.004<\/p>\n<\/td>\n<td>\n<p align=\"left\">1.6 times<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"321\" width=\"321\">\n<p align=\"left\">on different worlds<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.014<\/p>\n<\/td>\n<td>\n<p align=\"left\">twenty-seven times<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>What\u2019s convenient about this is that the spread is taken from the networks themselves, without touching the held-out set. If the networks agree, there is no point combining them, and you can spare the exam.<\/p>\n<h3>The micro-loop: reality names a machine, the config gets a line<\/h3>\n<p>And here is where the thing that makes a generator worth writing at all began.<\/p>\n<p>I never inspected the seven. But the ensemble of six sparse worlds gave the best AUC of the project, 0.944, and with it six false alarms <em>above the first attack<\/em>. Those were the first ones I actually looked at, to see <em>what<\/em> those windows were. Five out of six turned out to be one phenomenon: <strong>failure storms<\/strong>. Machines with 390-3391 events an hour and 90-99% unsuccessful logins. By the labels they are normal; in substance they are a broken service or somebody\u2019s brute force. For an analyst, a find; for the metric, an error.<\/p>\n<p>I checked how many of those are in the data: in LANL, 3 to 13 a day, 249 across the whole held-out set. And in my training worlds &#8212; <strong>zero<\/strong>. The config did have a role for \u201ca service with a broken password\u201d, but it produced 30-80% failures at an ordinary event volume. Not that kind of storm.<\/p>\n<p>And here an important thing came to light. By that point I had three <strong>families<\/strong> of worlds &#8212; six clean, six sparse and, skipping ahead, six with storms; a family is one config replicated with six seeds and different shares of coincidences. The upshot: <strong>all eighteen networks of the three families gave the storms the maximum rank<\/strong>. The ensemble had nothing to correct.<\/p>\n<blockquote>\n<p>An ensemble cures disagreement, not a shared delusion. A shared delusion is born from a shared hole in the worlds, and only a world can close it.<\/p>\n<\/blockquote>\n<p>What followed was the loop that is the real work with a generator: I look at the top false alarms, find a specific machine, work out what my world doesn\u2019t know about it, and add one or two lines to the config. The first row of the table is the base world, the second the sparse one the loop started from, then three rounds in a row. AUC here is on the hard day 12, and \u201cfalse alarms to the 6th\u201d is how many false alarms stand before the sixth of the twelve attacks labelled there:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"265\" width=\"265\">\n<p align=\"left\">what was added to the world<\/p>\n<\/th>\n<th data-colwidth=\"240\" width=\"240\">\n<p align=\"left\">found through which machine<\/p>\n<\/th>\n<th>\n<p align=\"left\">AUC, hard day<\/p>\n<\/th>\n<th>\n<p align=\"left\">false alarms to the 6th<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"265\" width=\"265\">\n<p align=\"left\"><em>base world, where we started<\/em><\/p>\n<\/td>\n<td data-colwidth=\"240\" width=\"240\">\n<p align=\"left\">&#8212;<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.990<\/p>\n<\/td>\n<td>\n<p align=\"left\">&#8212;<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"265\" width=\"265\">\n<p align=\"left\">sparse world: a tail of quiet machines<\/p>\n<\/td>\n<td data-colwidth=\"240\" width=\"240\">\n<p align=\"left\">the shape of LANL windows<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.99327<\/p>\n<\/td>\n<td>\n<p align=\"left\">0<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"265\" width=\"265\">\n<p align=\"left\">failure storms: 1.5% of machines, 90-100% failures, 1-4 hours<\/p>\n<\/td>\n<td data-colwidth=\"240\" width=\"240\">\n<p align=\"left\">the top of the held-out set<\/p>\n<\/td>\n<td>\n<p align=\"left\">0.99379<\/p>\n<\/td>\n<td>\n<p align=\"left\">3<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"265\" width=\"265\">\n<p align=\"left\">a person moving to another machine<\/p>\n<\/td>\n<td data-colwidth=\"240\" width=\"240\">\n<p align=\"left\">C3699 held 22 of the top 40<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0.99636<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\">0<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"265\" width=\"265\">\n<p align=\"left\">new machines 0.6% -&gt; 3%<\/p>\n<\/td>\n<td data-colwidth=\"240\" width=\"240\">\n<p align=\"left\">the first hours of new machines<\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>0.99743<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\">0<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>The relocation is worth telling separately, because I got it wrong the first time &#8212; and the mistake turned out to be a meaningful one.<\/p>\n<p>On the hard day one machine, C3699, held <em>twenty-two rows out of forty<\/em> at the top of the false alarms. The diagnosis: a person had moved onto it from another machine. I added a \u201crelocation\u201d role: from some hour onwards, half of the machine\u2019s events come from a stranger user. It did nothing at all.<\/p>\n<p>I compared the features of the real C3699 against my synthetic one &#8212; and the difference came down to one thing: in the real move, <strong>27 events out of 27<\/strong> produce a new triple of \u201caccount, source, destination\u201d, and in mine, zero. The reason is simple, and I should have used my head: a person who moves to another machine keeps going to <em>their own usual servers<\/em>, and for the new machine all of those are new. I had been making them walk the destinations of the machine\u2019s owner.<\/p>\n<p>A one-line fix. C3699 disappeared from the top forty entirely. After the third round, with the new machines, it came back in part &#8212; eight windows, starting from fifteenth place &#8212; but it no longer held the top.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/14e\/6dc\/525\/14e6dc5252c7fc3165203385069552be.svg\" alt=\"Reality names a specific machine, the config gets one or two lines. Three rounds - three errors pushed out of the top\" title=\"Reality names a specific machine, the config gets one or two lines. Three rounds - three errors pushed out of the top\" width=\"700\" height=\"330\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/14e\/6dc\/525\/14e6dc5252c7fc3165203385069552be.svg 780w,&#10;       https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/14e\/6dc\/525\/14e6dc5252c7fc3165203385069552be.svg 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption><em>Reality names a specific machine, the config gets one or two lines. Three rounds &#8212; three errors pushed out of the top<\/em><\/figcaption><\/div>\n<\/figure>\n<details class=\"spoiler\">\n<summary>What those three fixes look like in the config<\/summary>\n<div class=\"spoiler__content\">\n<p>So that \u201ctwo lines\u201d doesn\u2019t sound like a figure of speech &#8212; here they are verbatim, out of the actual files.<\/p>\n<p><strong>Storms<\/strong> (<a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-storm.tdc\" rel=\"noopener nofollow\">world-storm<\/a>): one role and three overrides in the places where an ordinary machine had values of its own (in the file they sit in three different sequences; here they are gathered together).<\/p>\n<pre><code class=\"xml\">&lt;!-- STORM. A service with a broken password hammering away without stopping:     one or two users, a few destinations, 90-100% failures, hundreds of events     an hour. In LANL there are 3-13 such windows a day, labelled normal.     Earlier worlds contained none, and the networks took a storm for an attack. --&gt;&lt;sequence name=\"isStorm\"&gt;&lt;gen type=\"text\" value=\"1,0\" percent=\"1.5,98.5\"\/&gt;&lt;\/sequence&gt;&lt;!-- and three overrides where an ordinary machine has its own values --&gt;&lt;gen if=\"isStorm == 1\" type=\"number\" value=\"1..2\"\/&gt;       &lt;!-- accounts --&gt;&lt;gen if=\"isStorm == 1\" type=\"number\" value=\"1..4\"\/&gt;       &lt;!-- hours: the whole storm in 1-4 --&gt;&lt;gen if=\"isStorm == 1\" type=\"number\" value=\"900..1000\"\/&gt;  &lt;!-- failures, out of a thousand --&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><strong>The relocation<\/strong> (<a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-move.tdc\" rel=\"noopener nofollow\">world-move<\/a>) came out longer, because the newcomer has to be not merely let in but sent walking to their own places:<\/p>\n<pre><code class=\"xml\">&lt;!-- MOVE and NEWCOMER. From some hour onwards half of the machine's events come     from a user it has never seen, and it stays that way to the end of the day,     with no failures. A move is a user of another machine (known in the network),     a newcomer is a fresh account (known nowhere).     The arrival goes to THEIR OWN destinations, new both to the machine and to     them: on C3699 every triple is new - that was the difference from version one. --&gt;&lt;sequence name=\"isMove\"&gt;&lt;gen type=\"text\" value=\"1,0\" percent=\"2,98\"\/&gt;&lt;\/sequence&gt;&lt;sequence name=\"isFresh\"&gt;&lt;gen type=\"text\" value=\"1,0\" percent=\"1,99\"\/&gt;&lt;\/sequence&gt;&lt;sequence name=\"moveHour\"&gt;&lt;gen type=\"number\" value=\"8..20\"\/&gt;&lt;\/sequence&gt;&lt;sequence name=\"Newcomer\"&gt;  &lt;gen type=\"formula\"       expr=\"Foreign == 0 &amp;&amp; IsSvc == 0 &amp;&amp; IsServ == 0 &amp;&amp; Period == 1             &amp;&amp; (W.isMove == 1 || W.isFresh == 1)             &amp;&amp; floor(Time \/ 3600) &gt;= W.moveHour &amp;&amp; hash(N, 21) &lt; 0.5 ? 1 : 0\"\/&gt;&lt;\/sequence&gt;&lt;sequence name=\"NewUser\"&gt;  &lt;gen type=\"formula\" expr=\"W.isMove == 1 ? ((W.hid * 37 + 11) % 2000) * 30 : 90000 + W.hid\"\/&gt;&lt;\/sequence&gt;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The difference between the first version of the move and the second is not in those lines &#8212; they are identical in both. It is in the destination formula, which isn\u2019t above. In the first version the newcomer walked the destinations of the machine\u2019s owner, and the \u201cnew triple\u201d feature never fired: the destinations were familiar to the machine. In the second they have a range of their own, new to the machine as well &#8212; one branch at the start of the <code>Dst<\/code> formula:<\/p>\n<pre><code>expr=\"Newcomer == 1 ? 5000 + (W.hid * 13) % 900 + floor(hash(N, 2) * 4) : (...)\"<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>That is precisely what separated the real C3699 from my invented one.<\/p>\n<p>The third fix, \u201cnew machines at 3%\u201d, is one number: the share of the \u201cnew machine\u201d role in the lookup table changes from <code>0.6<\/code> to <code>3<\/code>. The base world was 135 lines, the final one is 210. Everything in between was mined by rounds like these.<\/p>\n<\/div>\n<\/details>\n<p>In all of this the generator owes exactly one thing: to stay out of the way. It knows nothing about networks, storms and relocations, and it shouldn\u2019t &#8212; its job is that describing them takes two minutes once you\u2019ve worked out what was missing. The understanding is human work, and it isn\u2019t going anywhere.<\/p>\n<h3>How much of this is honest<\/h3>\n<p>This needs its own bookkeeping, otherwise it turns into cheating.<\/p>\n<p>A held-out set is valuable exactly up to the first look at it. The seven above was obtained <em>before<\/em> I first opened the top of the false alarms &#8212; that is an honest transfer. The storms I found having already looked there, and everything built on top inherited that knowledge. The difference is fundamental and the two must not be mixed:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th data-colwidth=\"405\" width=\"405\">\n<p align=\"left\">result<\/p>\n<\/th>\n<th data-colwidth=\"88\" width=\"88\">\n<p align=\"left\">AUC<\/p>\n<\/th>\n<th data-colwidth=\"140\" width=\"140\">\n<p align=\"right\">false alarms to the 16th<\/p>\n<\/th>\n<th>\n<p align=\"left\">how it was obtained<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td data-colwidth=\"405\" width=\"405\">\n<p align=\"left\">one network, base world &#8212; the first half of the article<\/p>\n<\/td>\n<td data-colwidth=\"88\" width=\"88\">\n<p align=\"left\">0.909<\/p>\n<\/td>\n<td data-colwidth=\"140\" width=\"140\">\n<p align=\"right\">188<\/p>\n<\/td>\n<td>\n<p align=\"left\">honestly<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"405\" width=\"405\">\n<p align=\"left\"><strong>six clean worlds, six networks<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"88\" width=\"88\">\n<p align=\"left\">0.938<\/p>\n<\/td>\n<td data-colwidth=\"140\" width=\"140\">\n<p align=\"right\"><strong>7<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>honestly<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"405\" width=\"405\">\n<p align=\"left\">six sparse worlds<\/p>\n<\/td>\n<td data-colwidth=\"88\" width=\"88\">\n<p align=\"left\"><strong>0.944<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"140\" width=\"140\">\n<p align=\"right\">10<\/p>\n<\/td>\n<td>\n<p align=\"left\">honestly<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td data-colwidth=\"405\" width=\"405\">\n<p align=\"left\">eighteen networks over three families: clean, with storms, final<\/p>\n<\/td>\n<td data-colwidth=\"88\" width=\"88\">\n<p align=\"left\">0.932<\/p>\n<\/td>\n<td data-colwidth=\"140\" width=\"140\">\n<p align=\"right\"><strong>1<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\">after diagnosing errors<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>The honest number to compare against the one we started with is <strong>seven instead of a hundred and eighty-eight<\/strong>. The one is real too, but it already involves peeking, and I quote it only with that note attached.<\/p>\n<p>And one more line in the same ledger: I consulted the held-out set <strong>at least twenty-three times<\/strong> over the project. Each consultation reduces its value a little. A genuinely clean check would be fresh Los Alamos days we never touched at all, used once. It hasn\u2019t been done, and saying so is more honest than staying quiet.<\/p>\n<p>It also turned out how many networks are worth keeping. The saturation curve: one network gives 43 false alarms before the sixteenth attack, two give 10, four give 4, six give 3, twelve give 1, and past that nothing changes. Saturation is at roughly twelve networks and three families of worlds with different phenomena. A fourth family added nothing.<\/p>\n<h3>For scale: what the specialists get<\/h3>\n<p>This isn\u2019t a league table, it\u2019s a coordinate system. I couldn\u2019t tell myself whether 0.909 was a lot or a little until I looked at what people who do this professionally get.<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">solution<\/p>\n<\/th>\n<th data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">AUC<\/p>\n<\/th>\n<th>\n<p align=\"left\">what it was trained on<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">LMTracker<\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">~0.95<\/p>\n<\/td>\n<td>\n<p align=\"left\">on labelled Los Alamos data<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><strong>six sparse worlds, six networks<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\"><strong>0.944<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>synthetic only<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><strong>six clean worlds, six networks<\/strong><\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\"><strong>0.938<\/strong><\/p>\n<\/td>\n<td>\n<p align=\"left\"><strong>synthetic only<\/strong><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">UGEA-LMD<\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">0.9254<\/p>\n<\/td>\n<td>\n<p align=\"left\">on labelled Los Alamos data<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">my boosting<\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">0.918<\/p>\n<\/td>\n<td>\n<p align=\"left\">synthetic only<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">one network, base world<\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">0.909<\/p>\n<\/td>\n<td>\n<p align=\"left\">synthetic only<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">fully connected, 1333 parameters<\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">0.862<\/p>\n<\/td>\n<td>\n<p align=\"left\">synthetic only<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">counter of \u201caccounts from a machine\u201d<\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">0.618<\/p>\n<\/td>\n<td>\n<p align=\"left\">&#8212;<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">counter of \u201cdestinations\u201d<\/p>\n<\/td>\n<td data-colwidth=\"149\" width=\"149\">\n<p align=\"right\">0.535<\/p>\n<\/td>\n<td>\n<p align=\"left\">&#8212;<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>A caveat about the top row: some papers publish not the value itself but only their first place in their own comparison, so I take the 0.92-0.95 range from the ones that give a number. And comparing AUC across different evaluation protocols head-on is incorrect &#8212; the splits differ &#8212; so this is a reference scale, not a comparison.<\/p>\n<p>Three honest conclusions.<\/p>\n<p><strong>First. Threshold rules lag far behind.<\/strong> On the held-out set they work barely better than a coin. By cost of search the difference is three orders of magnitude: to find sixteen windows out of 64, the counter needs about 161 thousand false alarms, and the recurrent network needs 188. That one I did not expect at all.<\/p>\n<p><strong>Second. The synthetic data did make it into the range of the research work<\/strong>, and that is nicer than I was counting on. Above UGEA-LMD, below LMTracker, six thousandths short of the upper bound. Both numbers are honest transfers, with no peeking at the held-out set. There isn\u2019t much to celebrate here, for the reason in the previous paragraph: the protocols differ and a head-on comparison is incorrect. But previously I was below everyone who publishes an AUC, and now I\u2019m in that range.<\/p>\n<p>I include the boosting row deliberately, even though I explained above that it\u2019s unfit: by AUC it formally holds its place in the same row, and by cost of search it is hundreds of times worse. The best illustration of why comparing methods by AUC alone is meaningless &#8212; including comparing against this very table. And nobody else\u2019s work publishes a cost of search, by the way, so on the measure that matters most to an analyst there are simply no reference points.<\/p>\n<p><strong>Third, and the main one: our problem statements differ.<\/strong> Everything listed needs labelled examples of the attack <em>in your network<\/em>. And an ordinary organisation doesn\u2019t have them: to get labels you first have to be compromised, discover it and label it. Commercial systems get around this with unsupervised profiling, but they require weeks of observation.<\/p>\n<p>I found no work in which a lateral-movement detector was trained on synthetic data only and checked against LANL. But I\u2019m not going to claim \u201cI\u2019m the first\u201d: absence from search results is not proof.<\/p>\n<h3>Can this be deployed<\/h3>\n<p>As a finished product, no. As a first-pass filter, yes.<\/p>\n<p>What works: four thousand parameters, features computed in a single streaming pass without loading the data into memory, without needing a single labelled incident from your network, and what comes out is not a verdict but a <em>queue<\/em> &#8212; \u201chere are twenty windows, look at these first\u201d.<\/p>\n<p>Three conditions, each of them measured.<\/p>\n<ol>\n<li>\n<p><strong>At least a week of network history.<\/strong> On the first day, where a machine has 464 events of history against 3500 on day 8, the method falls apart.<\/p>\n<\/li>\n<li>\n<p><strong>Refresh the baseline every two or three weeks<\/strong> &#8212; because of the scissors above.<\/p>\n<\/li>\n<li>\n<p><strong>This is a filter, not an auto-block.<\/strong> To find sixteen real windows an analyst looks through twenty-three windows out of 3.6 million &#8212; sixteen real and seven false. Acceptable for a review queue; still not acceptable for automatic response: half the labelled windows lie in the tail, in positions from three thousand to two million, and by the features of the hour they are indistinguishable from noise &#8212; one event, one new user, not a single failure. They can only be pulled out with other features, links between machines, and none of that is in this work.<\/p>\n<\/li>\n<\/ol>\n<p>A word about the anti-pattern. Allow-lists of the form \u201cthe machine passed a check, so we exclude it from analysis\u201d are an accelerated version of poisoning the baseline. Remember the 58.3%.<\/p>\n<h3>What didn\u2019t work<\/h3>\n<p>The negative results outnumbered the positive ones, and each came with an understood mechanism &#8212; which is worth more than the result itself.<\/p>\n<p><strong>Training on diluted windows.<\/strong> It sounded logical: teach the network to see one foreign event among thirty-seven. I got 1428 false alarms instead of 27. The mechanism was worked out: dilution teaches that <em>any<\/em> event with triple novelty means a compromise, and there are enough of those in 1330 normal windows.<\/p>\n<p><strong>Accumulating suspicion across neighbouring hours.<\/strong> An intrusion lasts hours, so it seemed one should accumulate. The result ranged from neutral to destructive, AUC 0.504. Diagnosis: the same self-poisoning mechanism repeated at the scale of hours &#8212; from the second hour onwards the activity enters the machine\u2019s own baseline.<\/p>\n<p><strong>Ensembles of networks from one world.<\/strong> A stack over the committee loses to plain averaging in every composition, and the cause is that same spread of 0.0002. Weak learners did give the stack some disagreement to work with, and it beat averaging for the first time, but the absolute level stayed below a single network. The qualifier \u201cfrom one world\u201d is essential here: as soon as the worlds became different, the ensemble started working &#8212; that was above, and it is exactly the case where a negative result turned out to be only half right.<\/p>\n<p><strong>And my own mistakes.<\/strong> Twice, twelve features out of sixteen made it to the model\u2019s input, the missing ones arrived empty, and the network output a constant. I caught it by its characteristic signature &#8212; an AUC of exactly 0.50000. Another time the threshold search turned out to be quadratic and simply never finished on 3.6 million values. And once I broke my own rule of \u201ccheck the synthetic data before training\u201d &#8212; the result was cut in half.<\/p>\n<h3>Where everything is, and how to check it<\/h3>\n<p>Everything above exists as real files, not as a retelling. It is all in <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\" rel=\"noopener nofollow\">the tdc-guard repository<\/a>, with a step-by-step guide in <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/README.md\" rel=\"noopener nofollow\">README<\/a> listing the expected numbers at every step (there is a <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/README.ru.md\" rel=\"noopener nofollow\">README in Russian<\/a> right beside it). The layout, so that you can look at it or run it yourself. In the repository\u2019s file names the held-out set is called sealed: exam\/sealed.mjs, results\/sealed-windows.csv.<\/p>\n<h4>The worlds<\/h4>\n<p>The configs live in the repository in two copies: in <code>gen\/<\/code> with English comments and in <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/tree\/main\/ru\/gen\" rel=\"noopener nofollow\">the Russian copies<\/a> with Russian ones. The lines of code are the same in both.<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">file<\/p>\n<\/th>\n<th>\n<p align=\"left\">what\u2019s in it<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world.tdc\" rel=\"noopener nofollow\">world<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the base world, 135 lines &#8212; the one quoted in full in the spoiler above<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-nostage.tdc\" rel=\"noopener nofollow\">world-nostage<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the clean world: the rich world without attack stages, family ns &#8212; six of these gave the honest seven<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-sparse.tdc\" rel=\"noopener nofollow\">world-sparse<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">sparse: the quiet machines the coincidences were born from<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-storm.tdc\" rel=\"noopener nofollow\">world-storm<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">plus failure storms<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-move.tdc\" rel=\"noopener nofollow\">world-move<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">plus a person moving to another machine<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-newhost.tdc\" rel=\"noopener nofollow\">world-newhost<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the final one, 210 lines<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/world-coin.tdc\" rel=\"noopener nofollow\">world-coin<\/a>, <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/gen\/make-control.sh\" rel=\"noopener nofollow\">make-control<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the world with an explicit coincidence mechanism, and the control that refuted it<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/tree\/main\/gen\" rel=\"noopener nofollow\">the world scripts<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the <code>make-*-worlds.sh<\/code> scripts: the sets of six worlds with different seeds, one script per family<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<h4>The feature builder (the measurer) and the exam<\/h4>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">file<\/p>\n<\/th>\n<th>\n<p align=\"left\">what\u2019s in it<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/judge\/windows.mjs\" rel=\"noopener nofollow\">windows<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the single feature builder, shared by the synthetic data and reality<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/judge\/rows.mjs\" rel=\"noopener nofollow\">rows<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the loader that fails if not all the features arrived &#8212; added after my two AUCs of exactly 0.50000<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/tree\/main\/exam\" rel=\"noopener nofollow\">the exam runs<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the <code>*.mjs<\/code> runs against the real Los Alamos logs<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<h4>Networks and experiments<\/h4>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">file<\/p>\n<\/th>\n<th>\n<p align=\"left\">what\u2019s in it<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/model.py\" rel=\"noopener nofollow\">model<\/a>, <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/data.py\" rel=\"noopener nofollow\">data<\/a>, <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/evaluate.py\" rel=\"noopener nofollow\">evaluate<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the model, feature encoding, AUC by ranks and the cost curve in one pass<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/sweep_big.py\" rel=\"noopener nofollow\">sweep_big<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the sweep over 31 fully connected architectures<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/recurrent.py\" rel=\"noopener nofollow\">recurrent<\/a>, <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/exotic.py\" rel=\"noopener nofollow\">exotic<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the recurrent ones and the nine exotic ones<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/heldout_ensemble.py\" rel=\"noopener nofollow\">heldout_ensemble<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">six networks on six worlds against the held-out set &#8212; the main result<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/heldout_sameworld.py\" rel=\"noopener nofollow\">heldout_sameworld<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the control: six seeds of one world, for the row in the attribution ladder<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/top_false.py\" rel=\"noopener nofollow\">top_false<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the top forty false alarms with their features &#8212; the instrument of the micro-loop<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/net_py\/saturation.py\" rel=\"noopener nofollow\">saturation<\/a><\/p>\n<\/td>\n<td>\n<p align=\"left\">the saturation curve: how many networks are worth keeping<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<h4>The record<\/h4>\n<p><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/DIARY.md\" rel=\"noopener nofollow\">DIARY<\/a> is the entries by date, every run in order, including the failed ones and the traps. <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/FACTS.md\" rel=\"noopener nofollow\">FACTS<\/a> is the set of numbers with notes on what was obtained honestly and what came after diagnosing errors. The second document is more useful for verification, the first is more honest: you can see how many times I was wrong along the way. The Russian versions sit next to them under a <code>.ru<\/code> suffix (<a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/DIARY.ru.md\" rel=\"noopener nofollow\">DIARY in Russian<\/a>, <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/FACTS.ru.md\" rel=\"noopener nofollow\">FACTS in Russian<\/a>, <a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\/blob\/main\/README.ru.md\" rel=\"noopener nofollow\">README in Russian<\/a>).<\/p>\n<p>The data is open and freely downloadable from <a href=\"https:\/\/csr.lanl.gov\/data\/cyber1\/\" rel=\"noopener nofollow\">the Los Alamos portal<\/a>. The worlds are deterministic given a seed and rebuild byte for byte, so the numbers above reproduce for you without asking me.<\/p>\n<h3>What this adds up to<\/h3>\n<p><strong>You can<\/strong> get a working detector without a single labelled incident in your network. Twenty-five thousand parameters, six small networks, training measured in seconds on a laptop. Seven false alarms on the way to the sixteenth attack out of three and a half million windows &#8212; against a hundred and sixty-one thousand for the threshold counter, which works directly on your real data at that. The whole thing took two days: one for reconnaissance of the data, the generator and the first network, the second for experiments with worlds, ensembles and the micro-loop.<\/p>\n<p><strong>You can\u2019t<\/strong> &#8212; and here I had to take my words back twice.<\/p>\n<p>First I wrote that I had hit a fundamental limit: a generator produces regularities, reality consists of regularities plus coincidences, and coincidences cannot be synthesized. It sounded solid. It turned out coincidences appear by themselves if the windows have the right shape, and all that was missing was sparseness.<\/p>\n<p>Then I wrote that ensembles are useless on this problem, because the networks make mistakes in the same places. Also true &#8212; right up until the networks stop learning from one world.<\/p>\n<p>Both times the ceiling turned out to be not the method but something I hadn\u2019t thought through about my own world. The real boundaries I am prepared to defend lie elsewhere: half the labelled windows are indistinguishable from noise by the features of the hour, and there is nothing here to pull them out with; and the held-out set I touched at least twenty-three times, so by rights it is long overdue for replacement with fresh days.<\/p>\n<h4>Five rules I took away<\/h4>\n<ul>\n<li>\n<p><strong>Synthetic data has to be right in meaning, not similar in statistics.<\/strong> The account must be real and stolen; an invented one breaks everything, and no network settings will fix that.<\/p>\n<\/li>\n<li>\n<p><strong>The world and the architecture are one decision, not two.<\/strong> Five times over the project, a change of world changed which architecture won. You cannot compare networks on one world and carry the conclusion to another.<\/p>\n<\/li>\n<li>\n<p><strong>AUC and the cost of search diverge.<\/strong> Not twice and not by accident, but systematically: the ordering of models by one measure doesn\u2019t match the ordering by the other.<\/p>\n<\/li>\n<li>\n<p><strong>An ensemble cures disagreement, not a shared delusion.<\/strong> If every network is wrong in the same way there is nothing to combine &#8212; only a world closes that hole.<\/p>\n<\/li>\n<li>\n<p><strong>Variety merged into one set becomes contradiction; split across models, it becomes strength.<\/strong> The same six worlds: one network per world gives seven false alarms, merged into one set they give twenty-six thousand.<\/p>\n<\/li>\n<\/ul>\n<p>The conclusion about the generator itself is boring, and that\u2019s the best thing that can be said about it. It survived more than a dozen versions of the world and never once became the bottleneck. The bottleneck was somewhere else the whole time: working out <em>what exactly<\/em> to produce. Reconnaissance of the data, diagnosing one specific machine at the top of the false alarms, guessing what my world doesn\u2019t know about it &#8212; and only then one line of config.<\/p>\n<blockquote>\n<p>In problems built on synthetic data, the bottleneck is never the model.<\/p>\n<\/blockquote>\n<p>What I liked most in this work is the loop from the last sections but one. The detector\u2019s error names a specific machine; you look at it and understand which phenomenon is missing from your invented world; you add two lines; the error is gone. Note that the interesting part is entirely human &#8212; and that is, perhaps, good news for anyone worried that data is about to start generating itself.<\/p>\n<p>The tool is called TDCV2 and it is MIT licensed.<\/p>\n<ul>\n<li>\n<p><a href=\"https:\/\/github.com\/NickLiapin\/tdcv2\" rel=\"noopener nofollow\">TDCV2 source on GitHub<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/github.com\/NickLiapin\/tdc-guard\" rel=\"noopener nofollow\">the tdc-guard repository<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/nickliapin.github.io\/tdcv2\/docs\/intro\" rel=\"noopener nofollow\">Documentation TDCV2<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/csr.lanl.gov\/data\/cyber1\/\" rel=\"noopener nofollow\">The Los Alamos data<\/a> &#8212; open, freely downloadable<\/p>\n<\/li>\n<\/ul>\n<p>If you work in this field professionally and can see where I went wrong, write in the comments &#8212; I\u2019m genuinely interested. Especially about the baseline scissors: I described them as a measured property, but I don\u2019t know how commercial systems live with this, and I won\u2019t pretend otherwise.<\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1081090\/\">https:\/\/habr.com\/ru\/articles\/1081090\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Can you train a cyberattack detector without ever showing it a real cyberattack?It sounds like a contradiction. If you want a neural network to detect lateral movement, you would expect to show it lateral movement. I did the opposite: I generated an entire corporate network with its login history, staged an attack inside that artificial world, and trained networks on it. Not a single real row in the training data. The whole world is a 135-line config; each network has four thousand parameters and trains in seconds on a laptop, and the best result came from six of them, trained on six different invented worlds.Then I pointed them at real data: the authentication logs of Los Alamos National Laboratory, 1.65 billion events, with red-team exercises labelled in them.And it worked. The networks rank 3.6 million windows by suspicion, and the top twenty-three rows of that list hold sixteen real attacks and seven false alarms: all the analyst has to do is open those rows. A threshold counter on the same data needs a hundred and sixty-one thousand false alarms to reach the sixteenth attack. By AUC the synthetic training landed inside the range of published research trained on real labelled data, although the two cannot be compared head-on, and I will explain why.No miracle happened. Something else did: twice I wrote down an elegant conclusion, and twice the experiment made me take it back. And I found the loop that makes a data generator worth writing at all: the detector\u2019s error points at one specific machine, you work out which phenomenon your invented world lacks, you add two lines to the config, and the error is gone.The rest, in order: who I am and why I needed this, what lateral movement is, how the invented world is built, what the exam showed, and how much of it is honest. If you do not need the theory, skip ahead to \u201cThe first result on real data\u201d.I write TDCV2, an open-source constructor for test data, and I wanted to know what it is actually good for. Filling a table with fake Smiths is one thing; producing data that something working can be trained on is another. You can\u2019t check that on a toy, so I took a real information-security problem, trained a classifier on invented data only, and turned it loose on the logs of a real network at Los Alamos National Laboratory.Three caveats, so the rest reads the way it should.I am not a machine-learning specialist. Everything below is the work of a person who writes a data generator and decided to test it on a serious problem. If it looks to you like I\u2019m reinventing a wheel somewhere, or reaching for the wrong tool &#8212; you\u2019re probably right.I am not competing with anyone. The results from research groups further down are there as a coordinate system, so the reader has a sense of scale, not because I\u2019m claiming a place in that row.I wasn\u2019t counting on a result. Honestly, I expected the synthetic data to do worse! It went differently, and that\u2019s the interesting part. There was no miracle anyway, but two things happened that are more interesting than a miracle: a measured boundary of a whole class of methods, and a limit on training from generated data that I then retracted myself.What lateral movement isSince the headline opens with a bit of jargon, let me explain what this beast is first. All the more so because the phenomenon is built in a non-obvious way, and half of your intuition about \u201chacking\u201d doesn\u2019t apply.There\u2019s a common picture in which the attacker cracks a password and that\u2019s the end of it. In reality, getting a login somewhere is only the beginning, and it is almost never the place they wanted. The first thing to fall is somebody\u2019s workstation: a person opened an attachment, or a service with an old hole was exposed to the outside. There is nothing valuable on that machine. The valuable things are in the database, on the file server, in the domain controller &#8212; and those still have to be reached.Reaching them is what\u2019s called lateral movement. In the MITRE ATT&amp;CK classification it is a tactic of its own, TA0008, and its most troublesome technique is T1078, Valid Accounts.How it spreadsThe mechanism is simple. A captured machine almost always has somebody else\u2019s credentials lying on it: a cached domain login, a token in memory, a password in a maintenance script, a saved connection. The attacker takes them and logs in to the neighbouring machine &#8212; an ordinary login, done exactly the way a real employee does it. There they collect that machine\u2019s credentials. And round it goes.After that it\u2019s arithmetic: every step grows both the number of machines and the number of available accounts, and every new account opens new machines. One workstation becomes ten, ten become a hundred.How this spreads. In our data it was exactly like that: 94% of the labelled events came from a single foothold, and the exercise reached 301 machines in allThe speed is nothing like the movies. Each step takes hours, the whole picture unfolds over days and weeks. That, broadly, is the one piece of good news &#8212; there is time to notice.Who this affects, and who it doesn\u2019tLateral movement needs somewhere to move, laterally. It lives in large corporate networks: hundreds and thousands of machines, a shared domain, service accounts with access to many places at once, administrators who legitimately go everywhere.A small business is not threatened by this phenomenon as such &#8212; not because it is safer there, but because there is nowhere to \u201cmove\u201d. If a company is five people, three laptops and all the work in cloud services, then an attacker who has taken the accountant\u2019s laptop is already where the money is. They need no \u201ctactic\u201d, it\u2019s all at hand. Such a company will have other problems, and those are caught differently.So everything below is about a network with somewhere to go.Why it is hard to catchAn awkward property of this phenomenon. Every single lateral-movement event is flawless: the password is correct, the answer is \u201csuccess\u201d, the account is genuine, the domain is our own, the time is business hours. There is nothing anomalous in it, because it really is an ordinary login &#8212; just made by the wrong person.To a signature detector, and to any threshold counter, it is indistinguishable from an employee doing their job.An ordinary login on the left, lateral movement on the right. Every field is valid in both cases; a single event cannot tell them apartWhy I needed this particular problemNow I can come back to where I started &#8212; the generator.Testing a data constructor on a problem whose answer is known in advance isn\u2019t interesting. You want one where invented data will either work or fail honestly, and where the outcome can be measured rather than eyeballed. Lateral movement fitted for three reasons, and each of them matters.Nobody has labelled examples. To train a detector the usual way, you need examples of the attack in your own network. And to get them, you first have to be compromised, notice it, and label it. Synthetic data here isn\u2019t a shortcut, it\u2019s the only way &#8212; if it works at all.The phenomenon has a computable definition. \u201cAn account logs in where it has never been, from a machine where it has never been\u201d is a rule, not a picture. And a rule can be written down as a config without a single real example in front of you.There is open data from a real network, with labelled ground truth in it. Which means the exam can be taken honestly: train on the invented, check on the live, and not grade your own homework.The plan followed from that, and it was simple. Show the network not one real row &#8212; not in training, not in validation, not in threshold picking. Write the entire training world as a generator config. And then turn it loose on real logs and see what it finds there.The exam data here is the open Los Alamos set Comprehensive, Multi-Source Cyber-Security Events: 58 days, 17,684 machines, 12,425 users, 1.65 billion events. What interests me is the authentication log.The labelled truth in it is the record of a red team exercise. And it looks like this:749 exercise events: 104 compromised accounts, 301 destination machines and a total of 4 sources. 94% of the events come from a single foothold.One machine. A hundred stolen logins. Three hundred places.The exercise activity is spread across the days extremely unevenly, and from here on I refer to three sets constantly. Everything below is counted in windows: a window is one machine for one hour, everything it did in that hour. A labelled window is one that caught at least one exercise event. To keep it straight, let\u2019s fix the names right away:setwhat it iswindowslabelledday 8the densest activity; debugging was done on it231,78715day 12second densest, also a working day223,98712held-out setthe other sixteen days, never touched3,600,39864Day 8 turned out to be easy &#8212; almost any model solves it. Day 12 is hard: on it the models finally differ. The held-out set is the only honest measure, and it will become clear why.The rule that saved the project: the exam firstMy first impulse was to sit down and write the generator config. Good thing I didn\u2019t.I spent half a day opening up and measuring the real data before the first line of config. And it turned out the problem was not the one I had pictured: I was thinking about password guessing, and what\u2019s labelled is an entirely different phenomenon. Everything I would have written in advance would have gone in the bin, all of it.The reconnaissance produced three facts that determined absolutely everything that followed.The account name carries no useful signalThe main \u201cvictim\u201d of the exercise &#8212; the account through which the most labelled events passed &#8212; appears 30,497 times in a clean four-hour slice. That is the busiest regular administrator, and catching them as an anomaly is pointless.Which means the feature has to be not about whose account it is, but about where it came from. Not a property, but a relationship.The naive counter is fooled by the shape of the networkThe hypothesis \u201cmany&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-494307","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/494307","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=494307"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/494307\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=494307"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=494307"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=494307"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}