Skip to content

How It Works

Melvin PETIT edited this page Jun 17, 2026 · 1 revision

How It Works

This page documents what Bull actually does, end to end, at the level of functions, files, and on-disk artifacts. If Architecture is the map, this is the walkthrough.

Everything below traces real code in lib/ and configs/. Function names are given so you can read along in the source.

The create lifecycle

bull create flows through cmd_create (in bull.sh) into create_vm() (lib/vagrant.sh:1199). Step by step:

  1. Resolve inputs. Username defaults to the OS default via _os_default_user() (kali for Kali, user for Parrot). Password is taken from --password-file (read once, then the file is truncated), --password (deprecated), or generated with openssl rand (24 chars).
  2. Pre-flight checks. ensure_dependencies() offers to install anything missing. Then validate_vm_name() (letter-led, alphanumeric+hyphen, ≤11 chars), validate_positive_int() for RAM/CPU, a duplicate check against the inventory, and check_disk_space().
  3. Cache the box. ensure_vagrant_box() (lib/vagrant.sh:310) makes sure the Vagrant box for the chosen OS exists locally, offering to download it if not. Parrot has a dedicated path (_ensure_parrot_box, OVA/qcow2 handling and patching) because its box is not a standard Vagrant artifact.
  4. Build the VM directory. get_vm_dir() resolves ${BULL_HOME}/vms/<name>. It is created chmod 700 because the Vagrantfile will briefly hold the plaintext password.
  5. Generate the Vagrantfile. generate_vagrantfile() (lib/vagrant.sh:1089) renders configs/Vagrantfile.template (see below).
  6. Copy the provisioner. copy_provision_script() copies configs/<os>-provision.sh to <vm-dir>/provision.sh.
  7. Record in inventory. inventory_add() writes the VM with status: not_created before boot, so a crash mid-boot still leaves a tracked, cleanable record.
  8. Boot. vagrant up --provider=$BULL_PROVIDER runs in the VM directory, output teed to both the terminal and <vm-dir>/vagrant-up.log. The shell provisioner runs inside the guest as root.
  9. Post-boot fixups. Status flips to running, the IP is fetched and stored. On libvirt, _fix_spice_cursor() rewrites the domain XML to kill the SPICE double-cursor. On VirtualBox, a two-pass resolution apply runs (setvideomodehint host-side + xrandr guest-side).
  10. Sanitize. The Vagrantfile is regenerated with an empty password slot, the credential is GPG-encrypted to disk, and the plaintext is shown once on screen (with consent) then wiped from memory.

If any step before boot fails, the VM directory is removed and the inventory entry rolled back.

Vagrantfile templating

configs/Vagrantfile.template is a static Ruby file with %%PLACEHOLDER%% tokens. generate_vagrantfile() substitutes them with a single sed pass and writes the result chmod 600.

Placeholder Source
%%VM_NAME%%, %%RAM%%, %%CPU%% create arguments
%%PROVIDER%% BULL_PROVIDER
%%BOX%% _os_to_box()
%%USERNAME%%, %%PLAIN_PASSWORD%% credentials (password empty after sanitize)
%%KEYBOARD%%, %%RESOLUTION%% layout / display
%%SKIP_UPGRADE%% BULL_SKIP_UPGRADE env
%%TOOLKIT_URLS%%, %%EXTRA_PACKAGES%% optional extras
%%UEFI%%, %%OVMF_LOADER%%, %%OVMF_VARS%% Parrot UEFI boot

Key things the template does:

  • Disables the synced folder (config.vm.synced_folder ".", "/vagrant", disabled: true) so the guest cannot reach the host filesystem.
  • Disables clipboard, drag-and-drop, and audio on VirtualBox.
  • Configures per-provider graphics: VMSVGA + 128 MB VRAM on VirtualBox; SPICE + QXL + a virtio-serial channel and a USB tablet on libvirt (the tablet sends absolute coordinates, fixing cursor offset).
  • For Parrot (%%UEFI%% == true), points libvirt at the OVMF loader/nvram so it boots under UEFI instead of SeaBIOS, and sets the SSH user to user.
  • Passes credentials and settings to the provisioner via the env: block, not the command line, so they never appear in the guest's process list.

UEFI vs SeaBIOS for Parrot

Parrot uses a GPT + EFI layout and will not boot under SeaBIOS. generate_vagrantfile() checks that /usr/share/OVMF/OVMF_CODE_4M.fd and OVMF_VARS_4M.fd are readable; if so it enables UEFI, otherwise it falls back to SeaBIOS with a warning. BULL_FORCE_UEFI=1 forces UEFI regardless. The VARS file has several possible names across distros, so Bull probes .fd, .ms.fd, and .snakeoil.fd in order.

