From f60b75c83a7a9440e31aec984ccb7ca5d4efd93f Mon Sep 17 00:00:00 2001 From: Stephen Nemeth Date: Fri, 21 Aug 2026 08:41:15 -0600 Subject: [PATCH] tested; works --- crackers.js | 327 +++++++-- darknet-looter.js | 121 +++- darknet-virus.js | 30 + darknet.js | 1611 ++++++++++++++++++++++++++++++++++----------- dnet-killall.js | 106 +++ 5 files changed, 1760 insertions(+), 435 deletions(-) create mode 100644 dnet-killall.js diff --git a/crackers.js b/crackers.js index 1f1895ef..759ec490 100644 --- a/crackers.js +++ b/crackers.js @@ -34,17 +34,120 @@ export function largestPrimeFactor(n) { return m; } +// Decodes one roman numeral, or null if the string isn't one. +// +// Upstream's romanNumeralEncoder returns the literal "nulla" for zero, and +// that word is a trap: uppercasing it and stripping to [IVXLCDM] leaves "LL", +// so a naive decoder reads Latin for "nothing" as 100. Handle it explicitly +// and reject anything that isn't a numeral rather than silently scoring the +// characters that happen to survive a filter. export function romanDecode(s) { + const t = String(s ?? '').trim(); + if (!t) return null; + if (t.toLowerCase() === 'nulla') return 0; + if (!/^[IVXLCDM]+$/i.test(t)) return null; const map = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 }; + const u = t.toUpperCase(); let total = 0; - for (let i = 0; i < s.length; i++) { - const cur = map[s[i]] || 0; - const next = map[s[i + 1]] || 0; + for (let i = 0; i < u.length; i++) { + const cur = map[u[i]]; + const next = map[u[i + 1]] || 0; total += cur < next ? -cur : cur; } return total; } +// BellaCuore ships its numerals two ways (getRomanNumeralConfig): +// difficulty < 8: passwordHintData = the encoded password +// difficulty >= 8: passwordHintData = "encodedMin,encodedMax", a range +// Returns {exact} or {lo, hi}, or null if neither parses. +export function romanHint(data, hint) { + const src = String(data ?? '').trim() || String(hint ?? '').match(/'([^']*)'\s*and\s*'([^']*)'/)?.slice(1, 3).join(',') || ''; + if (!src) return null; + const parts = src.split(',').map(x => x.trim()).filter(Boolean); + if (parts.length >= 2) { + // Decode each bound on its own. Concatenating them first is what turned + // "nulla,LXVIII" (0 to 68) into the single numeral 168. + const lo = romanDecode(parts[0]), hi = romanDecode(parts[1]); + if (lo == null || hi == null) return null; + return { lo: Math.min(lo, hi), hi: Math.max(lo, hi) }; + } + const exact = romanDecode(parts[0]); + return exact == null ? null : { exact }; +} + +// Upstream's cleanArithmeticExpression, verbatim. Order matters: the +// "ns.exit()," splice is removed before the split on "," so that a nested +// injection doesn't truncate the real expression early. +export function cleanArithmetic(expression) { + return String(expression ?? '') + .replaceAll('\u04B3', '*') + .replaceAll('\u00F7', '/') + .replaceAll('\u2795', '+') + .replaceAll('\u2796', '-') + .replaceAll('ns.exit(),', '') + .split(',')[0]; +} + +// Recursive-descent evaluator for the cleaned expression. Deliberately not +// eval(): the injected tail exists precisely to catch that, and a parser that +// only understands numbers, + - * / and parentheses can't be made to run +// anything. Standard precedence, matching upstream's +// parseSimpleArithmeticExpression (parentheses, then * and /, then + and -). +// Returns null on anything malformed. +export function evalArithmetic(expr) { + const src = String(expr ?? ''); + let i = 0; + const ws = () => { while (i < src.length && src[i] === ' ') i++; }; + const peek = () => { ws(); return src[i]; }; + + const parseAtom = () => { + ws(); + if (src[i] === '(') { + i++; + const v = parseSum(); + ws(); + if (src[i] !== ')') return null; + i++; + return v; + } + if (src[i] === '-') { i++; const v = parseAtom(); return v == null ? null : -v; } + if (src[i] === '+') { i++; return parseAtom(); } + const m = /^\d*\.?\d+/.exec(src.slice(i)); + if (!m) return null; + i += m[0].length; + return parseFloat(m[0]); + }; + const parseProduct = () => { + let v = parseAtom(); + if (v == null) return null; + for (;;) { + const op = peek(); + if (op !== '*' && op !== '/') return v; + i++; + const r = parseAtom(); + if (r == null) return null; + v = op === '*' ? v * r : v / r; + } + }; + const parseSum = () => { + let v = parseProduct(); + if (v == null) return null; + for (;;) { + const op = peek(); + if (op !== '+' && op !== '-') return v; + i++; + const r = parseProduct(); + if (r == null) return null; + v = op === '+' ? v + r : v - r; + } + }; + + const out = parseSum(); + ws(); + return i === src.length && out != null && Number.isFinite(out) ? out : null; +} + // Generic fallback guesser for any modelId we don't have a specific solver for. export function guessFromHint(hint, len) { len = Math.max(1, len || 1); @@ -60,7 +163,25 @@ const DOGS = ["fido","spot","rover","max","bella","luna","charlie","buddy","rock const EU_COUNTRIES = ['Austria', 'Belgium', 'Bulgaria', 'Croatia', 'Republic of Cyprus', 'Czech Republic', 'Denmark', 'Estonia', 'Finland', 'France', 'Germany', 'Greece', 'Hungary', 'Ireland', 'Italy', 'Latvia', 'Lithuania', 'Luxembourg', 'Malta', 'Netherlands', 'Poland', 'Portugal', 'Romania', 'Slovakia', 'Slovenia', 'Spain', 'Sweden']; -const TOP_PASSWORDS = ['123456', 'password', '12345678', 'qwerty', '12345', '123456789', 'football', 'iloveyou', 'admin', 'welcome']; +// Upstream's commonPasswordDictionary verbatim (models/dictionaryData.ts). +// This used to be a hand-written top-10, which is why TopPass sat at an 8% +// solve rate: getLargeDictionaryConfig draws uniformly from all 93 entries, +// and "thomas", "asdfgh" and "987654321" were never among the ten guesses. +// The caller filters by password length, which is what keeps this inside the +// attempt cap - the longest bucket (6 characters) holds 53 entries. +const TOP_PASSWORDS = [ + "123456", "password", "12345678", "qwerty", "123456789", "12345", "1234", "111111", "1234567", + "dragon", "123123", "baseball", "abc123", "football", "monkey", "letmein", "696969", "shadow", + "master", "666666", "qwertyuiop", "123321", "mustang", "1234567890", "michael", "654321", + "superman", "1qaz2wsx", "7777777", "121212", "0", "qazwsx", "123qwe", "trustno1", "jordan", + "jennifer", "zxcvbnm", "asdfgh", "hunter", "buster", "soccer", "harley", "batman", "andrew", + "tigger", "sunshine", "iloveyou", "2000", "charlie", "robert", "thomas", "hockey", "ranger", + "daniel", "starwars", "112233", "george", "computer", "michelle", "jessica", "pepper", "1111", + "zxcvbn", "555555", "11111111", "131313", "freedom", "777777", "pass", "maggie", "159753", + "aaaaaa", "ginger", "princess", "joshua", "cheese", "amanda", "summer", "love", "ashley", + "6969", "nicole", "chelsea", "biteme", "matthew", "access", "yankees", "987654321", "dallas", + "austin", "thunder", "taylor", "matrix" +]; // Shared safety ceiling for consumers that don't have their own configurable // retry option (darknet.js uses its own max-auth-retries opt; the other three @@ -106,9 +227,21 @@ export function getCandidates(modelId, hint, len, data) { return [dig.slice(0, l) || '0'.repeat(l)]; } - case 'PR0verFL0': { - const words = ['overfl', 'prover', 'buffer', 'admin', 'overflow', 'prove', 'proof']; - return words.map(w => w.slice(0, l)); + // Upstream spells this "Pr0verFl0" (Enums.ts BufferOverflow). The old + // 'PR0verFL0' spelling here never matched a real server, so this + // branch was dead and the model fell through to the default guesser. + case 'Pr0verFl0': { + // Not a password guess at all. checkPassword() lays out a buffer of + // `len` received chars followed by `len` expected chars, copies the + // attempt over it, then compares the two halves - so any 2*len + // string whose halves are equal authenticates. Dictionary words + // were never going to work. + // + // NOTE: a caller that truncates candidates to passwordLength (as + // the generic loop in darknet.js does) will cut this back to `len` + // and defeat it. darknet.js routes this model to authInteractive() + // for that reason. + return ['a'.repeat(l * 2)]; } case 'DeepGreen': { @@ -125,6 +258,12 @@ export function getCandidates(modelId, hint, len, data) { } case '2G_cellular': + // Every attempt's message reports the index of the first wrong + // character, which builds the password left to right - so this is + // a prefix oracle and needs the response to each probe. + // darknet.js handles it in authTimingAttack() and never gets here. + // The old "0".repeat(len) was a single candidate that could only + // ever be re-sent unchanged. return [String(0).padStart(l, '0')]; case 'PrimeTime 2': { @@ -133,8 +272,25 @@ export function getCandidates(modelId, hint, len, data) { } case 'BellaCuore': { - const roman = (d || h).toUpperCase().replace(/[^IVXLCDM]/g, ''); - return [roman ? String(romanDecode(roman)) : '1']; + // Two forms. Below difficulty 8 the data is the password itself, + // roman-encoded, so it decodes in one step. At or above 8 it is a + // range and the model becomes a higher/lower guessing game with + // Latin feedback ("ALTUS NIMIS" too high, "PARUM BREVIS" too low), + // which needs the response to each attempt - darknet.js routes that + // case to authHiLo() and never reaches here. + // + // The old branch uppercased the whole data string and stripped it + // to [IVXLCDM], which mangles the range form badly: the two Ls in + // "NULLA" survive, so "nulla,LXVIII" (0 to 68) decoded as the + // single numeral LLLXVIII = 168. + const parsed = romanHint(d, h); + if (!parsed) return ['1']; + if (parsed.exact != null) return [String(parsed.exact)]; + const lo = Math.max(parsed.lo, l === 1 ? 0 : Math.pow(10, Math.min(l, 6) - 1)); + const hi = Math.min(parsed.hi, Math.pow(10, Math.min(l, 6)) - 1); + const out = []; + for (let n = lo; n <= hi; n++) out.push(String(n)); + return out.length ? out : [String(parsed.lo)]; } case 'Laika4': { @@ -148,25 +304,23 @@ export function getCandidates(modelId, hint, len, data) { } case 'AccountsManager_4.2': { - // This is a higher/lower guessing game and genuinely needs live - // feedback between guesses to solve efficiently; a fixed candidate - // list can't adapt mid-search. This returns a reasonable spread of - // guesses across the hinted range as an interim measure (better - // than the single-guess-per-call version, which had no memory of - // prior real attempts at all), but the real fix is a dedicated - // interactive solver that reads each authenticate() response - // before generating the next guess. Flagging this rather than - // pretending it's fully solved. - const range = h.match(/between\s*(\d+)\s*and\s*(\d+)/i); - let lo = 0, hi = 100; - if (range) { lo = parseInt(range[1], 10); hi = parseInt(range[2], 10); } - const mid = Math.floor((lo + hi) / 2); - const step = Math.max(1, Math.floor((hi - lo) / 4)); - const guesses = [ - mid, mid + step, mid - step, mid + 2 * step, mid - 2 * step, - Math.floor(mid + step / 2), Math.floor(mid - step / 2), hi, lo, - ].filter(g => g >= lo && g <= hi); - return [...new Set(guesses)].map(g => String(g).padStart(l, '0')); + // Higher/lower guessing game. Solving it properly means reading + // each attempt's "Lower"/"Higher" feedback back out of the server's + // packet log with heartbleed() - authenticate() itself returns no + // data for this model - which a static candidate list can't do. + // darknet.js routes this model to authAccountsManager() and never + // reaches here; this branch is the no-feedback fallback, so it + // sweeps the whole space (authenticate() has no cooldown). + // + // The password is stored as String(n), never zero-padded, so a + // length-L password is a plain L-digit decimal: 0-9 for L=1, + // 10-99 for L=2. The hint's "between 0 and 10^L" upper bound is + // just the length restated; the low end is not 0. + const lo = l === 1 ? 0 : Math.pow(10, Math.min(l, 6) - 1); + const hi = Math.pow(10, Math.min(l, 6)) - 1; + const out = []; + for (let n = lo; n <= hi; n++) out.push(String(n)); + return out; } case 'TopPass': @@ -188,8 +342,24 @@ export function getCandidates(modelId, hint, len, data) { } case 'RateMyPix.Auth': { - const count = d.split('\ud83c\udf36').length - 1; - return [count > 0 ? String(count) : '0'.repeat(l)]; + // "Bulls-only" Mastermind: each attempt scores one chilli per + // character sitting in exactly the right position ("\ud83c\udf36\ufe0f\ud83c\udf36\ufe0f/5"), so + // it can only be solved by reading the score between attempts. + // darknet.js routes this model to authSpiceLevel() and never + // reaches here. + // + // The previous version counted chillis in `data` and returned that + // count as the password. The chilli count is per-attempt feedback, + // not a server property, and this model sets no passwordHintData + // at all - its only static hint is the literal "!!\ud83c\udf36\ufe0f!!" - so `data` + // was always "" and this always guessed "0".repeat(len). + // + // Without a feedback channel the best a static list can do is the + // all-one-character probes, which win outright on the occasional + // uniform password and are the same probes the real solver opens + // with. Letters only appear above difficulty 8. + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + return alphabet.split('').map(c => c.repeat(l)); } case 'OctantVoxel': { @@ -210,23 +380,63 @@ export function getCandidates(modelId, hint, len, data) { } case 'MathML': { - const expr = (d || h).replace(/[^0-9+\-*/().]/g, ''); - if (!expr) return ['0']; - try { return [String(eval(expr))]; } catch { return ['0']; } + // Above difficulty 12 the operators are swapped for lookalikes + // (ҳ ÷ ➕ ➖), and above 16 there's a decent chance the expression + // has a code-injection tail bolted on - a deliberate trap for + // anyone who reaches for eval(): + // , !globalThis.pwn3d && (globalThis.pwn3d=true, alert(...) ... + // plus sometimes an "ns.exit()," spliced inside a parenthesis. + // + // The old branch did exactly the wrong two things: it stripped + // every character outside [0-9+-*/().], which deletes all four + // lookalike operators and silently fuses the operands into one + // enormous number, and then it ran eval() on the result. It solved + // half the servers it saw - the ones below difficulty 13, where + // the operators are still ASCII. + const cleaned = cleanArithmetic(d || h); + const val = evalArithmetic(cleaned); + return [val == null ? '0' : String(val)]; } - case 'Factori-Os': - // Unclear puzzle semantics; darknet.js guessed '1'-repeated, - // darknet-virus.js gave up with an empty list. Guessing something - // beats guessing nothing, keeping the non-empty behavior. - return ['1'.repeat(l)]; + case 'Factori-Os': { + // A divisibility oracle: whatever number you send, the response + // says whether it divides the password. Solving it means probing + // primes and reading "true"/"false" back out of the packet log, so + // darknet.js routes this to authFactoriOs() and never gets here. + // + // The old '1'.repeat(len) guess had nothing behind it - the hint + // "The password is divisible by 1 ;)" is a joke, not a clue. + // + // Upstream builds the password as a product of primes, but with no + // oracle there's nothing to narrow with: any integer of the right + // digit count is a candidate. So sweep the range ascending, which + // at least covers low-difficulty servers outright - below + // difficulty 5 the password is just a small base with no extra + // factors applied. + const lo = l === 1 ? 1 : Math.pow(10, Math.min(l, 6) - 1); + const hi = Math.pow(10, Math.min(l, 6)) - 1; + const out = []; + for (let n = lo; n <= hi; n++) out.push(String(n)); + return out; + } case 'BigMo%od': + // (password % n) % (((n - 1) % 32) + 1) for whatever n you send - + // only solvable by collecting residues and reconstructing by CRT, + // which needs the response to each probe. darknet.js handles it in + // authBigMod(); an empty guess is as good as anything from here. return ['']; case 'KingOfTheHill': { - const dig = d.replace(/[^0-9]/g, ''); - return [dig ? dig.slice(0, Math.min(l, 8)) : '0']; + // Each attempt reports an altitude on a Gaussian landscape; the + // password is the global maximum. Needs the readings, so this is + // authKingOfTheHill()'s job. Scraping digits out of `d` never made + // sense - this model sets no passwordHintData either. + const lo = l === 1 ? 0 : Math.pow(10, Math.min(l, 6) - 1); + const hi = Math.pow(10, Math.min(l, 6)) - 1; + const out = []; + for (let n = lo; n <= hi; n++) out.push(String(n)); + return out; } case 'PHP 5.4': { @@ -246,31 +456,18 @@ export function getCandidates(modelId, hint, len, data) { } case 'OpenWebAccessPoint': { - const packet = d || ''; - const out = []; - // Direct password markers (most reliable) - const direct = packet.match(/--(\w+)--/); - if (direct) out.push(direct[1]); - const neighbor = packet.match(/Connecting to\s+\S+:(\w+)/i); - if (neighbor) out.push(neighbor[1]); - const passcode = packet.match(/passcode:\s*["']?(\w+)["']?\s*\./i); - if (passcode) out.push(passcode[1]); - const pws = packet.match(/(?:password|pin|code|key|pass)\s*(?::|is|set to)\s*["']?(\w+)["']?/i); - if (pws) out.push(pws[1]); - // Extract any standalone multi-digit numbers (likely part of the code) - const multiDigits = packet.match(/\b(\d{4})\b/g); - if (multiDigits) out.push(...multiDigits); - // Bell pepper pattern (-- with special chars between) - const bell = packet.match(/(?:\d{2})(\d{4})(?:\d{2})/g); - if (bell) out.push(...bell); - // Any multi-digit groups from the entire text - const nums = packet.replace(/[^0-9]/g, ''); - if (nums && nums.length >= l) { - // Try chunks of length `l` from the numeric mass - for (let i = 0; i <= nums.length - l; i++) - out.push(nums.slice(i, i + l)); - } - return out.length ? [...new Set(out)] : ['admin']; + // The packet dump that leaks this password is the failure data of + // an authenticate() call - it is generated fresh per attempt by + // capturePackets() and never appears in getServerDetails(). This + // model sets no passwordHintData at all, so `d` here is always "". + // The previous version ran six regexes over that empty string and + // fell through to 'admin' every time. + // + // darknet.js routes this to authOpenWeb(), which reads the dump + // off the response and either regexes out `hostname:password` or + // intersects substrings across captures. Nothing useful is + // possible from static details, so keep it honest and cheap. + return ['admin', 'password', 'guest']; } case 'OrdoXenos': { diff --git a/darknet-looter.js b/darknet-looter.js index a08e58c6..3dd76f46 100644 --- a/darknet-looter.js +++ b/darknet-looter.js @@ -1,18 +1,137 @@ +export function autocomplete(data) { return ['--storm']; } + +// Bump this whenever any worm file changes. +// +// exec() with preventDuplicates returns 0 when the script is already +// running, so scp updates the file on disk while the live process +// keeps executing whatever it started with. Without this the worm never +// updates itself: a fix only reaches a host when its scripts happen to be +// killed by a mutation, which is why an old solver can sit there re-sending +// the same dead guess for hours after being replaced. +// +// Each instance re-reads its own source - already overwritten by scp - and +// exits when the version on disk no longer matches the one it started with. +// Restart comes from above: peers re-attempt their deploy every +// DEPLOY_RETRY_CYCLES ticks, and autopilot.js redeploys from home whenever no +// darknet.js is running on any reachable darknet host. +// +// ns.read and ns.getScriptName are both free, so this costs no RAM. +const WORM_VERSION = 2; + +function isStale(ns) { + try { + const m = ns.read(ns.getScriptName()).match(/^const WORM_VERSION = (\d+)/m); + return !!m && Number(m[1]) !== WORM_VERSION; + } catch { return false; } +} + + export async function main(ns) { ns.disableLog('ALL'); let dnet; try { dnet = ns.dnet; } catch { return; } if (!dnet || !dnet.isDarknetServer(ns.getHostname())) return; + // Off unless asked for explicitly. See handleStormSeed(). + const unleash = ns.args.includes('--storm'); + let reportedStorm = false; + while (true) { + if (isStale(ns)) { + ns.print(`newer darknet-looter.js on disk (was v${WORM_VERSION}) - exiting so it can start`); + return; + } try { const server = ns.getHostname(); + const files = ns.ls(server); // Open caches - for (const f of ns.ls(server).filter(f => f.endsWith('.cache'))) + for (const f of files.filter(f => f.endsWith('.cache'))) try { await dnet.openCache(f, true); } catch {} + // Report (or, opted in, fire) a storm seed sitting on this host + if (files.includes(STORM_SEED) && !reportedStorm) + reportedStorm = handleStormSeed(ns, dnet, server, unleash); + // Free the owner's blocked RAM + await drainRamBlock(ns, dnet, server); // Phishing loop try { await dnet.phishingAttack(); } catch {} } catch {} await ns.sleep(15000); } } + +const STORM_SEED = 'STORM_SEED.exe'; + +// STORM_SEED.exe is what the darknet UI means by "A mysterious executable has +// been found here...". It is not loot, and there is nothing to collect: it is +// a hazard that lands on a server when a RAM block is fully cleared (15% +// chance, at most one in existence, 30 minute cooldown). +// +// Running it calls launchWebstorm(), which deletes ~60% of the movable darknet +// servers outright, then restartServer()s every survivor - killing their +// scripts, clearing every authenticated PID, and wiping backdoors - before +// spawning fresh waves over the following ~12 seconds. That is the entire worm +// fleet and every session it holds. The in-game docs are blunt about it: +// "creates a webstorm that can cause catastrophic damage to the darknet. Run +// at your own risk." +// +// So the default is to report and leave it alone. Note that restartServer() +// never touches server.password, so /data/dnet-passwords.txt still gets you +// back into whatever survives a storm - it is the sessions, scripts, and +// backdoors that have to be rebuilt. +// +// The one reason to want a storm is the reshuffle: it re-rolls the topology +// when the net has settled into a shape you can't get any deeper into. To fire +// one deliberately, run this script on the host holding the file with --storm. +// unleashStormSeed() takes no arguments and always acts on the server it is +// running from, so it has to be that host. +function handleStormSeed(ns, dnet, server, unleash) { + if (!unleash) { + ns.tprint(`WARN: ${STORM_SEED} found on ${server}. Leaving it alone - running it wipes the darknet. ` + + `To fire it anyway: run darknet-looter.js --storm on ${server}.`); + return true; + } + ns.tprint(`WARN: unleashing ${STORM_SEED} on ${server} - the darknet is about to be rebuilt.`); + try { + const r = dnet.unleashStormSeed(); + ns.print(`unleashStormSeed on ${server}: ${r?.message || 'no response'}`); + } catch (e) { + ns.print(`unleashStormSeed on ${server} failed: ${e?.message || e}`); + } + return true; +} + +// Calls per cycle. Each call blocks for max(8000 * 500/(500+cha), 200) ms, so +// this trades off against how often caches and phishing get a turn. Charisma +// (which phishing builds) shortens each call, so the drain speeds up over time. +const RAM_BLOCK_CALLS_PER_CYCLE = 10; + +// Frees RAM that the server's owner has blocked off. +// +// This used to live in darknet.js as a single fire-and-forget call per cycle, +// which is why it looked like it never ran: one call only chips off +// getRamBlockRemoved(), roughly +// 0.02 * 2 * 0.92^(difficulty+1) * threads * (1 + charisma/100) GB +// which is on the order of 0.04 GB at 1 thread. Servers over 64GB can have +// their entire maxRam blocked, so a call-per-cycle never visibly moves the +// number. It has to loop, and it's worth looping: fully clearing a block drops +// a .cache file on the server and rolls for a clue and a stormSeed program, +// on top of freeing the RAM itself. +// +// Note this always targets the host the script is running on, and a server is +// always "directly connected" to itself, so a drain in progress can't be +// broken by a topology mutation the way authenticate() can. +async function drainRamBlock(ns, dnet, server) { + for (let i = 0; i < RAM_BLOCK_CALLS_PER_CYCLE; i++) { + let r; + try { r = await dnet.memoryReallocation(server); } catch { return; } + // First non-success ends the drain. Code 454 (NoBlockRAM) means the + // block is clear - the common case, and the cheap no-op on later + // cycles. Anything else means the server went offline or unauthorized. + if (!r || !r.success) { + if (i > 0) ns.print(`ram block on ${server}: ${r?.message || 'stopped'} after ${i} calls`); + return; + } + if (i === 0) ns.print(`draining ram block on ${server}: ${r.message}`); + } + ns.print(`ram block on ${server}: still blocked, continuing next cycle`); +} diff --git a/darknet-virus.js b/darknet-virus.js index abc75361..0482ce2c 100644 --- a/darknet-virus.js +++ b/darknet-virus.js @@ -2,6 +2,32 @@ import { getCandidates } from './crackers.js' const DB = '/data/dnet-passwords.txt'; +// Bump this whenever any worm file changes. +// +// exec() with preventDuplicates returns 0 when the script is already +// running, so scp updates the file on disk while the live process +// keeps executing whatever it started with. Without this the worm never +// updates itself: a fix only reaches a host when its scripts happen to be +// killed by a mutation, which is why an old solver can sit there re-sending +// the same dead guess for hours after being replaced. +// +// Each instance re-reads its own source - already overwritten by scp - and +// exits when the version on disk no longer matches the one it started with. +// Restart comes from above: peers re-attempt their deploy every +// DEPLOY_RETRY_CYCLES ticks, and autopilot.js redeploys from home whenever no +// darknet.js is running on any reachable darknet host. +// +// ns.read and ns.getScriptName are both free, so this costs no RAM. +const WORM_VERSION = 2; + +function isStale(ns) { + try { + const m = ns.read(ns.getScriptName()).match(/^const WORM_VERSION = (\d+)/m); + return !!m && Number(m[1]) !== WORM_VERSION; + } catch { return false; } +} + + export async function main(ns) { ns.disableLog('ALL'); let dnet; @@ -12,6 +38,10 @@ export async function main(ns) { let lastHits = 0; while (true) { + if (isStale(ns)) { + ns.print(`newer darknet-virus.js on disk (was v${WORM_VERSION}) - exiting so it can start`); + return; + } try { const server = ns.getHostname(); const neighbors = dnet.probe(false).filter(h => dnet.isDarknetServer(h)); diff --git a/darknet.js b/darknet.js index b3c3eeae..007c959f 100644 --- a/darknet.js +++ b/darknet.js @@ -1,7 +1,47 @@ -import { getCandidates } from './crackers.js' +import { getCandidates, romanHint, permute } from './crackers.js' export function autocomplete(data) { return ['--tail']; } +// Bump this whenever any worm file changes. +// +// exec() with preventDuplicates returns 0 when the script is already +// running, so scp updates the file on disk while the live process +// keeps executing whatever it started with. Without this the worm never +// updates itself: a fix only reaches a host when its scripts happen to be +// killed by a mutation, which is why an old solver can sit there re-sending +// the same dead guess for hours after being replaced. +// +// Each instance re-reads its own source - already overwritten by scp - and +// exits when the version on disk no longer matches the one it started with. +// Restart comes from above: peers re-attempt their deploy every +// DEPLOY_RETRY_CYCLES ticks, and autopilot.js redeploys from home whenever no +// darknet.js is running on any reachable darknet host. +// +// ns.read and ns.getScriptName are both free, so this costs no RAM. +const WORM_VERSION = 2; + +function isStale(ns) { + try { + const m = ns.read(ns.getScriptName()).match(/^const WORM_VERSION = (\d+)/m); + return !!m && Number(m[1]) !== WORM_VERSION; + } catch { return false; } +} + +// How often an already-deployed host gets another exec attempt. Cheap (exec +// returns 0 while an instance is live) and it's what restarts a peer that +// just exited on a version bump. +const DEPLOY_RETRY_CYCLES = 20; + +const INTERACTIVE_MODELS = new Set([ + 'AccountsManager_4.2', 'DeepGreen', 'NIL', 'OpenWebAccessPoint', + 'Pr0verFl0', 'Factori-Os', 'BigMo%od', 'KingOfTheHill', 'RateMyPix.Auth', + 'BellaCuore', '2G_cellular', 'PHP 5.4', +]); + +// Higher/lower guessing games. Same search, different vocabulary and different +// source for the range - see hiLoDirection() and hiLoPool(). +const HILO_MODELS = { 'AccountsManager_4.2': 'AccountsManager', 'BellaCuore': 'BellaCuore' }; + export async function main(ns) { let dnet; try { dnet = ns.dnet; } catch { return; } @@ -12,6 +52,10 @@ export async function main(ns) { let cache = loadCache(ns); while (true) { + if (isStale(ns)) { + ns.print(`newer darknet.js on disk (was v${WORM_VERSION}) - exiting so it can start`); + return; + } try { const nearby = dnet.probe(); @@ -24,7 +68,7 @@ export async function main(ns) { const details = dnet.getServerDetails(hostname); if (!details.isOnline) { if (!cache[hostname]._loggedOffline) { ns.print(`${hostname}: offline`); cache[hostname]._loggedOffline = true; } continue; } if (!details.isConnectedToCurrentServer) { if (!cache[hostname]._loggedNoConn) { ns.print(`${hostname}: not connected (${details.modelId || '?'})`); cache[hostname]._loggedNoConn = true; } continue; } - if (details.hasSession) { cache[hostname].session = true; continue; } + if (details.hasSession) cache[hostname].session = true; // Try cached password via connectToSession (sync, any distance) if (cache[hostname].password) { @@ -34,31 +78,38 @@ export async function main(ns) { ns.print(`connect ${hostname} (cached)`); } } - // Authenticate if not yet connected if (!cache[hostname].session) { - // AccountsManager_4.2 or DeepGreen are interactive — handle inline - if (details.modelId === 'AccountsManager_4.2' || details.modelId === 'DeepGreen' - || details.modelId === 'NIL' || details.modelId === 'OpenWebAccessPoint' - || details.modelId === 'Pr0verFl0' || details.modelId === 'Factori-Os' - || details.modelId === 'BigMo%od' || details.modelId === 'KingOfTheHill') { + // These models can't be solved from a static candidate list - + // each needs the response to one attempt before it can pick + // the next. handled inline by authInteractive(). + if (INTERACTIVE_MODELS.has(details.modelId)) { const authed = await authInteractive(ns, dnet, hostname, cache[hostname], details); if (authed && !cache[hostname].deployed) { await deployWorm(ns, hostname, cache[hostname], details); } } else { - const candidates = getCandidates( - details.modelId, details.passwordHint || '', - details.passwordLength || 1, details.data || '' - ); - const attempts = Math.min(candidates.length, 60); - if (!cache[hostname]._loggedModel) { - ns.print(`${hostname}: ${details.modelId} (${details.passwordHint || 'no hint'}, len ${details.passwordLength}) trying ${attempts} candidates`); - cache[hostname]._loggedModel = true; - } - + const candidates = getCandidates( + details.modelId, details.passwordHint || '', + details.passwordLength || 1, details.data || '' + ); + // Prefer candidates that already match the password + // length; only fall back to the raw list if none do. + // The previous code truncated every candidate with + // .slice(0, passwordLength), which silently corrupted + // any candidate longer than the password instead of + // skipping it - a decoded "168" became a guess of "16", + // resent every cycle forever. + const plen = details.passwordLength || 0; + const fitted = plen ? candidates.filter(c => c.length === plen) : candidates; + const usable = fitted.length ? fitted : candidates; + const attempts = Math.min(usable.length, 60); + if (!cache[hostname]._loggedModel) { + ns.print(`${hostname}: ${details.modelId} (${details.passwordHint || 'no hint'}, len ${details.passwordLength}) trying ${attempts} candidates`); + cache[hostname]._loggedModel = true; + } for (let i = 0; i < attempts; i++) { - const pw = candidates[i].slice(0, Math.min(details.passwordLength || 50, 50)); + const pw = usable[i]; const r = await dnet.authenticate(hostname, pw); if (r.success) { cache[hostname].password = pw; @@ -78,18 +129,27 @@ export async function main(ns) { cache[hostname]._loggedFail = true; } } // end else (non-interactive auth) + } + // Deploy once we have a session, and keep re-attempting on a + // slow tick. exec returns 0 while an instance is already live, + // so the retry costs nothing and is what restarts a host whose + // worm just exited on a version bump. + if (cache[hostname].session) { + const e = cache[hostname]; + e._deployTick = (e._deployTick || 0) + 1; + if (!e.deployed || e._deployTick % DEPLOY_RETRY_CYCLES === 0) { + await deployWorm(ns, hostname, e, details); + } + } } - // Server-local ops - if (dnet.isDarknetServer(ns.getHostname())) { - for (const f of ns.ls(ns.getHostname()).filter(f => f.endsWith('.cache'))) - try { const r = await dnet.openCache(f, true); } catch {} - try { await dnet.phishingAttack(); } catch {} - try { - const details = dnet.getServerDetails(); - if (details.blockedRam > 0) await dnet.memoryReallocation(ns.getHostname()); - } catch {} - } + // Server-local ops (cache opening, phishing, RAM-block draining) + // live in darknet-looter.js, which this script execs onto every + // host it cracks. They used to be duplicated here as well, which + // bought nothing - both scripts run on the same server - and cost + // this one openCache (2GB) + phishingAttack (2GB) + + // memoryReallocation (1GB) + ls (0.2GB) of a budget that has to + // fit alongside the looter and the virus on a 16GB server. saveCache(ns, cache); } catch (e) { @@ -102,12 +162,10 @@ export async function main(ns) { function saveCache(ns, cache) { const flat = {}; for (const [h, d] of Object.entries(cache)) { - const entry = {}; - if (d.password) entry.pw = d.password; - if (d._guess != null) entry.g = d._guess; - if (d._binaryLo != null) entry.lo = d._binaryLo; - if (d._binaryHi != null) entry.hi = d._binaryHi; - if (Object.keys(entry).length) flat[h] = entry; + // Only the solved password is worth persisting. Interactive solver + // state is in-memory only: it's large (candidate pools), and it's + // invalidated by a topology mutation anyway. + if (d.password) flat[h] = { pw: d.password }; } try { ns.write('/data/dnet-passwords.txt', JSON.stringify(flat), 'w'); } catch {} } @@ -119,430 +177,1254 @@ function loadCache(ns) { for (const [h, d] of Object.entries(raw)) { cache[h] = {}; if (d.pw) cache[h].password = d.pw; - if (d.g != null) cache[h]._guess = d.g; - if (d.lo != null) cache[h]._binaryLo = d.lo; - if (d.hi != null) cache[h]._binaryHi = d.hi; } return cache; } catch { return {}; } } async function authInteractive(ns, dnet, hostname, entry, details) { - const h = details.passwordHint || ''; const l = details.passwordLength || 1; const model = details.modelId; - // DeepGreen: Mastermind with numeric match counts. NIL: Mastermind with yes/yesn't positional. - if (model === 'DeepGreen') { - return await authMastermind(ns, dnet, hostname, entry, l); - } if (model === 'NIL') { - return await authNIL(ns, dnet, hostname, entry, l); + return await authNIL(ns, dnet, hostname, entry, details); } if (model === 'OpenWebAccessPoint') { - return await authOpenWeb(ns, dnet, hostname, entry, l); + return await authOpenWeb(ns, dnet, hostname, entry, details); } // Pr0verFl0: classic buffer overflow. Send the same string twice (length = 2*l). // It overflows the password buffer into the expected-value field, making them match. + // (Was 'aaaaa'.slice(0, l) twice, which silently capped the payload at 10 + // chars and stopped overflowing correctly for any password longer than 5.) if (model === 'Pr0verFl0') { - const payload = 'aaaaa'.slice(0, l) + 'aaaaa'.slice(0, l); + const payload = 'a'.repeat(l * 2); const r = await dnet.authenticate(hostname, payload); if (r.success) { entry.password = payload; entry.session = true; ns.print(`auth ${hostname} SUCCESS: bo`); return true; } return false; } if (model === 'Factori-Os') { - return await authFactoriOs(ns, dnet, hostname, entry, l); + return await authFactoriOs(ns, dnet, hostname, entry, details); } if (model === 'BigMo%od') { - return await authBigMod(ns, dnet, hostname, entry, l); + return await authBigMod(ns, dnet, hostname, entry, details); } if (model === 'KingOfTheHill') { - return await authKingOfTheHill(ns, dnet, hostname, entry, l); + return await authKingOfTheHill(ns, dnet, hostname, entry, details); } - // AccountsManager_4.2: higher/lower guessing game - let lo = entry._binaryLo != null ? entry._binaryLo : 0; - let hi = entry._binaryHi != null ? entry._binaryHi : 100; - const range = h.match(/between\s*(\d+)\s*and\s*(\d+)/i); - if (range) { lo = parseInt(range[1], 10); hi = parseInt(range[2], 10); } - entry._binaryLo = lo; - entry._binaryHi = hi; + if (model === 'PHP 5.4') { + return await authSortedEcho(ns, dnet, hostname, entry, details); + } + if (model === '2G_cellular') { + return await authTimingAttack(ns, dnet, hostname, entry, details); + } + if (HILO_MODELS[model]) { + return await authHiLo(ns, dnet, hostname, entry, details, HILO_MODELS[model]); + } + if (BULLS_MODELS[model]) { + return await authBullsOracle(ns, dnet, hostname, entry, details, BULLS_MODELS[model]); + } + return false; +} - let guess = entry._guess != null ? entry._guess : Math.floor((lo + hi) / 2); +// Reads the puzzle feedback for a password attempt we just made. +// +// This is THE thing every interactive solver in this file got wrong. +// ns.dnet.authenticate() resolves to a bare {success, code, message} - it +// carries a `data` field ONLY for labyrinth servers (NetscriptFunctions/ +// Darknet.ts branches on isLabyrinthServer before building the return value). +// For every puzzle model, `r.data` is undefined, so any solver keying off it +// is reading nothing forever. +// +// The feedback is real, it just travels a different route: checkPassword() +// builds a PasswordResponse and getAuthResult() hands it to +// logPasswordAttempt(), which pushes it onto the server's packet log. The only +// way a script sees it is to read that log back with heartbleed(). The in-game +// docs say so outright: "Use await ns.dnet.heartbleed(hostname) to check that +// server's logs and get clues after you attempt a password." +// +// Auth entries arrive as JSON strings, e.g. +// {"code":401,"message":"The password is a number between 0 and 100", +// "data":"Lower","passwordAttempted":"50"} +// Logs are unshifted newest-first, but noise lines get interleaved on both +// logPasswordAttempt() and getServerLogs(), so match on passwordAttempted +// rather than trusting index 0. peek:true so we don't splice the log out from +// under the other worm scripts. +// Capture window. Our auth entry lands at index 0, but +// populateServerLogsWithNoise() runs again inside getServerLogs() and unshifts +// floor(elapsed / logTrafficInterval) noise lines ahead of it - and `elapsed` +// includes heartbleed's own network delay, which is 1.5x an authenticate(). +// On a chatty server that easily buries the entry past a window of 5, which +// reads as "no feedback" and silently degrades every solver to a blind sweep. +// Capturing more costs nothing: getServerLogs() just slices, and peek:true +// doesn't consume. 200 is MAX_LOG_LINES upstream, i.e. everything retained, so +// the window can never be what loses the entry. Scanning newest-first means a +// stale entry for the same attempt can't win over the fresh one either. +const FEEDBACK_LOG_LINES = 200; - for (let i = 0; i < 20; i++) { - const pw = String(guess).padStart(l, '0'); - try { - const r = await dnet.authenticate(hostname, pw); - if (r && r.success) { - entry.password = pw; - entry.session = true; - delete entry._guess; delete entry._binaryLo; delete entry._binaryHi; - ns.print(`auth ${hostname} SUCCESS: ${pw}`); - return true; - } - const fb = String(r?.data || ''); - if (fb.includes('Lower') || fb.includes('lower')) { hi = guess - 1; } - else if (fb.includes('Higher') || fb.includes('higher')) { lo = guess + 1; } - // If no feedback, keep lo/hi as-is and advance guess - if (lo > hi) break; - } catch (e) { ns.print(`authInteractive error: ${e}`); } - guess = Math.floor((lo + hi) / 2); - entry._guess = guess; - entry._binaryLo = lo; - entry._binaryHi = hi; - await ns.sleep(50); +// Returns the parsed PasswordResponse for our attempt, or null. Callers that +// only want the hint payload use readAuthFeedback(); this exists for the ones +// that also want `message`, which sometimes restates the answer in prose. +async function readAuthEntry(ns, dnet, hostname, attempted, lines = FEEDBACK_LOG_LINES) { + let hb; + try { + hb = await dnet.heartbleed(hostname, { peek: true, logsToCapture: lines }); + } catch (e) { + return null; + } + // Charisma below the server's requirement, or a dropped connection, both + // land here - the caller degrades to a blind sweep rather than stalling. + if (!hb || !hb.success || !Array.isArray(hb.logs)) return null; + for (const line of hb.logs) { + if (typeof line !== 'string' || line.charAt(0) !== '{') continue; + let o; + try { o = JSON.parse(line); } catch { continue; } + if (o && o.passwordAttempted === attempted && o.data != null) return o; + } + return null; +} + +async function readAuthFeedback(ns, dnet, hostname, attempted, lines = FEEDBACK_LOG_LINES) { + const o = await readAuthEntry(ns, dnet, hostname, attempted, lines); + return o ? String(o.data) : null; +} + +// The solvers treat unreadable feedback as "stop, resume next cycle" rather +// than as a score, so a persistent heartbleed failure looks like silence from +// outside. Say so once per host, otherwise it just sits there. +function noFeedback(ns, st, hostname, label) { + if (!st.warned) { + st.warned = true; + ns.print(`${hostname}: ${label} could not read heartbleed feedback (charisma gate?), stalled`); } return false; } -// DeepGreen Mastermind solver. Reads match counts from r.data ("1,0" format). -async function authMastermind(ns, dnet, hostname, entry, l) { - // Start fresh if no state or previous guess didn't match expected feedback - if (!entry._mmCandidates || !entry._mmGuess) { - // Generate all length-L numeric combinations - const digits = Math.min(l, 4); - const max = Math.min(Math.pow(10, digits), 10000); - entry._mmCandidates = []; - for (let i = 0; i < max; i++) - entry._mmCandidates.push(String(i).padStart(digits, '0')); - entry._mmGuess = 0; - } - - for (let i = entry._mmGuess; i < entry._mmCandidates.length; i++) { - const pw = entry._mmCandidates[i]; - entry._mmGuess = i + 1; - const r = await dnet.authenticate(hostname, pw); - if (r.success) { +// Higher/lower number guessing games: AccountsManager_4.2 and BellaCuore. +// +// Both compare the same way, they just say it differently: +// GuessNumber: attempt > password ? "Lower" : "Higher" +// RomanNumeral: attempt > password ? "ALTUS NIMIS" : "PARUM BREVIS" +// so in both cases the first form means the answer is below the guess. +// +// The old AccountsManager code parsed "Lower"/"Higher" correctly - it just read +// them off r.data, which is always undefined, so the interval never moved and +// every iteration recomputed the same midpoint. That was the "always 50". +// BellaCuore never got a solver at all; it went through the static path, which +// mis-decoded its range and then had the guess truncated to the password +// length, which is where the endlessly repeated "16" came from. +// +// If heartbleed can't be read (charisma gate, server drifted out of direct +// connection), each guess still gets consumed, so the search degrades to a +// blind sweep of the range instead of spinning. +async function authHiLo(ns, dnet, hostname, entry, details, label) { + if (!entry._amPool || !entry._amPool.length) { + entry._amPool = hiLoPool(details); + ns.print(`${hostname}: ${label} searching ${entry._amPool.length} value(s) in [${entry._amPool[0]}, ${entry._amPool[entry._amPool.length - 1]}]`); + } + + const rounds = Math.min(12, entry._amPool.length); + for (let i = 0; i < rounds; i++) { + if (!entry._amPool.length) break; + const guess = entry._amPool[Math.floor(entry._amPool.length / 2)]; + // The generator stores the password as String(n) with no padding, so + // send it unpadded - "05" is never the answer on a length-2 server. + const pw = String(guess); + let r; + try { + r = await dnet.authenticate(hostname, pw); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return false; + } + if (r && r.success) { entry.password = pw; entry.session = true; - delete entry._mmCandidates; delete entry._mmGuess; + delete entry._amPool; delete entry._amBlind; ns.print(`auth ${hostname} SUCCESS: ${pw}`); return true; } - const fb = (r.data || '0,0').split(',').map(Number); - const [exact, wrongPos] = [fb[0] || 0, fb[1] || 0]; - if (exact + wrongPos > 0) { - // This guess had matches — filter candidates using Mastermind logic - entry._mmCandidates = entry._mmCandidates.filter(c => - getMastermindScore(pw, c)[0] === exact && - getMastermindScore(pw, c)[1] === wrongPos - ); - entry._mmGuess = 0; - ns.print(`${hostname}: ${pw} -> (${exact},${wrongPos}) narrowed to ${entry._mmCandidates.length}`); - return false; + + const dir = hiLoDirection(await readAuthFeedback(ns, dnet, hostname, pw)); + if (dir < 0) entry._amPool = entry._amPool.filter(n => n < guess); + else if (dir > 0) entry._amPool = entry._amPool.filter(n => n > guess); + else { + // Consume the guess regardless - this is what stops a spin. + entry._amPool = entry._amPool.filter(n => n !== guess); + if (!entry._amBlind) { + entry._amBlind = true; + ns.print(`${hostname}: ${label} got no heartbleed feedback (cha ${details.requiredCharismaSkill ?? '?'} req), sweeping ${entry._amPool.length + 1}`); + } } await ns.sleep(50); } - delete entry._mmCandidates; delete entry._mmGuess; + if (!entry._amPool.length) { + ns.print(`auth ${hostname}: ${label} range exhausted, rebuilding`); + delete entry._amPool; delete entry._amBlind; + } return false; } -// NIL Mastermind: positional yes/yesn't feedback. Each position independently -// confirms whether the guessed digit matches the password at that position. -// Once we know a position is correct, lock it. Keep iterating unknown positions. -async function authNIL(ns, dnet, hostname, entry, l) { - if (!entry._nilLocked) { - entry._nilLocked = Array(l).fill(null); // null = unknown, '0'-'9' = locked - entry._nilDigit = 0; // current digit being tested - entry._nilPos = 0; // current position for this digit +// RateMyPix.Auth (upstream SpiceLevel) - "bulls-only" Mastermind. +// +// Feedback is one chilli per character that is in exactly the right position, +// then the length: "🌶️🌶️/5" means 2 of 5 positions correct, "0/5" means none. +// Crucially the chillis are concatenated with no separator, so the response +// carries a COUNT and nothing about which positions matched. +// +// The old crackers.js branch counted chillis in `details.data` and returned +// that count as the password. Two things wrong with it: the chilli count is +// per-attempt feedback, not a property of the server, and SpiceLevel sets no +// passwordHintData at all - its only static hint is the literal string +// "!!🌶️!!" - so `data` is always "" and it always guessed "0".repeat(len). +// +// Solved in three phases. With a null character - one the password does not +// contain - a probe of nullChar.repeat(len) with a subset S of positions set +// to c scores exactly |{i in S : password[i] === c}|, because the null +// character contributes nothing outside S. That turns the oracle into a +// counting query over any position set, which binary splitting resolves in +// about len*log2(len) probes instead of len*|alphabet|. +// +// 1. Composition: probe c.repeat(len) for each c in the alphabet. The score +// is how many positions hold c. Stop as soon as the counts sum to len; +// any character not reached has a count of zero. This also picks up the +// null character for free, and outright wins if the password is all one +// character. +// 2. Placement: for each character present (skipping the most frequent one - +// its positions are whatever is left over), binary-split the unresolved +// positions to find where its instances sit. +// 3. Assemble and authenticate. +// +// The alphabet comes from passwordFormat, which upstream derives from the +// actual password, so "numeric" is a sound restriction to 10 characters rather +// than a guess. getSpiceLevelConfig only allows letters when difficulty > 8. +const BULLS_DIGITS = '0123456789'; +const BULLS_LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +// Probes per cycle. Each is an authenticate() plus a heartbleed(), so this +// caps how long one host can monopolise the worm's loop; state persists in +// `entry`, so the solve resumes on the next pass. +const BULLS_PROBES_PER_CYCLE = 25; + +// Both RateMyPix.Auth and DeepGreen expose the same underlying oracle, so they +// share this solver; only the scoring differs (see BULLS_MODELS). +// RateMyPix.Auth "🌶️🌶️/5" -> 2 characters in exactly the right place +// DeepGreen "2,1" -> 2 exact, 1 present but misplaced +// DeepGreen's misplaced count is extra information this doesn't need: a probe +// of c.repeat(len) always scores 0 misplaced (every non-exact guess character +// is c, and c is by construction absent from the unmatched remainder), so the +// exact count alone is the same composition oracle. The null-character mask +// then makes it a subset-counting oracle, and binary splitting does the rest. +async function authBullsOracle(ns, dnet, hostname, entry, details, cfg) { + const len = details.passwordLength || 1; + if (!entry._bo || entry._bo.len !== len) { + entry._bo = newBullsState(details, len); + ns.print(`${hostname}: ${cfg.label} len ${len} ${details.passwordFormat || '?'}, ${entry._bo.alpha.length}-char alphabet`); } + const st = entry._bo; - const locked = entry._nilLocked; - const digitsCovered = locked.every(d => d != null); - if (digitsCovered) { - // All positions locked — construct the password - const pw = locked.join(''); - const r = await dnet.authenticate(hostname, pw); - if (r.success) { - entry.password = pw; - entry.session = true; - delete entry._nilLocked; delete entry._nilDigit; delete entry._nilPos; - ns.print(`auth ${hostname} SUCCESS: ${pw}`); - return true; + for (let budget = BULLS_PROBES_PER_CYCLE; budget > 0; budget--) { + // Phase 1: character composition. + if (!st.placing) { + if (st.found < len && st.idx < st.alpha.length) { + const c = st.alpha[st.idx]; + const p = await bullsProbe(ns, dnet, hostname, entry, c.repeat(len), cfg); + if (p.solved) return true; + // Only advance once the score is actually in hand. Advancing + // first would drop this character's count on an unreadable + // response, leaving a composition that never sums to len and + // deducing a password from incomplete counts. + if (p.count == null) return noFeedback(ns, st, hostname, cfg.label); + st.idx++; + if (p.count > 0) { st.counts[c] = p.count; st.found += p.count; } + else if (st.nullChar === null) st.nullChar = c; + continue; + } + if (!beginBullsPlacement(ns, st, hostname, len, cfg)) { + delete entry._bo; + return false; + } + } + + // Phase 2: locate each character's positions by binary splitting. + if (st.tasks.length) { + const t = st.tasks[0]; + if (t.k === 0) { st.tasks.shift(); continue; } + if (t.k === t.pos.length) { + for (const i of t.pos) st.known[i] = t.c; + st.tasks.shift(); + continue; + } + const half = Math.floor(t.pos.length / 2); + const a = t.pos.slice(0, half), b = t.pos.slice(half); + const p = await bullsProbe(ns, dnet, hostname, entry, bullsMask(st, len, a, t.c), cfg); + if (p.solved) return true; + if (p.count == null) return noFeedback(ns, st, hostname, cfg.label); + st.tasks.shift(); + st.tasks.push({ c: t.c, k: p.count, pos: a }, { c: t.c, k: t.k - p.count, pos: b }); + continue; } - // If final guess fails, reset and try different approach - delete entry._nilLocked; delete entry._nilDigit; delete entry._nilPos; + if (st.pending.length) { + const c = st.pending.shift(); + st.tasks = [{ c, k: st.counts[c], pos: bullsUnknown(st, len) }]; + continue; + } + + // Phase 3: everything still unresolved belongs to the skipped character. + for (let i = 0; i < len; i++) if (st.known[i] === null) st.known[i] = st.fill; + const pw = st.known.join(''); + const p = await bullsProbe(ns, dnet, hostname, entry, pw, cfg); + if (p.solved) return true; + // Only reachable if a probe was scored against a password that changed + // mid-solve. Start over rather than loop on a stale deduction. + ns.print(`auth ${hostname}: ${cfg.label} deduced ${pw} but it was rejected, restarting`); + delete entry._bo; + return false; + } + return false; +} + +// passwordFormat is derived upstream from the actual password, so restricting +// to the reported class is sound rather than a guess. +function bullsAlphabet(details) { + const fmt = details.passwordFormat; + return fmt === 'numeric' ? BULLS_DIGITS + : fmt === 'alphabetic' ? BULLS_LETTERS + : BULLS_DIGITS + BULLS_LETTERS; +} + +function newBullsState(details, len) { + const alpha = bullsAlphabet(details); + return { + len, alpha, + idx: 0, // next alphabet index to probe + counts: {}, // character -> how many positions hold it + found: 0, // sum of counts so far + nullChar: null, // a character known absent from the password + known: new Array(len).fill(null), + placing: false, + pending: [], // characters still to locate + tasks: [], // {c, k, pos} - k instances of c somewhere in pos + fill: null, // character whose positions are inferred, not probed + }; +} + +// Transitions from composition to placement. Returns false if the scan came up +// empty, which means the password uses characters outside the alphabet we +// derived from passwordFormat. +function beginBullsPlacement(ns, st, hostname, len, cfg) { + const present = Object.keys(st.counts); + if (!present.length) { + ns.print(`auth ${hostname}: ${cfg.label} found no characters in ${st.alpha.length}-char alphabet, giving up`); return false; } + if (st.nullChar === null) { + // Every probed character scored, so take one we never reached; a + // password shorter than the alphabet always leaves one. + st.nullChar = st.alpha.split('').find(c => !(c in st.counts)); + if (st.nullChar == null) { + ns.print(`auth ${hostname}: ${cfg.label} has no unused character to mask with, giving up`); + return false; + } + } + // Skip the most frequent character - the positions no other character + // claims are its by elimination, which saves its whole binary split. + present.sort((a, b) => st.counts[b] - st.counts[a]); + st.fill = present.shift(); + st.pending = present; + st.placing = true; + return true; +} - // Build a guess: locked positions use the known digit, others use test digit - const testDigit = String(entry._nilDigit); - let guess = []; - for (let i = 0; i < l; i++) { - guess.push(locked[i] != null ? locked[i] : testDigit); +// nullChar everywhere except `positions`, which get `c`. Positions outside the +// set score nothing because nullChar is absent from the password, so the +// result is purely the count of `c` inside the set. +function bullsMask(st, len, positions, c) { + const out = new Array(len).fill(st.nullChar); + for (const i of positions) out[i] = c; + return out.join(''); +} + +function bullsUnknown(st, len) { + const out = []; + for (let i = 0; i < len; i++) if (st.known[i] === null) out.push(i); + return out; +} + +// One attempt plus the heartbleed read of its score. +// Returns {solved} on success, {count} otherwise - count is null when the +// feedback couldn't be read, which tells the caller to stop for this cycle +// rather than treat "unknown" as "zero" and deduce garbage. +async function bullsProbe(ns, dnet, hostname, entry, pw, cfg) { + let r; + try { + r = await dnet.authenticate(hostname, pw); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return { solved: false, count: null }; } - const pw = guess.join(''); - const r = await dnet.authenticate(hostname, pw); - if (r.success) { + if (r && r.success) { entry.password = pw; entry.session = true; - delete entry._nilLocked; delete entry._nilDigit; delete entry._nilPos; + delete entry._bo; ns.print(`auth ${hostname} SUCCESS: ${pw}`); - return true; + return { solved: true, count: null }; } + return { solved: false, count: cfg.parse(await readAuthFeedback(ns, dnet, hostname, pw)) }; +} + +// "🌶️🌶️/5" -> 2, "0/5" -> 0. Iterating yields code points, so the variation +// selector trailing each chilli doesn't inflate the count. +const BULLS_MODELS = { + 'RateMyPix.Auth': { label: 'RateMyPix', parse: spiceCount }, + 'DeepGreen': { label: 'DeepGreen', parse: mastermindExact }, +}; - // Parse positional feedback: comma-separated yes/yesn't - const fb = (r.data || '').split(','); - for (let i = 0; i < Math.min(l, fb.length); i++) { - if (fb[i].trim() === 'yes' && locked[i] == null) { - locked[i] = testDigit; +// "2,1" -> 2 exact matches. Upstream builds this as `${exact},${misplaced}`. +function mastermindExact(fb) { + if (typeof fb !== 'string') return null; + const n = parseInt(fb.split(',')[0], 10); + return Number.isFinite(n) ? n : null; +} + +function spiceCount(fb) { + if (typeof fb !== 'string') return null; + const head = fb.split('/')[0]; + if (head === '0') return 0; + let n = 0; + for (const ch of head) if (ch === '🌶') n++; + return n; +} + +// -1 if the response says the password is below the guess, +1 if above, 0 if +// unreadable. Both vocabularies are recognised; the substring fallbacks cover +// the wording gaining decoration. +function hiLoDirection(fb) { + if (typeof fb !== 'string') return 0; + const t = fb.trim().toUpperCase(); + if (t === 'LOWER' || t === 'ALTUS NIMIS') return -1; + if (t === 'HIGHER' || t === 'PARUM BREVIS') return 1; + if (t.includes('ALTUS') || t.includes('LOWER')) return -1; + if (t.includes('BREVIS') || t.includes('HIGHER')) return 1; + return 0; +} + +// Every value the password could take. +// +// The digit count always bounds it: getGuessNumberConfig writes the password +// as String(Math.floor(...)) and getRomanNumeralConfig as `${password}`, so +// neither is zero-padded and a length-L password is a plain L-digit decimal - +// 0-9 for L=1, 10-99 for L=2. That alone rules out the zero-padded low end. +// +// BellaCuore narrows it further at difficulty 8 and up, where passwordHintData +// carries an explicit "encodedMin,encodedMax" range. Intersecting the two is +// what turns "between nulla and LXVIII" at length 2 into 10..68 - 59 values, +// six probes of binary search. +function hiLoPool(details) { + const l = Math.min(Math.max(details.passwordLength || 1, 1), 6); + let lo = l === 1 ? 0 : Math.pow(10, l - 1); + let hi = Math.pow(10, l) - 1; + + if (details.modelId === 'BellaCuore') { + const parsed = romanHint(details.data, details.passwordHint); + if (parsed && parsed.exact != null) { + // Below difficulty 8 the hint IS the password, roman-encoded. + return [parsed.exact]; + } + if (parsed) { + lo = Math.max(lo, parsed.lo); + hi = Math.min(hi, parsed.hi); } } + if (lo > hi) { lo = l === 1 ? 0 : Math.pow(10, l - 1); hi = Math.pow(10, l) - 1; } - // Move to next digit - entry._nilDigit++; - if (entry._nilDigit > 9) { - // Shouldn't happen if feedback was correct, but reset - delete entry._nilLocked; delete entry._nilDigit; delete entry._nilPos; + const pool = []; + for (let n = lo; n <= hi; n++) pool.push(n); + return pool; +} + +// PHP 5.4 (upstream SortedEchoVuln). The hint hands over the password's digits +// already sorted, so the only unknown is the arrangement - and the failure data +// carries the root-mean-square deviation between the attempt and the password: +// +// squaredError += (Number(att[i]) - Number(password[i])) ** 2 +// `${passwordHintData}; RMS Deviation:${Math.sqrt(se / len).toFixed(3)}` +// +// That is enough to read off each digit directly. Let E(a) = len * rmsd(a)^2 be +// the total squared error. Take a baseline B and change exactly one position i +// from b to d; only that term moves, so with x the true digit at i: +// +// E' - E = (d - x)^2 - (b - x)^2 = (d^2 - b^2) + 2x(b - d) +// => x = (E' - E - d^2 + b^2) / (2 * (b - d)) +// +// One baseline probe plus one probe per position solves it outright: len + 1 +// reads regardless of length, instead of walking permutations. d is chosen far +// from b so the division by (b - d) suppresses the rounding in toFixed(3). +// +// Below length 5 upstream returns no deviation at all (checkPassword bails on +// `password.length < 5`), but the permutation count there is at most 24, so +// that case just enumerates. The old static path enumerated in every case, +// which is why 5-digit servers sat at a 64% solve rate - 120 permutations +// against the caller's 60-attempt cap. +const SORTED_ECHO_PROBES_PER_CYCLE = 25; + +async function authSortedEcho(ns, dnet, hostname, entry, details) { + const len = details.passwordLength || 1; + if (!entry._se || entry._se.len !== len) { + const digits = String(details.data || details.passwordHint || '').replace(/[^0-9]/g, '').slice(-len); + entry._se = { len, digits, base: null, E0: null, pos: 0, out: new Array(len).fill(null), perms: null, idx: 0 }; + ns.print(`${hostname}: PHP 5.4 len ${len}, digits ${digits || '?'}, ${len >= 5 ? 'RMS deviation solve' : 'permutations'}`); + } + const st = entry._se; + if (st.digits.length !== len) { + ns.print(`auth ${hostname}: PHP 5.4 hint has ${st.digits.length} digits, expected ${len}, giving up`); + delete entry._se; return false; } - await ns.sleep(50); - return false; -} -// OpenWebAccessPoint: password clues are in heartbleed traffic logs, not static data. -// Extract digit clues ("I can see a X and a Y"), build permutations, try them. -async function authOpenWeb(ns, dnet, hostname, entry, l) { - // Collect digit clues across multiple heartbleed calls - if (!entry._owClues) { - entry._owClues = new Set(); - entry._owCandidates = null; - entry._owIdx = 0; - } - - // Extract "I can see a X and a Y" patterns and credential leaks from heartbleed - const hb = await dnet.heartbleed(hostname, { peek: true, logsToCapture: 5 }); - if (hb.success && hb.logs) { - for (const log of hb.logs) { - // Direct credential leaks: other servers authenticating with this one's password - // Format: hostname:password or "passcode: NNNNN" or "Logging in with passcode: NNNNN" - const leak = log.match(/:(\d+)$/m) - || log.match(/passcode:\s*(\d+)/i) - || log.match(/password:\s*(\d+)/i); - if (leak && leak[1].length === l) { - entry._owCandidates = [leak[1]]; - entry._owIdx = 0; - } - const matches = log.match(/see a (\d) and a (\d)/gi); - if (matches) { - for (const m of matches) { - const d = m.match(/\d/g); - if (d) d.forEach(x => entry._owClues.add(x)); - } - } - // Also extract "X and Y are important" patterns - const imp = log.match(/(\d) and (\d) are important/gi); - if (imp) { - for (const m of imp) { - const d = m.match(/\d/g); - if (d) d.forEach(x => entry._owClues.add(x)); - } + for (let budget = SORTED_ECHO_PROBES_PER_CYCLE; budget > 0; budget--) { + // Short passwords get no deviation reported, so enumerate instead. + if (len < 5 || st.perms) { + if (!st.perms) st.perms = [...new Set(permute(st.digits))].filter(x => x[0] !== '0' || len === 1); + if (st.idx >= st.perms.length) { + ns.print(`auth ${hostname}: PHP 5.4 exhausted ${st.perms.length} permutations, restarting`); + delete entry._se; + return false; } + const r = await sortedEchoProbe(ns, dnet, hostname, entry, st.perms[st.idx++]); + if (r.solved) return true; + if (r.rmsd === undefined) return false; + continue; + } + + if (st.E0 == null) { + st.base = st.digits; + const r = await sortedEchoProbe(ns, dnet, hostname, entry, st.base); + if (r.solved) return true; + if (r.rmsd == null) return noFeedback(ns, st, hostname, 'PHP 5.4'); + st.E0 = len * r.rmsd * r.rmsd; + continue; + } + + if (st.pos < len) { + const b = Number(st.base[st.pos]); + // Pick the probe digit as far from the baseline as possible: the + // solved x divides by (b - d), so a large gap damps the 3-decimal + // rounding on the reported deviation. + const d = b < 5 ? 9 : 0; + const probe = st.base.slice(0, st.pos) + String(d) + st.base.slice(st.pos + 1); + const r = await sortedEchoProbe(ns, dnet, hostname, entry, probe); + if (r.solved) return true; + if (r.rmsd == null) return noFeedback(ns, st, hostname, 'PHP 5.4'); + const E = len * r.rmsd * r.rmsd; + const x = (E - st.E0 - d * d + b * b) / (2 * (b - d)); + st.out[st.pos] = Math.min(9, Math.max(0, Math.round(x))); + st.pos++; + continue; } - } - // Read Mastermind feedback from heartbleed data - const hasFeedback = hb.logs?.some(log => /No characters are in the right place/i.test(log) - || /characters?.*in the right place/i.test(log)); - if (hasFeedback && entry._owCandidates) { - // Filter: remove the last guess from candidates if "no characters right" - if (hb.logs?.some(log => /No characters are in the right place/i.test(log)) - && entry._owCandidates.length > 1) { - entry._owCandidates = entry._owCandidates.filter(c => c !== entry._owLastGuess); + const pw = st.out.join(''); + // The deduced digits must be a rearrangement of the ones we were given; + // if not, the algebra drifted and permutations are the safer fallback. + if (pw.split('').sort().join('') !== st.digits.split('').sort().join('')) { + ns.print(`auth ${hostname}: PHP 5.4 deduced ${pw}, not a permutation of ${st.digits} - falling back to enumeration`); + st.perms = null; st.idx = 0; + continue; } - entry._owIdx = 0; + const r = await sortedEchoProbe(ns, dnet, hostname, entry, pw); + if (r.solved) return true; + ns.print(`auth ${hostname}: PHP 5.4 deduced ${pw} but it was rejected - falling back to enumeration`); + st.perms = null; st.idx = 0; } + return false; +} - // Clues collected but not yet built into candidate list - if (!entry._owCandidates || entry._owIdx >= entry._owCandidates.length) { - const clues = [...entry._owClues]; - if (clues.length >= l) { - // Generate all permutations of clue digits, plus extra combos with 0 - const candidates = new Set(); - for (const perm of permute(clues)) { - const p = perm.slice(0, l).join(''); - if (p.length === l) candidates.add(p); - } - // Also try repeating the first clue digit - const repeat = clues[0].repeat(l); - candidates.add(repeat); - entry._owCandidates = [...candidates]; - } else if (clues.length > 0) { - // Not enough clues — pad with common digits - entry._owCandidates = [clues.join('').padEnd(l, '0'), clues.join('').padEnd(l, '9')]; +async function sortedEchoProbe(ns, dnet, hostname, entry, pw) { + let r; + try { + r = await dnet.authenticate(hostname, pw); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return { solved: false, rmsd: undefined }; + } + if (r && r.success) { + entry.password = pw; + entry.session = true; + delete entry._se; + ns.print(`auth ${hostname} SUCCESS: ${pw}`); + return { solved: true, rmsd: null }; + } + const fb = await readAuthFeedback(ns, dnet, hostname, pw); + const m = typeof fb === 'string' ? fb.match(/RMS Deviation:\s*([0-9.]+)/i) : null; + return { solved: false, rmsd: m ? parseFloat(m[1]) : null }; +} + +// 2G_cellular (upstream TimingAttack) - a prefix oracle handed over in plain +// text. +// +// const indexOfDifference = server.password.split("") +// .findIndex((char, i) => char !== attemptedPassword[i]); +// const hint = `Found a mismatch while checking each character (${indexOfDifference})`; +// +// Every attempt reports the index of the FIRST wrong character, so the password +// builds left to right: hold a known-good prefix, try each alphabet character +// in the next slot, and whichever one pushes the reported index past that slot +// is correct. +// +// The index arrives in `message`, not `data`. `data` carries the response time +// - the side channel the model is named for, which leaks the same prefix length +// through calculateAuthenticationTime(..., getSharedChars(...)) - but the +// message is exact, so there's no need to time anything. +// +// A filler character occupies the unknown tail. When the filler happens to +// match positions beyond the one being solved, the reported index jumps past +// them and confirms several characters at once, so the new prefix is +// guess.slice(0, index) rather than just one character appended. +// +// The old static branch returned "0".repeat(len): one candidate, re-sent +// unchanged every cycle forever. +const TIMING_PROBES_PER_CYCLE = 25; + +async function authTimingAttack(ns, dnet, hostname, entry, details) { + const len = details.passwordLength || 1; + if (!entry._ta || entry._ta.len !== len) { + entry._ta = { len, alpha: bullsAlphabet(details), prefix: '', idx: 0 }; + ns.print(`${hostname}: 2G_cellular len ${len} ${details.passwordFormat || '?'}, prefix oracle over ${entry._ta.alpha.length} chars`); + } + const st = entry._ta; + const fill = st.alpha[0]; + + for (let budget = TIMING_PROBES_PER_CYCLE; budget > 0; budget--) { + const at = st.prefix.length; + if (at >= len || st.idx >= st.alpha.length) { + // Solving the last position wins outright (the guess equals the + // password), so reaching either of these means the prefix went + // stale under us - most likely the password was regenerated. + ns.print(`auth ${hostname}: 2G_cellular stuck at position ${at} of ${len}, restarting`); + delete entry._ta; + return false; + } + + const guess = st.prefix + st.alpha[st.idx] + fill.repeat(len - at - 1); + let r; + try { + r = await dnet.authenticate(hostname, guess); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return false; + } + if (r && r.success) { + entry.password = guess; + entry.session = true; + delete entry._ta; + ns.print(`auth ${hostname} SUCCESS: ${guess}`); + return true; + } + + const o = await readAuthEntry(ns, dnet, hostname, guess); + const mismatch = timingMismatchIndex(o?.message); + if (mismatch == null) return noFeedback(ns, st, hostname, '2G_cellular'); + + if (mismatch > at) { + // Everything before the reported mismatch is confirmed, which can + // be more than the single character we were testing. + st.prefix = guess.slice(0, mismatch); + st.idx = 0; + } else if (mismatch === at) { + st.idx++; } else { - entry._owCandidates = ['0'.repeat(l)]; + ns.print(`auth ${hostname}: 2G_cellular prefix invalidated at ${mismatch}, restarting`); + delete entry._ta; + return false; + } + } + return false; +} + +// "Found a mismatch while checking each character (3)" -> 3 +function timingMismatchIndex(msg) { + if (typeof msg !== 'string') return null; + const m = msg.match(/\((-?\d+)\)/); + if (!m) return null; + const n = parseInt(m[1], 10); + return Number.isFinite(n) ? n : null; +} + +// NIL (upstream Yesn_t) - positional feedback, the most generous oracle here. +// +// attemptedPassword.split("").map((c, i) => c === password[i] ? "yes" : "yesn't").join(",") +// +// So a probe of c.repeat(len) names EVERY position holding c in one shot - +// no binary splitting needed, unlike the bulls-only models. Sweep the alphabet +// and the password falls out in at most |alphabet| probes. +// +// The old solver had the right shape but read the response off r.data, which +// is undefined, so it never locked a position; it then walked _nilDigit to 10, +// reset, and looped forever. It was also digit-only, while getYesn_tConfig +// allows letters above difficulty 8. +const NIL_PROBES_PER_CYCLE = 25; + +async function authNIL(ns, dnet, hostname, entry, details) { + const len = details.passwordLength || 1; + if (!entry._nil || entry._nil.len !== len) { + entry._nil = { + len, + alpha: bullsAlphabet(details), + idx: 0, + known: new Array(len).fill(null), + }; + ns.print(`${hostname}: NIL len ${len} ${details.passwordFormat || '?'}, ${entry._nil.alpha.length}-char alphabet`); + } + const st = entry._nil; + + for (let budget = NIL_PROBES_PER_CYCLE; budget > 0; budget--) { + const unknown = st.known.filter(c => c === null).length; + + // Whatever the final unprobed character is, it must own every position + // still unaccounted for - no need to spend a probe confirming it. + if (unknown && st.idx === st.alpha.length - 1) { + const last = st.alpha[st.idx]; + for (let i = 0; i < len; i++) if (st.known[i] === null) st.known[i] = last; + st.idx++; + continue; + } + if (!unknown) { + const pw = st.known.join(''); + const r = await nilProbe(ns, dnet, hostname, entry, pw); + if (r.solved) return true; + ns.print(`auth ${hostname}: NIL deduced ${pw} but it was rejected, restarting`); + delete entry._nil; + return false; + } + if (st.idx >= st.alpha.length) { + ns.print(`auth ${hostname}: NIL exhausted its alphabet with ${unknown} positions unresolved, restarting`); + delete entry._nil; + return false; + } + + const c = st.alpha[st.idx]; + const r = await nilProbe(ns, dnet, hostname, entry, c.repeat(len)); + if (r.solved) return true; + if (r.marks == null) return noFeedback(ns, st, hostname, 'NIL'); + st.idx++; + for (let i = 0; i < len && i < r.marks.length; i++) { + // "yesn't" starts with "yes", so this has to be an exact match. + if (r.marks[i].trim() === 'yes') st.known[i] = c; } - entry._owIdx = 0; } + return false; +} + +async function nilProbe(ns, dnet, hostname, entry, pw) { + let r; + try { + r = await dnet.authenticate(hostname, pw); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return { solved: false, marks: null }; + } + if (r && r.success) { + entry.password = pw; + entry.session = true; + delete entry._nil; + ns.print(`auth ${hostname} SUCCESS: ${pw}`); + return { solved: true, marks: null }; + } + const fb = await readAuthFeedback(ns, dnet, hostname, pw); + return { solved: false, marks: typeof fb === 'string' ? fb.split(',') : null }; +} + +// OpenWebAccessPoint (upstream packetSniffer) - the response IS the leak. +// +// checkPassword hands this model's failure data straight to capturePackets(), +// which splices the password into a wall of noise: +// +// difficulty <= 16: ` ${hostname}:${password} ` inside chatty log text +// difficulty > 16: the raw password, no delimiter, buried in ~124-144 +// characters of random alphanumeric junk +// +// The easy form is a regex. The hard form carries no marker, but every +// substring of the right length is a candidate - and because each failed +// attempt returns a FRESH capture with fresh noise, intersecting the candidate +// sets across two or three captures leaves only the password, which is the one +// string present in all of them. +// +// The old solver read heartbleed's ambient log noise instead and scraped +// "I can see a X and a Y" hints out of it to build digit permutations. Those +// phrases come from getLogNoise(), a different generator, and leak two +// characters of a password that may not even be this server's. It never looked +// at the packet dump the oracle was handing it directly. +const OPENWEB_PROBES_PER_CYCLE = 12; +// Below this many candidates, spend the probe on a real attempt rather than a +// throwaway - it might just win, and it yields a capture either way. +const OPENWEB_DIRECT_TRIES = 8; + +async function authOpenWeb(ns, dnet, hostname, entry, details) { + const len = details.passwordLength || 1; + if (!entry._ow || entry._ow.len !== len) { + entry._ow = { len, candidates: null, captures: 0 }; + ns.print(`${hostname}: OpenWebAccessPoint len ${len}, reading packet captures`); + } + const st = entry._ow; + + for (let budget = OPENWEB_PROBES_PER_CYCLE; budget > 0; budget--) { + const narrowed = st.candidates && st.candidates.length && st.candidates.length <= OPENWEB_DIRECT_TRIES; + const pw = narrowed ? st.candidates.shift() : 'a'.repeat(len); - // Try next candidate - for (let i = 0; i < Math.min(5, entry._owCandidates.length - entry._owIdx); i++) { - const pw = entry._owCandidates[entry._owIdx]; - entry._owLastGuess = pw; - entry._owIdx++; - const r = await dnet.authenticate(hostname, pw); - if (r.success) { + let r; + try { + r = await dnet.authenticate(hostname, pw); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return false; + } + if (r && r.success) { entry.password = pw; entry.session = true; - delete entry._owClues; delete entry._owCandidates; delete entry._owIdx; delete entry._owLastGuess; + delete entry._ow; ns.print(`auth ${hostname} SUCCESS: ${pw}`); return true; } - await ns.sleep(50); + + const packet = await readAuthFeedback(ns, dnet, hostname, pw); + if (packet == null) return noFeedback(ns, st, hostname, 'OpenWebAccessPoint'); + st.captures++; + + // Easy form: the password is labelled with the server's own hostname. + const direct = owDirect(packet, hostname, len); + if (direct) { st.candidates = [direct]; continue; } + + // Hard form: intersect this capture's substrings with what survived. + const seen = owSubstrings(packet, len); + st.candidates = st.candidates ? st.candidates.filter(c => seen.has(c)) : [...seen]; + if (!st.candidates.length) { + // The password is in every capture, so an empty set means the + // password changed under us. Start over rather than spin. + ns.print(`auth ${hostname}: OpenWebAccessPoint candidates collapsed after ${st.captures} captures, restarting`); + delete entry._ow; + return false; + } } return false; } -async function authFactoriOs(ns, dnet, hostname, entry, l) { - if (!entry._foPrimes) entry._foPrimes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, - 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113]; - if (!entry._foIdx) entry._foIdx = 0; - if (!entry._foProduct) entry._foProduct = 1; - - const primes = entry._foPrimes; - for (let i = entry._foIdx; i < primes.length; i++) { - entry._foIdx = i + 1; - const p = primes[i]; - const pStr = String(p); - - // Does this prime divide the current remaining password? - const r = await dnet.authenticate(hostname, pStr); - await ns.sleep(30); - if (r.success) { entry.password = pStr; entry.session = true; return true; } - if (r.data !== 'true') continue; - - // Keep multiplying by this prime while it still divides - let factor = entry._foProduct * p; - while (true) { - const fStr = String(factor); - const r2 = await dnet.authenticate(hostname, fStr); - if (r2.success) { entry.password = fStr; entry.session = true; return true; } - // Test if factor still divides (it's a partial product, not the full password) - if (r2.data !== 'true') { factor = Math.floor(factor / p); break; } - factor *= p; - await ns.sleep(30); - } - entry._foProduct = factor; +function owDirect(packet, hostname, len) { + const esc = String(hostname).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Anchored on our own hostname first. The trailing guard keeps it from + // matching a prefix of something longer. + let m = packet.match(new RegExp(esc + ':([0-9A-Za-z]{' + len + '})(?![0-9A-Za-z])')); + if (m) return m[1]; + // Fall back to any host:secret pair of the right length, in case we were + // handed an IP rather than the hostname the packet was written with. + m = packet.match(new RegExp('[\\w.-]+:([0-9A-Za-z]{' + len + '})(?![0-9A-Za-z])')); + return m ? m[1] : null; +} + +function owSubstrings(packet, len) { + const out = new Set(); + for (let i = 0; i + len <= packet.length; i++) out.add(packet.slice(i, i + len)); + return out; +} + +// Factori-Os (upstream divisibilityTest) - a divisibility oracle. +// +// Send any number n and the response says whether n divides the password: +// data "true"/"false", message "Password IS/is not divisible by 'n'". Same +// story as every other model - that only reaches a script through +// heartbleed(), never through authenticate()'s return - so the old solver's +// `if (r.data !== 'true') continue;` was false on every prime. It skipped all +// 30, left _foIdx past the end of its list, and then did nothing at all on +// every subsequent call. +// +// The algorithm was also wrong independently of the feedback bug. It walked a +// hardcoded list of the first 30 primes, which misses the large primes +// upstream multiplies in above difficulty 12, and it never used the known +// password length to bound anything. +// +// getPasswordMadeUpOfPrimesProduct builds the password as +// base * (scale/3 factors) * [largePrime] * [largePrime] +// with base <= 80, the inner factors drawn from smallPrimes or 1..5, and the +// large primes only above difficulty 12 and 24. Every prime factor therefore +// comes from a known 108-entry universe - these two lists, copied from +// upstream - so this is a bounded sweep, not a search. +// +// Primes are taken in ascending order with each exponent resolved before +// moving on, so the unfactored remainder never has a factor below the current +// prime. That gives a strong stopping rule: once product * p exceeds the +// largest number of the password's digit length, the remainder must be 1 and +// the factorisation is complete. On a 3-digit password that kills the entire +// large-prime list on the first comparison, since 1069 alone already overflows. +const FACTORI_SMALL_PRIMES = [ + 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, +]; +const FACTORI_LARGE_PRIMES = [ + 1069, 1409, 1471, 1567, 1597, 1601, 1697, 1747, 1801, 1889, 1979, 1999, 2063, 2207, 2371, 2503, 2539, 2693, 2741, + 2753, 2801, 2819, 2837, 2909, 2939, 3169, 3389, 3571, 3761, 3881, 4217, 4289, 4547, 4729, 4789, 4877, 4943, 4951, + 4957, 5393, 5417, 5419, 5441, 5519, 5527, 5647, 5779, 5881, 6007, 6089, 6133, 6389, 6451, 6469, 6547, 6661, 6719, + 6841, 7103, 7549, 7559, 7573, 7691, 7753, 7867, 8053, 8081, 8221, 8329, 8599, 8677, 8761, 8839, 8963, 9103, 9199, + 9343, 9467, 9551, 9601, 9739, 9749, 9859, +]; +const FACTORI_PROBES_PER_CYCLE = 25; + +async function authFactoriOs(ns, dnet, hostname, entry, details) { + const len = details.passwordLength || 1; + if (!entry._fo || entry._fo.len !== len) { + entry._fo = { + len, + // BigInt throughout: upstream only rejects a generated password if + // it doesn't round-trip through Number, which still admits values + // above MAX_SAFE_INTEGER (17 digits shows up by difficulty 26). + // Accumulating the product in doubles silently corrupts those. + maxVal: 10n ** BigInt(len) - 1n, + product: 1n, // fully resolved factors so far + large: false, // which prime list we're sweeping + idx: 0, + exp: 0, // confirmed exponent of the current prime + }; + ns.print(`${hostname}: Factori-Os len ${len}, factoring via divisibility oracle`); + } + const st = entry._fo; + + for (let budget = FACTORI_PROBES_PER_CYCLE; budget > 0; budget--) { + const primes = st.large ? FACTORI_LARGE_PRIMES : FACTORI_SMALL_PRIMES; + // Only safe to conclude between primes, not mid-exponent-walk. + const done = st.idx >= primes.length + || (st.exp === 0 && st.product * BigInt(primes[st.idx]) > st.maxVal); + if (done) { + if (st.large) return await factoriVerify(ns, dnet, hostname, entry, st); + st.large = true; st.idx = 0; st.exp = 0; + continue; + } + + const p = BigInt(primes[st.idx]); + const q = p ** BigInt(st.exp + 1); + // password = product * remainder, and p does not divide product, so if + // p^(exp+1) divides the password then the password is at least + // product * p^(exp+1). Bounding against the product rather than + // against maxVal alone is what keeps the exponent walk short - on a + // 17-digit password the loose version burned ~1400 probes walking + // 2^1..2^57 and friends. + if (st.product * q > st.maxVal) { + st.product *= p ** BigInt(st.exp); + st.idx++; st.exp = 0; + continue; + } + + const res = await factoriProbe(ns, dnet, hostname, entry, q); + if (res.solved) return true; + if (res.divides == null) return noFeedback(ns, st, hostname, 'Factori-Os'); + if (res.divides) { st.exp++; continue; } + st.product *= p ** BigInt(st.exp); + st.idx++; st.exp = 0; } return false; } -async function authBigMod(ns, dnet, hostname, entry, l) { - if (!entry._bmRem) entry._bmRem = {}; - if (!entry._bmN) entry._bmN = 2; - - const rem = entry._bmRem; - let n = entry._bmN; - for (let i = 0; i < 8; i++, n++) { - if ((n - 1) % 32 === 0) n++; // skip n where second mod = 1 (always 0) - entry._bmN = n + 1; - const r = await dnet.authenticate(hostname, String(n)); - await ns.sleep(30); - if (r.success) { entry.password = String(n); entry.session = true; return true; } - const val = parseInt(r.data); - if (!isNaN(val)) rem[n] = val; - - // CRT: product of moduli. If it covers the possible password range, reconstruct. - const mods = Object.keys(rem).map(Number); - const M = mods.reduce((a, b) => a * b, 1); - const maxPass = Math.pow(10, l); - if (M > maxPass) { - // CRT reconstruction - let x = 0; - for (const m of mods) { - const Mi = M / m; - const inv = modInverse(Mi % m, m); - x = (x + rem[m] * Mi * inv) % M; - } - const pw = String(x); - const r2 = await dnet.authenticate(hostname, pw); - if (r2.success) { entry.password = pw; entry.session = true; return true; } - // Off by M? Try x + M, x + 2M... - for (let k = 1; k < 5; k++) { - const pw2 = String(x + k * M); - if (pw2.length > l) break; - const r3 = await dnet.authenticate(hostname, pw2); - if (r3.success) { entry.password = pw2; entry.session = true; return true; } - } - // Reset: collected enough info but CRT didn't work — moduli conflict - entry._bmRem = {}; entry._bmN = 2; +async function factoriVerify(ns, dnet, hostname, entry, st) { + const pw = st.product.toString(); + if (pw.length !== st.len) { + // The product doesn't have the advertised digit count, so the password + // has a factor outside both upstream lists. Bail loudly rather than + // hammer a number that can't be right. + ns.print(`auth ${hostname}: Factori-Os factored to ${pw} (${pw.length} digits, expected ${st.len}) - factor outside the known primes, giving up`); + delete entry._fo; + return false; + } + const res = await factoriProbe(ns, dnet, hostname, entry, st.product); + if (res.solved) return true; + ns.print(`auth ${hostname}: Factori-Os deduced ${pw} but it was rejected, restarting`); + delete entry._fo; + return false; +} + +// Asks whether `n` divides the password. Note that a probe can win outright: +// checkPassword() tests equality before it ever reaches the divisibility +// branch, so probing a divisor that happens to BE the password authenticates. +async function factoriProbe(ns, dnet, hostname, entry, n) { + const q = String(n); + let r; + try { + r = await dnet.authenticate(hostname, q); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return { solved: false, divides: null }; + } + if (r && r.success) { + entry.password = q; + entry.session = true; + delete entry._fo; + ns.print(`auth ${hostname} SUCCESS: ${q}`); + return { solved: true, divides: null }; + } + const o = await readAuthEntry(ns, dnet, hostname, q); + const fb = o ? String(o.data) : null; + if (fb === 'true') return { solved: false, divides: true }; + if (fb === 'false') return { solved: false, divides: false }; + // Fall back to the prose if `data` ever stops being a bare boolean string. + const msg = typeof o?.message === 'string' ? o.message : ''; + if (/\bis divisible\b/i.test(msg)) return { solved: false, divides: true }; + if (/\bnot divisible\b/i.test(msg)) return { solved: false, divides: false }; + return { solved: false, divides: null }; +} + +// BigMo%od (upstream tripleModulo). The response is +// (password % n) % (((n - 1) % 32) + 1) +// for whatever n you send. That inner modulus is the whole puzzle: for n <= 32 +// it equals n exactly, so the outer mod is a no-op and the answer is a clean +// `password % n`. Above 32 it collapses - n = 33 gives an inner modulus of 1, +// i.e. always 0 - so every useful probe lives at n <= 32. +// +// Take residues against pairwise-coprime prime powers <= 32 and reconstruct +// with the Chinese Remainder Theorem. Their full product is ~1.4e14, well past +// the 11 digits getTripleModuloConfig can produce, so 9-11 probes is the whole +// solve. +// +// The old version was broken three ways over: it read r.data (undefined, so +// every residue was NaN and the moduli product never grew), it walked n = 2, +// 3, 4, 5... which are not pairwise coprime and make CRT invalid, and it did +// the reconstruction in doubles where the intermediate terms overflow +// MAX_SAFE_INTEGER. +const BIGMOD_MODULI = [32, 27, 25, 7, 11, 13, 17, 19, 23, 29, 31]; +const BIGMOD_PROBES_PER_CYCLE = 15; + +async function authBigMod(ns, dnet, hostname, entry, details) { + const len = details.passwordLength || 1; + if (!entry._bm || entry._bm.len !== len) { + entry._bm = { len, maxVal: 10n ** BigInt(len) - 1n, rems: [], idx: 0 }; + ns.print(`${hostname}: BigMo%od len ${len}, CRT over moduli <= 32`); + } + const st = entry._bm; + + for (let budget = BIGMOD_PROBES_PER_CYCLE; budget > 0; budget--) { + let product = 1n; + for (const e of st.rems) product *= BigInt(e.m); + // Once the moduli product exceeds the largest value of this digit + // length, the residues pin the password to exactly one candidate. + if (product > st.maxVal || st.idx >= BIGMOD_MODULI.length) { + return await bigModSolve(ns, dnet, hostname, entry, st, product); + } + + const m = BIGMOD_MODULI[st.idx]; + let r; + try { + r = await dnet.authenticate(hostname, String(m)); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); return false; } + // A modulus can be the password outright on a short one. + if (r && r.success) { + entry.password = String(m); + entry.session = true; + delete entry._bm; + ns.print(`auth ${hostname} SUCCESS: ${m}`); + return true; + } + const fb = await readAuthFeedback(ns, dnet, hostname, String(m)); + const val = fb == null ? NaN : parseInt(fb, 10); + if (!Number.isFinite(val)) return noFeedback(ns, st, hostname, 'BigMo%od'); + st.rems.push({ m, r: val }); + st.idx++; } return false; } -async function authKingOfTheHill(ns, dnet, hostname, entry, l) { - // Scan the full range. For length-L numeric, range is 0 to 10^L-1. - // When within 3% of the answer, the game switches to a clean single-peak - // Gaussian centered on the password with peak altitude 10,000. - if (!entry._kohTried) entry._kohTried = new Set(); - const tried = entry._kohTried; - - // Try a few values each call, pick the one with highest altitude - const range = Math.min(Math.pow(10, l), 10000); - let bestX = entry._kohBestX; - let bestAlt = entry._kohBestAlt || 0; - - // Phase 1: coarse scan - if (!entry._kohCoarse) { - const step = Math.max(1, Math.floor(range / 20)); - for (let x = 0; x < range && tried.size < 50; x += step) { - if (tried.has(x)) continue; - tried.add(x); - const pw = String(x).padStart(l, '0'); - const r = await dnet.authenticate(hostname, pw); - if (r.success) { entry.password = pw; entry.session = true; return true; } - const alt = parseFloat(r.data) || 0; - if (alt > bestAlt) { bestAlt = alt; bestX = x; } - if (alt >= 10000) break; - await ns.sleep(30); - } - entry._kohCoarse = tried.size >= 50 || bestAlt >= 9000; - entry._kohBestX = bestX; - entry._kohBestAlt = bestAlt; - return false; +async function bigModSolve(ns, dnet, hostname, entry, st, product) { + if (!st.rems.length) { delete entry._bm; return false; } + const x = crtCombine(st.rems); + // If the moduli product never cleared maxVal we only know the password + // modulo `product`, so walk the arithmetic progression x, x+M, x+2M... + const candidates = []; + for (let k = 0n; ; k++) { + const c = x + k * product; + if (c > st.maxVal) break; + if (c.toString().length === st.len) candidates.push(c); + if (candidates.length >= 20) break; + } + for (const c of candidates) { + let r; + try { + r = await dnet.authenticate(hostname, c.toString()); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return false; + } + if (r && r.success) { + entry.password = c.toString(); + entry.session = true; + delete entry._bm; + ns.print(`auth ${hostname} SUCCESS: ${c}`); + return true; + } + } + ns.print(`auth ${hostname}: BigMo%od CRT gave ${candidates.length} candidate(s), none accepted - restarting`); + delete entry._bm; + return false; +} + +// Chinese Remainder Theorem over pairwise-coprime moduli, in BigInt because +// the mi * inv terms overflow a double well before the moduli product does. +function crtCombine(rems) { + let M = 1n; + for (const e of rems) M *= BigInt(e.m); + let x = 0n; + for (const e of rems) { + const m = BigInt(e.m); + const mi = M / m; + x = (x + BigInt(e.r) * mi * modInverseBig(mi % m, m)) % M; + } + return ((x % M) + M) % M; +} + +function modInverseBig(a, m) { + let [r0, r1] = [((a % m) + m) % m, m]; + let [s0, s1] = [1n, 0n]; + while (r1 !== 0n) { + const q = r0 / r1; + [r0, r1] = [r1, r0 - q * r1]; + [s0, s1] = [s1, s0 - q * s1]; } + return ((s0 % m) + m) % m; +} + +// KingOfTheHill (upstream globalMaxima). Every attempt reports an altitude on +// a landscape of Gaussian hills, with the password sitting on the tallest. +// +// The exploitable part is the proximity rule in getKingOfTheHillAltitude: +// +// if (Math.abs((x - password) / password) < 0.03) +// return getAltitudeGivenHillSpecs(x, password, 10000, width); +// +// Inside 3% of the password the side hills are dropped and the reading is a +// single clean Gaussian, height 10000, centred exactly on the answer: +// +// altitude = 10000 * exp(-((x - password) / width)^2) +// +// That inverts. One reading inside the band gives the exact distance +// +// |x - password| = width * sqrt(ln(10000 / altitude)) +// +// leaving just two candidates, x - d and x + d. So this doesn't hill-climb at +// all: it scans until something lands in the band, then solves for the answer. +// +// The scan is geometric rather than linear because the band is a percentage. +// Stepping by 5% guarantees a probe within 3% of any password (worst case is +// the midpoint, sqrt(1.05) - 1 = 2.4%), which covers a whole decade in ~48 +// probes regardless of how large the numbers get. +// +// `width` is 10^max(len-2, 0) + 1, straight from the source, and the old +// solver never used it - it coarse-scanned, fine-scanned, and compared +// altitudes, all against r.data, which was undefined. parseFloat(undefined) is +// NaN, `|| 0` made every altitude 0, and bestAlt never moved off 0. +const KOTH_SCAN_RATIO = 1.05; +const KOTH_PROBES_PER_CYCLE = 25; +const KOTH_BRUTE_FORCE_MAX = 200; + +async function authKingOfTheHill(ns, dnet, hostname, entry, details) { + const len = details.passwordLength || 1; + if (!entry._koh || entry._koh.len !== len) { + const lo = len === 1 ? 0 : Math.pow(10, len - 1); + const hi = Math.pow(10, len) - 1; + entry._koh = { + len, lo, hi, + width: Math.pow(10, Math.max(len - 2, 0)) + 1, + x: lo, // next scan position + scanning: hi - lo + 1 > KOTH_BRUTE_FORCE_MAX, + candidates: [], // inversion results, best-altitude first + readings: [], // {x, alt} from the scan + }; + ns.print(`${hostname}: KingOfTheHill len ${len}, width ${entry._koh.width}, ${entry._koh.scanning ? 'geometric scan' : 'brute force'}`); + } + const st = entry._koh; + + for (let budget = KOTH_PROBES_PER_CYCLE; budget > 0; budget--) { + // Small ranges are cheaper to walk than to scan-and-invert. + if (!st.scanning && st.x <= st.hi) { + const r = await kothProbe(ns, dnet, hostname, entry, st.x); + st.x++; + if (r.solved) return true; + if (r.alt == null) return noFeedback(ns, st, hostname, 'KingOfTheHill'); + continue; + } + + if (st.scanning && st.x <= st.hi) { + const r = await kothProbe(ns, dnet, hostname, entry, st.x); + if (r.solved) return true; + if (r.alt == null) return noFeedback(ns, st, hostname, 'KingOfTheHill'); + if (r.alt > 0) st.readings.push({ x: st.x, alt: r.alt }); + const next = Math.ceil(st.x * KOTH_SCAN_RATIO); + // A geometric step overshooting `hi` would leave the top of the + // range unprobed - the last step of a 100..999 sweep jumps 963 to + // 1012, and a password of 993 sits 3.02% from 963, just outside the + // band. Land on `hi` instead. That always closes the gap: the + // skipped span is under one 5% step, so nothing in it can be 3% + // away from both endpoints at once. + st.x = (next > st.hi && st.x < st.hi) ? st.hi : (next > st.x ? next : st.x + 1); + if (st.x > st.hi) st.candidates = kothInvert(st); + continue; + } + + if (st.candidates.length) { + const c = st.candidates.shift(); + const r = await kothProbe(ns, dnet, hostname, entry, c); + if (r.solved) return true; + if (r.alt == null) return noFeedback(ns, st, hostname, 'KingOfTheHill'); + continue; + } - // Phase 2: fine scan around best coarse guess - const margin = Math.max(1, Math.floor(range * 0.05)); - let start = Math.max(0, (bestX || 0) - margin); - let end = Math.min(range - 1, (bestX || 0) + margin); - for (let x = start; x <= end && tried.size < 100; x++) { - if (tried.has(x)) continue; - tried.add(x); - const pw = String(x).padStart(l, '0'); - const r = await dnet.authenticate(hostname, pw); - if (r.success) { entry.password = pw; entry.session = true; return true; } - const alt = parseFloat(r.data) || 0; - if (alt > bestAlt) { bestAlt = alt; bestX = x; } - if (alt >= 10000) break; - await ns.sleep(30); - } - entry._kohBestX = bestX; - entry._kohBestAlt = bestAlt; - - // Phase 3: if we found a peak >= 10000, it's the password - if (bestAlt >= 10000) { - const pw = String(bestX).padStart(l, '0'); - const r = await dnet.authenticate(hostname, pw); - if (r.success) { entry.password = pw; entry.session = true; return true; } + ns.print(`auth ${hostname}: KingOfTheHill exhausted scan and ${st.readings.length} inversions, restarting`); + delete entry._koh; + return false; } return false; } -function modInverse(a, m) { - let [m0, x0, x1] = [m, 0, 1]; - while (a > 1) { - const q = Math.floor(a / m); - [m, a] = [a % m, m]; - [x0, x1] = [x1 - q * x0, x0]; +// Turns each scan reading into the two positions it implies for the password, +// assuming that reading was taken inside the 3% band. Readings taken outside +// produce a distance inconsistent with being inside the band, which throws +// them out for free - no probe spent. Ordered by altitude, since the closest +// reading is the most likely to have actually been in the band. +function kothInvert(st) { + const out = []; + const seen = new Set(); + const byAlt = st.readings.slice().sort((a, b) => b.alt - a.alt); + for (const { x, alt } of byAlt) { + if (!(alt > 0) || alt > 10000) continue; + const d = st.width * Math.sqrt(Math.log(10000 / alt)); + if (!Number.isFinite(d)) continue; + for (const cand of [Math.round(x - d), Math.round(x + d), Math.floor(x - d), Math.ceil(x + d)]) { + if (cand < st.lo || cand > st.hi || seen.has(cand)) continue; + // Self-consistency: if x really was within 3% of the password, the + // candidate it implies has to be within 3% of x too. + if (Math.abs(cand - x) / cand >= 0.03) continue; + seen.add(cand); + out.push(cand); + } } - return x1 < 0 ? x1 + m0 : x1; + return out; } +async function kothProbe(ns, dnet, hostname, entry, x) { + const pw = String(x); + let r; + try { + r = await dnet.authenticate(hostname, pw); + } catch (e) { + ns.print(`auth ${hostname} error: ${e?.message || e}`); + return { solved: false, alt: null }; + } + if (r && r.success) { + entry.password = pw; + entry.session = true; + delete entry._koh; + ns.print(`auth ${hostname} SUCCESS: ${pw}`); + return { solved: true, alt: null }; + } + const fb = await readAuthFeedback(ns, dnet, hostname, pw); + const alt = fb == null ? NaN : parseFloat(fb); + return { solved: false, alt: Number.isFinite(alt) ? alt : null }; +} + + async function deployWorm(ns, hostname, entry, details) { try { // Re-check connection before exec — darknet topology and serversOnNetwork can diverge after mutations. @@ -572,15 +1454,6 @@ async function deployWorm(ns, hostname, entry, details) { } catch (e) { ns.print(`deploy ${hostname} err: ${e}`); } } -function permute(arr) { - if (arr.length <= 1) return [arr.slice()]; - const result = []; - for (let i = 0; i < arr.length; i++) { - const rest = permute(arr.filter((_, j) => j !== i)); - for (const r of rest) result.push([arr[i], ...r]); - } - return result; -} function getMastermindScore(guess, target) { let exact = 0, wrongPos = 0; const gUsed = [], tUsed = []; @@ -595,4 +1468,4 @@ function getMastermindScore(guess, target) { } } return [exact, wrongPos]; -} +} \ No newline at end of file diff --git a/dnet-killall.js b/dnet-killall.js new file mode 100644 index 00000000..6912cc8b --- /dev/null +++ b/dnet-killall.js @@ -0,0 +1,106 @@ +// dnet-killall.js — stop the darknet worm fleet. +// +// Why this exists: exec() with preventDuplicates returns 0 when a script is +// already running, so scp updates a worm file on disk while the live process +// keeps executing whatever it started with. From WORM_VERSION 2 onward each +// worm notices that and exits on its own, but anything older has to be killed +// by hand — otherwise a superseded solver sits there re-sending the same dead +// guess indefinitely. +// +// Finding the hosts is the awkward part. dnet.probe() only returns servers +// directly connected to the one you're standing on, so from home it sees the +// depth-0 entries and nothing deeper. The worm's own password cache is the +// better source: darknet.js writes an entry for every host it has cracked, so +// its keys are exactly the set of hosts likely to be running a worm. Both are +// used, and hosts that have since been deleted are just skipped. +// +// ns.ps() and ns.kill() work on any server by name — no session needed — which +// is what makes this reachable from home at all. +// +// Usage: +// run dnet-killall.js kill every worm process it can reach +// run dnet-killall.js --dry-run list what it would kill, touch nothing +// run dnet-killall.js --quiet summary only, no per-process lines + +const WORM_FILES = ['darknet.js', 'darknet-looter.js', 'darknet-virus.js', 'labyrinth.js']; +const CACHE = '/data/dnet-passwords.txt'; + +export function autocomplete(data) { return ['--dry-run', '--quiet']; } + +/** @param {NS} ns */ +export async function main(ns) { + ns.disableLog('ALL'); + const dryRun = ns.args.includes('--dry-run'); + const quiet = ns.args.includes('--quiet'); + const say = msg => { if (!quiet) ns.tprint(msg); }; + + const hosts = new Set(); + let probed = 0; + try { + const dnet = ns.dnet; + if (dnet) for (const h of dnet.probe()) { hosts.add(h); probed++; } + } catch { /* not in BN15, or no darknet access */ } + const cached = cachedHosts(ns); + for (const h of cached) hosts.add(h); + // Worth checking even from home: harmless if nothing matches. + hosts.add(ns.getHostname()); + + if (!hosts.size) { + ns.tprint('dnet-killall: no darknet hosts found (no neighbours, empty cache)'); + return; + } + + let killed = 0, found = 0, unreachable = 0; + const touched = new Set(); + for (const host of [...hosts].sort()) { + let procs; + // Deleted darknet servers keep their names in the cache, and their + // hostnames get recycled, so an unknown host here is expected. + try { procs = ns.ps(host); } catch { unreachable++; continue; } + if (!Array.isArray(procs)) { unreachable++; continue; } + + for (const p of procs) { + if (!WORM_FILES.includes(p.filename)) continue; + found++; + if (dryRun) { + say(` would kill ${p.filename} on ${host} (pid ${p.pid})`); + touched.add(host); + continue; + } + let ok = false; + try { ok = ns.kill(p.pid); } catch { ok = false; } + if (ok) { + killed++; + touched.add(host); + say(` killed ${p.filename} on ${host} (pid ${p.pid})`); + } else { + say(` FAILED to kill ${p.filename} on ${host} (pid ${p.pid})`); + } + } + } + + ns.tprint(`dnet-killall: ${hosts.size} host(s) checked (${probed} probed, ${cached.length} cached` + + `${unreachable ? `, ${unreachable} gone` : ''}), ${found} worm process(es) on ${touched.size} host(s)`); + if (dryRun) { + ns.tprint('dnet-killall: --dry-run, nothing was killed'); + return; + } + ns.tprint(`dnet-killall: killed ${killed}/${found}`); + if (killed) { + // autopilot.js redeploys as soon as no darknet.js is running on any + // host adjacent to home, re-scp'ing from home first — so the fresh + // copy goes out on its own within a tick or two. + ns.tprint('dnet-killall: autopilot will redeploy from home once no worm is running on a nearby host'); + } +} + +// Hostnames the worm has cracked. darknet.js writes {host: {pw}}, while +// darknet-virus.js writes {host: "password"} to the same path — the two +// formats clobber each other, but either way the keys are the hostnames, which +// is all this needs. +function cachedHosts(ns) { + try { + const raw = JSON.parse(ns.read(CACHE)); + return raw && typeof raw === 'object' ? Object.keys(raw) : []; + } catch { return []; } +}