Игрушечный ботнет на JavaScript под BitBurner

Размножаемся

Игра дает нам программу NUKE.EXE, которая взламывает компьютер и получает права администратора. Программа поможет вирусу захватывать компьютеры.

Иногда NUKE.EXE требует, чтобы компьютер-жертва открыл сетевые порты. Позже научимся взламывать порты, а пока ограничимся жертвами, что поддаются NUKE.EXE и без открытых портов.

Взлом компьютера требует навыков — хакерского уровня. NUKE.EXE не взломает компьютер, если уровень ниже требуемого. Вы повышаете уровень, когда взламываете компьютеры и учитесь в университете.

Sector-12

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 размножается

Червь 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 запускает грабителя

Червь 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 запускает трех грабителей

Червь 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

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.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

Играйте с пользой!

© Habrahabr.ru