Inside the guest: provisioning

provision.sh (from configs/kali-provision.sh / parrot-provision.sh) runs as root inside the VM and reads its inputs from the BULL_VM_* environment variables set by the Vagrantfile. The hardening it performs:

  1. User creation. Creates ${BULL_VM_USER} if absent and adds it to sudo.
  2. Password. Sets it with chpasswd (plaintext piped in) — the most reliable method across Kali versions regardless of the default hashing scheme. BULL_VM_PASS is unset immediately after.
  3. Encrypt /home. Installs ecryptfs-utils, generates a 32-char random passphrase, wraps it with ecryptfs-wrap-passphrase into the user's .ecryptfs/, and adds it to the keyring.
  4. SSH key propagation. Copies Vagrant's insecure authorized key from /home/vagrant/.ssh to the custom user so bull connect works as that user.
  5. Lock the default account. passwd -l kali (or the Parrot equivalent) locks the distro's shipped account.
  6. Passwordless sudo. Writes /etc/sudoers.d/bull-<user> with NOPASSWD:ALL (pentest convenience), chmod 440.
  7. Keyboard layout. Applied through seven mechanisms for robustness across display managers: /etc/default/keyboard, debconf preseed, dpkg-reconfigure, an X11 xorg.conf.d snippet, /etc/xprofile, the XFCE xfconf channel, a system-wide XDG autostart running setxkbmap, and localectl.
  8. Resolution. Installs /usr/local/bin/bull-set-resolution.sh, which detects the connected output and the active X session's XAUTHORITY cookie, then forces the mode with xrandr (creating the modeline if needed). LightDM display/session setup scripts re-apply it at login.

The Kali and Parrot scripts share this structure; Parrot's differs where its desktop and boot layout require it.

Credential lifecycle

The plaintext password exists in exactly three transient places, and is removed from each:

  1. In create_vm's memory — wiped (plain_password="") right after it is used.
  2. In the Vagrantfile (%%PLAIN_PASSWORD%%) — needed so the provisioner can chpasswd. After boot, generate_vagrantfile re-runs with an empty slot, overwriting it. The file is chmod 600 the whole time.
  3. In the guest's environment (BULL_VM_PASS) — unset immediately after chpasswd.

For persistence, _credentials_save() (lib/core.sh) GPG-encrypts the password (AES256, SHA512, 65M S2K iterations) to <vm-dir>/.credentials.gpg, while <vm-dir>/.credentials holds only the username. The GPG key is a per-user key under ${BULL_HOME}/.gnupg (chmod 700), generated on first use with an empty passphrase. bull show-pass calls _credentials_show() which decrypts on demand. See the Security Model.

Inventory data model

The inventory is a single JSON file at data/inventory.json (relative to the install, not committed). Each VM record:

{
  "name": "kali-lab",
  "os": "kali",
  "status": "running",
  "ip": "192.168.122.50",
  "created": "2026-06-17T12:00:00Z",
  "ram": "4096",
  "cpu": "2",
  "resolution": "1920x1080",
  "snapshots": [],
  "vpn_configured": false,
  "vm_dir": "/root/.bull/vms/kali-lab"
}

All mutations go through inventory_*() functions in lib/inventory.sh, which build the new JSON with jq and write it atomically (mktemp in the same directory, then mv) so a crash never leaves a half-written file. status moves not_created → running → poweroff over a VM's life.

bull sync (inventory_sync()) reconciles the file with reality: for each VM it reads vagrant status --machine-readable, updates the stored status, and refreshes the IP from hostname -I when running. Use it after changing a VM outside Bull.

Provider abstraction internals

BULL_PROVIDER is set once in core.sh (libvirt if /dev/kvm exists, else VirtualBox, overridable via the environment), and VAGRANT_DEFAULT_PROVIDER is exported so Vagrant never enumerates providers — this sidesteps a Vagrant 2.4.x Hyper-V detection crash on WSL2.

All Vagrant calls go through ${VAGRANT_CMD} (vagrant, or vagrant.exe on WSL2+VirtualBox). The libvirt-specific helpers (_libvirt_uri, _libvirt_domain, _libvirt_start/stop/destroy, _libvirt_get_ip, _libvirt_ensure_network, _libvirt_ssh) wrap virsh so the rest of the code does not care which hypervisor is underneath. The SPICE cursor fix (_fix_spice_cursor) and the VirtualBox path/health helpers (_ensure_vbox_in_windows_path, _verify_vbox_functional, _repair_vbox_services) are the only places that reach into provider-specific internals.

See Architecture for the high-level structure and Troubleshooting for what to do when one of these steps fails.

Clone this wiki locally