Игрушечный ботнет на JavaScript под BitBurner
Размножаемся
Игра дает нам программу NUKE.EXE
, которая взламывает компьютер и получает права администратора. Программа поможет вирусу захватывать компьютеры.
Иногда NUKE.EXE
требует, чтобы компьютер-жертва открыл сетевые порты. Позже научимся взламывать порты, а пока ограничимся жертвами, что поддаются NUKE.EXE
и без открытых портов.
Взлом компьютера требует навыков — хакерского уровня. NUKE.EXE
не взломает компьютер, если уровень ниже требуемого. Вы повышаете уровень, когда взламываете компьютеры и учитесь в университете.
Sector-12
Напишем скрипт, который заражает соседние компьютеры:
// worm-01.js
/** @param {NS} ns */
export async function main(ns) {
while (true) {
let victims = ns.scan();
for (let i in victims) {
if (!isInfected(ns, victims[i]))
infect(ns, victims[i]);
}
await ns.sleep(15000);
}
}
/**
* @param {NS} ns
* @param {string} host */
function isInfected(ns, host) {
return ns.hasRootAccess(host)
&& ns.isRunning(ns.getScriptName(), host);
}
/**
* @param {NS} ns
* @param {string} host */
function infect(ns, host) {
ns.print("infect ", host);
grantRootAccess(ns, host);
if (ns.hasRootAccess(host)) {
ns.scp(ns.getScriptName(), host);
ns.exec(ns.getScriptName(), host);
}
}
/**
* @param {NS} ns
* @param {string} host */
function grantRootAccess(ns, host) {
if (ns.hasRootAccess(host)) return true;
if (ns.getServerRequiredHackingLevel(host) <= ns.getHackingLevel()) {
const s = ns.getServer(host);
if (s.numOpenPortsRequired <= s.openPortCount)
ns.nuke(host);
else
ns.printf("Cannot grant root access on '%s': %d open ports required, %d opened",
host, s.numOpenPortsRequired, s.openPortCount);
return ns.hasRootAccess(host);
}
return false;
}
Червь 1 размножается
Функция
ns.sleep
работает асинхронно — возвращает управление программе сразу, но функция еще не завершила работу. Операторawait
ждет, пока функция завершится.Асинхронные функции помогают программам и устройствам ввода-вывода работать параллельно. Примеры:
Программа отправляет сообщение по сети и выполняет другой код, пока ждет ответа
Драйвер просит диск записать блоки файла и выполняет другой код, пока диск выполняет просьбу. xv6: Прерывания и драйверы устройств
Собираем дань
Вирус бесполезен, если только размножается. Научим вирус грабить соседей.
//robber.js
/** @param {NS} ns */
export async function main(ns) {
while (true) {
let victims = ns.scan();
for (let i in victims) {
const host = victims[i];
if ("home" == host) {
// игра требует выполнять await на каждой итерации цикла, иначе зависнет
await ns.sleep(1);
continue;
}
if (ns.hasRootAccess(host)) {
if (ns.getServerMinSecurityLevel(host) < ns.getServerSecurityLevel(host)) {
await ns.weaken(host);
} else if (ns.getServerMoneyAvailable(host) < ns.getServerMaxMoney(host)) {
await ns.grow(host);
} else {
await ns.hack(host);
}
}
}
}
}
//worm-02.js
const SCRIPT_ROBBER = "robber.js";
/**
* @param {NS} ns
* @param {string} host */
function infect(ns, host) {
grantRootAccess(ns, host);
if (ns.hasRootAccess(host)) {
ns.scp(ns.getScriptName(), host);
ns.exec(ns.getScriptName(), host);
ns.scp(SCRIPT_ROBBER, host);
ns.exec(SCRIPT_ROBBER, host);
}
}
/* ... остальной код из worm-01.js ... */
Червь 2 запускает грабителя
Компьютер способен запустить дополнительные потоки, когда память свободна. Функция execScriptIfEnoughRam
выполняет как можно больше потоков скрипта.
//worm-03.js
/**
* @param {NS} ns
* @param {string} host */
function execScriptIfEnoughRam(ns, scriptFileName, host, maxThreads) {
const threads = Math.min(countPossibleThreads(ns, scriptFileName, host), maxThreads);
if (0 < threads)
ns.exec(scriptFileName, host, threads);
}
/**
* @param {NS} ns
* @param {string} host */
function countPossibleThreads(ns, scriptFileName, host) {
const maxRam = ns.getServerMaxRam(host);
const freeRam = maxRam - ns.getServerUsedRam(host);
const ramCost = ns.getScriptRam(scriptFileName, host);
return Math.floor(freeRam / ramCost);
}
/**
* @param {NS} ns
* @param {string} host */
function infect(ns, host) {
grantRootAccess(ns, host);
if (ns.hasRootAccess(host)) {
ns.scp(ns.getScriptName(), host);
execScriptIfEnoughRam(ns, ns.getScriptName(), host, 1);
ns.scp(SCRIPT_ROBBER, host);
execScriptIfEnoughRam(ns, SCRIPT_ROBBER, host, 999);
}
}
Червь 3 запускает трех грабителей
Повелеваем и властвуем
Мы захватили компьютеры сети, но пока не способны ими управлять. Научим вирус получать команды по сети. Предлагаю два способа:
Вирус подключается к управляющему серверу и получает команды. Такая сеть вирусов умрет, когда умрет управляющий сервер.
Вирус получает команды от соседей. Владелец сети отдает команды любому компьютеру и команды оказываются у остальных. Такую сеть вирусов победить труднее — придется вылечить каждый компьютер.
Первый способ проще — каждый компьютер знает адрес сервера, подключается и выполняет команды.
//worm-04.js
const COMMANDS_FILE = "todo.txt";
/**
* @param {NS} ns
* @param {string} host */
function downloadCommandFile(ns, host) {
return ns.scp(COMMANDS_FILE, ns.getHostname(), host);
}
/** @param {NS} ns */
async function processCommandFile(ns) {
const lines = ns.read(COMMANDS_FILE).split(/\n|\r\n/);
for (let i in lines) {
const words = lines[i].split(' ');
if (0 < words.length) {
const command = words.shift();
await processCommand(ns, command, words);
}
}
}
/** @param {NS} ns */
export async function main(ns) {
while (true) {
let victims = ns.scan();
for (let i in victims) {
if (!isInfected(ns, victims[i]))
infect(ns, victims[i]);
}
downloadCommandFile(ns, "home");
await processCommandFile(ns);
await ns.sleep(15000);
}
}
Червь ожирел
Скрипт worm-04.js
перестал влезать в память. Функция getServer
жрет памяти больше остальных — избавимся от нее.
/**
* @param {NS} ns
* @param {string} host */
function grantRootAccess(ns, host) {
if (ns.hasRootAccess(host)) return true;
try {
ns.nuke(host);
} catch (e) {
ns.print(`Cannot grant root access on '${host}': ${e}`);
}
return ns.hasRootAccess(host);
}
Червь облегчился
Теперь научим вирус получать команды от соседей.
//worm-05.js
/** @param {NS} ns */
export async function main(ns) {
while (true) {
let victims = ns.scan();
for (let i in victims) {
const host = victims[i];
if (!isInfected(ns, host))
infect(ns, host);
downloadCommandFile(ns, host);
}
await processCommandFile(ns);
await ns.sleep(15000);
}
}
Учим крестьян знать барина в лицо
Прежде вирус знал — управляющий сервер хранит последние команды, что отдал владелец. Теперь вирус не знает, получил ли сосед последние команды или еще не успел. Пометим файлы команд номерами, чтобы отличать старые и новые.
//worm-05.js
/**
* @param {NS} ns
* @param {string} fileName */
function getCommandsFileVersion(ns, fileName) {
return parseInt(ns.read(fileName).split(/\n|\r\n/).shift());
}
todo.txt
Пусть вирус перезапишет файл команд, только когда получит следующую версию. Функция scp()
перезаписывает файлы всегда, поэтому напишем функцию downloadFile
, что сохраняет файл под другим именем.
//worm-05.js
/**
* @param {NS} ns
* @param {string} host */
function downloadCommandFile(ns, host) {
const tempFile = getTemporaryFileName();
downloadFile(ns, host, COMMANDS_FILE, tempFile);
if (getCommandsFileVersion(ns, COMMANDS_FILE) < getCommandsFileVersion(ns, tempFile))
ns.mv(ns.getHostname(), tempFile, COMMANDS_FILE);
}
function getTemporaryFileName() {
const now = new Date().getTime();
return `${TMP_DIR}/${now}.txt`;
}
/**
* @param {NS} ns
* @param {string} sourceFileName
* @param {string} destinationFileName */
function downloadFile(ns, remoteHost, sourceFileName, destinationFileName) {
const localHost = ns.getHostname();
const backup = backupFile(ns, sourceFileName);
ns.scp(sourceFileName, localHost, remoteHost);
ns.mv(localHost, sourceFileName, destinationFileName);
if (backup) ns.mv(localHost, backup, sourceFileName);
}
/**
* @param {NS} ns
* @param {string} fileName */
function backupFile(ns, fileName) {
const backupFileName = `${fileName}.backup.txt`;
ns.write(backupFileName, ns.read(fileName), "w");
return backupFileName;
}
Подпишем командный файл, чтобы вирус выполнял только команды владельца. Скрипт sign.js
подписывает файл, а verify.js
проверяет подпись. Скрипт generateKeys.js
создает пару ключей — для подписи и проверки.
//sign.js
/** @param {NS} ns */
export async function main(ns) {
if (ns.args.length < 3) {
return usage(ns);
}
const keyFileName = ns.args[0];
const dataFileName = ns.args[1];
const outputFileName = ns.args[2];
if (!assertFileExists(ns, keyFileName)) return;
if (!assertFileExists(ns, dataFileName)) return;
const pemEncodedKey = ns.read(keyFileName);
const key = await importPrivateKey(pemEncodedKey);
const data = new TextEncoder().encode(ns.read(dataFileName));
let signature = await crypto.subtle.sign(
{
name: "ECDSA",
hash: { name: "SHA-384" },
},
key,
data,
);
let output = btoa(ab2str(signature));
ns.write(outputFileName, output, "w");
ns.tprintf("%d bytes written to '%s'", output.length, outputFileName);
}
//FIXME Protect private key with a password
function importPrivateKey(pem) {
// fetch the part of the PEM string between header and footer
const pemHeader = "-----BEGIN PRIVATE KEY-----";
const pemFooter = "-----END PRIVATE KEY-----";
const pemContents = pem.substring(
pemHeader.length,
pem.length - pemFooter.length - 1,
);
// base64 decode the string to get the binary data
const binaryDerString = atob(pemContents);
// convert from a binary string to an ArrayBuffer
const binaryDer = str2ab(binaryDerString);
return crypto.subtle.importKey(
"pkcs8",
binaryDer,
{
name: "ECDSA",
namedCurve: "P-384",
},
true,
["sign"]
);
}
/*
Convert an ArrayBuffer into a string
from https://developer.chrome.com/blog/how-to-convert-arraybuffer-to-and-from-string/
*/
function ab2str(buf) {
return String.fromCharCode.apply(null, new Uint8Array(buf));
}
/*
Convert a string into an ArrayBuffer
from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
*/
function str2ab(str) {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
/** @param {NS} ns */
function usage(ns) {
ns.tprintf("Usage: $0 ");
}
/**
* @param {NS} ns
* @param {string} fileName */
function assertFileExists(ns, fileName) {
const exists = ns.fileExists(fileName);
if (!exists)
ns.tprint("ERROR: File '", fileName, "' does not exist");
return exists;
}
//verify.js
/** @param {NS} ns */
export async function main(ns) {
if (ns.args.length < 3) return usage(ns);
const publicKeyFileName = ns.args[0];
const dataFileName = ns.args[1];
const signatureFileName = ns.args[2];
if (!assertFileExists(ns, publicKeyFileName)) return;
if (!assertFileExists(ns, dataFileName)) return;
if (!assertFileExists(ns, signatureFileName)) return;
const key = await importPublicKey(ns.read(publicKeyFileName));
const message = new TextEncoder().encode(ns.read(dataFileName));
const signature = str2ab(atob(ns.read(signatureFileName)));
let result = await crypto.subtle.verify(
{
name: "ECDSA",
hash: { name: "SHA-384" },
},
key,
signature,
message,
);
ns.tprint(result ? "OK" : "INVALID");
}
/**
* @param {string} pem */
function importPublicKey(pem) {
// fetch the part of the PEM string between header and footer
const pemHeader = "-----BEGIN PUBLIC KEY-----";
const pemFooter = "-----END PUBLIC KEY-----";
const pemContents = pem.substring(
pemHeader.length,
pem.length - pemFooter.length - 1,
);
// base64 decode the string to get the binary data
const binaryDerString = atob(pemContents);
// convert from a binary string to an ArrayBuffer
const binaryDer = str2ab(binaryDerString);
return crypto.subtle.importKey(
"spki",
binaryDer,
{
name: "ECDSA",
namedCurve: "P-384",
},
true,
["verify"]
);
}
/** @param {NS} ns */
function usage(ns) {
ns.tprintf("Usage: $0 ");
}
//generateKeys.js
/** @param {NS} ns */
export async function main(ns) {
if (ns.args.length < 2) {
return usage(ns);
}
const privateKeyFileName = ns.args[0];
const publicKeyFileName = ns.args[1];
const { publicKey, privateKey } = await crypto.subtle.generateKey({
name: "ECDSA",
namedCurve: "P-384",
},
true,
["sign", "verify"],
);
await savePrivateKeyToFile(ns, privateKey, privateKeyFileName);
await savePublicKeyToFile(ns, publicKey, publicKeyFileName);
}
/**
* @param {NS} ns
* @param {CryptoKey} key
* @param {string} fileName */
async function savePrivateKeyToFile(ns, key, fileName) {
const exported = await crypto.subtle.exportKey("pkcs8", key);
const exportedAsString = ab2str(exported);
const exportedAsBase64 = btoa(exportedAsString);
const pemExported = `-----BEGIN PRIVATE KEY-----\n${exportedAsBase64}\n-----END PRIVATE KEY-----`;
ns.write(fileName, pemExported, "w");
}
/**
* @param {NS} ns
* @param {CryptoKey} key
* @param {string} fileName */
async function savePublicKeyToFile(ns, key, fileName) {
const exported = await crypto.subtle.exportKey("spki", key);
const exportedAsString = ab2str(exported);
const exportedAsBase64 = btoa(exportedAsString);
const pemExported = `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`;
ns.write(fileName, pemExported, "w");
}
Игра разрешает писать только .txt
и .js
файлы, поэтому файл подписи назовем todo.txt.sig.txt
.
run generateKeys.js keys/sign.txt keys/verify.txt
run sign.js keys/sign.txt todo.txt todo.txt.sig.txt
run verify.js keys/verify.txt todo.txt todo.txt.sig.txt
Пусть вирус перезаписывает файл команд, только если получил новую версию и подпись верна.
//worm-06.js
/**
* @param {NS} ns
* @param {string} host */
async function downloadCommandFile(ns, host) {
const tempFile = getTemporaryFileName();
downloadFile(ns, host, COMMANDS_FILE, tempFile);
const signatureFileName = getSignatureFileName(COMMANDS_FILE);
const tempSignatureFile = getTemporaryFileName();
downloadFile(ns, host, signatureFileName, tempSignatureFile);
const isSignatureValid = await verifyFileSignature(ns, PUBLIC_KEY_FILE, tempFile, tempSignatureFile);
if (isSignatureValid
&& getCommandsFileVersion(ns, COMMANDS_FILE) < getCommandsFileVersion(ns, tempFile)) {
ns.mv(ns.getHostname(), tempFile, COMMANDS_FILE);
ns.mv(ns.getHostname(), tempSignatureFile, getSignatureFileName(COMMANDS_FILE));
}
}
/**
* @param {NS} ns
* @param {string} publicKeyFileName
* @param {string} dataFileName
* @param {string} signatureFileName */
async function verifyFileSignature(ns, publicKeyFileName, dataFileName, signatureFileName) {
const pemEncodedKey = ns.read(publicKeyFileName);
const key = await importPublicKey(pemEncodedKey);
const data = new TextEncoder().encode(ns.read(dataFileName));
const signature = str2ab(atob(ns.read(signatureFileName)));
return await crypto.subtle.verify({ name: "ECDSA", hash: { name: "SHA-384" } },
key, signature, data);
}
Пусть вирус проверит подпись файла, прежде чем выполнять команды.
//worm-06.js
/**
* @param {NS} ns
* @param {string} fileName */
async function processCommandFile(ns) {
const isSignatureValid = await verifyFileSignature(
ns, PUBLIC_KEY_FILE, COMMANDS_FILE, getSignatureFileName(COMMANDS_FILE));
if (!isSignatureValid) {
ns.print("processCommandFile: invalid file signature");
return;
}
const lines = ns.read(COMMANDS_FILE).split(/\n|\r\n/).splice(1); // skip file version
for (let i in lines) {
const words = lines[i].split(' ');
if (0 < words.length) {
const command = words.shift();
await processCommand(ns, command, words);
}
}
}
Функция
crypto.subtle.verify
— асинхронная, поэтому асинхронными стали и функцииprocessCommandFile
,downloadCommandFile
,verifyFileSignature
.
Команды
Вирус выполняет такие команды:
run
запускает скриптkill
останавливает скриптsleep
спитshare
делится ресурсами жертвы с другими хакерами. Пригодится, если решите пройти игру по сюжету.
/**
*
* @param {NS} ns
* @param {string} command
* @param {string[]} args
*/
async function processCommand(ns, command, args) {
const now = new Date();
const timeStr = `[${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}] `;
ns.print(timeStr, `processCommand: ${command}`);
if ("run" == command) {
commandRun(ns, args);
} else if ("kill" == command) {
commandKill(ns, args);
} else if ("share" == command) {
await ns.share();
} else if ("sleep" == command) {
const timeout = (0 < args.length) ? parseInt(args[0]) : null;
if (timeout)
await ns.sleep(timeout);
}
}
/**
* @param {NS} ns
* @param {string[]} args */
function commandRun(ns, args) {
if (0 < args.length) {
const scriptName = args[0];
let threads = (1 < args.length) ? parseInt(args[1]) : null;
if (!threads) threads = 1;
if (!ns.isRunning(scriptName, ns.getHostname()))
execScriptIfEnoughRam(ns, scriptName, ns.getHostname(), threads);
}
}
/**
* @param {NS} ns
* @param {string[]} args */
function commandKill(ns, args) {
if (0 < args.length)
ns.scriptKill(args[0], ns.getHostname());
}
Вызов ns.share()
отнимает у вируса 2.40GB
памяти — вынесем ns.share()
в отдельный скрипт share.js
. Вирус менее заметен, когда жрет меньше памяти. Поэтому мы вынесли ns.grow()
, ns.weaken()
и ns.hack()
в robber.js
.
SCRIPT_SHARE = "share.js";
/**
* @param {NS} ns
* @param {string} host */
function infect(ns, host) {
grantRootAccess(ns, host);
if (ns.hasRootAccess(host)) {
ns.scp(ns.getScriptName(), host);
execScriptIfEnoughRam(ns, ns.getScriptName(), host, 1);
ns.scp(COMMANDS_FILE, host);
ns.scp(getSignatureFileName(COMMANDS_FILE), host);
ns.scp(PUBLIC_KEY_FILE, host);
ns.scp(SCRIPT_ROBBER, host);
ns.scp(SCRIPT_SHARE, host);
}
}
/**
*
* @param {NS} ns
* @param {string} command
* @param {string[]} args
*/
async function processCommand(ns, command, args) {
const now = new Date();
const timeStr = `[${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}] `;
ns.print(timeStr, `processCommand: ${command}`);
if ("run" == command) {
commandRun(ns, args);
} else if ("kill" == command) {
commandKill(ns, args);
} else if ("share" == command) {
ns.run(SCRIPT_SHARE);
} else if ("sleep" == command) {
const timeout = (0 < args.length) ? parseInt(args[0]) : null;
if (timeout)
await ns.sleep(timeout);
}
}
share.js
Команда share
подорожала на 1.60GB
, но вирус похудел. Мы экономим память, если share
вызывают редко.
Заключение
BitBurner
— для тех, кто любит программировать. Игра не ограничивает фантазию игрока — умеет все, что умеет JavaScript.
Забавно, что вызовы ns
требуют памяти, но другие функции JavaScript — шифрования, кодирования, даты и времени — скрипт вызывает на халяву. Игра оштрафует скрипт на 25.00GB
только когда он обратится к window
:
//sign.js
export async function main(ns) {
//...
let signature = await window.crypto.subtle.sign(
//...
}
[home /]> mem sign.js
This script requires 26.70GB of RAM to run for 1 thread(s)
25.00GB | window (dom)
1.60GB | baseCost (misc)
0.10GB | fileExists (fn)
//sign.js
export async function main(ns) {
//...
let signature = await crypto.subtle.sign(
//...
}
This script requires 1.70GB of RAM to run for 1 thread(s)
1.60GB | baseCost (misc)
0.10GB | fileExists (fn)
Исходный код BitBurner
на GitHub
Файлы к статье
BitBurner
в Steam
Играйте с пользой!