Run KVM-backed virtual machines with encrypted disk images with virsh

On the importance of storing data encrypted at rest

One of the most relevant concerns about IT security when maintaining an on-premise infrastructure is the risk of a data breach. With the rise of business malware (specially ransomware), these kind of attacks are more common than ever before, so it is a good practice to establish as many defense techniques as you have available. Here, the proposed solution includes two technologies that will allow to store data safely, by encrypting data at rest and using it in a virtualized environment. Although this solution is not perfect, it could be part of a more complete and resilient solution. The main motivation for this effort is to have a virtual machine which runs on an encrypted disk image. This way, data is safely stored and encrypted on-the-fly with a completely new set of keys.

Introduction

In order to run a virtual machine a hypervisor is needed. Usually, hypervisors are not managed directly, since they are usually complex pieces of software with a lot of configuration parameters, such as virtual disk devices, network devices and device controllers among other things like AppArmor. Instead there are other pieces of software that allows the user to configure the host and the virtual machine, specifying only the required options. In this case, the application that we will use to manage the virtual machines will be virsh, which is part of the libvirt project. In a nutshell, the libvirt project is a toolkit to manage virtualization platforms, allowing to manage several hypervisors (such as QEMU and KVM, the ones that we will use) under the same APIs and commands. Usually, creating a virtual machine using libvirt is extremely simple. The command virt-install will accept all the parameters required to provision a complete standard virtual machine. For example, the following command will create the required components to launch a virtual machine:

sudo virt-install --name vmname --memory 4096 --disk disk.qcow2,device=disk,bus=virtio --cdrom osimage.iso --os-variant ubuntu22.04 --virt-type kvm --graphics spice --network bridge:virbr0

After running that command, a window will appear where the OS can be installed into the disk image. This requires manual configuration, since the software is the same that the one that is used to install a physical machine. Nevertheless, there is an alternative that allows the automation of the deployment of virtual machines. In this case, a cloud image will be used instead of a blank image and a disk image. In this case, the command will resemble something similar to this:

sudo virt-install --name vmname --memory 4096 --disk disk.qcow2,device=disk,bus=virtio --os-variant ubuntu22.04 --virt-type kvm --graphics spice --network bridge:virbr0 --import

Please note the --import flag. This will indicate to virt-install that the image contains a bootable disk image. For this, a cloud image should be used and converted with qemu-convert if needed.

This command creates all the necessary components to launch a virtual machine. libvirt comprises several concepts that allows to model a virtual machine. Here below is a non-extensive list of them:

  • Domains: which are the virtual machines itself. This is a collection of all the parts that are used to virtualize the environment, such as hardware configuration, disks, networks...
  • Networks: this will model the connection between the virtual machines among them and the host.
  • Secrets: values that are used in several places, configuration files and other
  • Pools: locations where images are stored. They can be file-based, network-based, partition-based, iSCSI-based...
  • Volumes: those are the disks itself within a pool, and they can be mounted as disks within the virtual machine.

Usually, virt-install takes care of creating all the parts required to spin the virtual machine. But using this piece of software reduces the options that libvirt provides.

libvirt configuration

The idea then is to create all the components manually in order to configure the encryption. libvirt uses XML files as configuration files, with specific tags for each component type. The process here will be:

  1. Generate the encrypted base image in order to be used as a root volume
  2. Create a secret, which will store the encryption keys
  3. Create a domain which uses this volume
  4. Start the domain.

Generating the encrypted image

First of all, a secure encryption key is needed. To be secure, it is recommended to generate a truly random key, openssl offers a cli utility which uses the random linux device to generate such number. Then, this key should be converted to base64 and stored in the tmp directory to use it with qemu, Also, it should be safely stored whenever it is possible (I would recommend to store it in a password manager, such as the excelent KeePassXC). For that, the following command can be used:

openssl rand -rand /dev/urandom 32 | base64 | tee /tmp/enc.key

Then, the file contents should be something similar to this: q1VdyUVI/qn7V1nPnm1rORqz96x0Q4OxtQMO8ejHhbM=.

After generating the key, the image should be encrypted with the key. For that, qemu-img will be used. Qemu allows the use of objects as a method to pass extra data to the program. This is done by using the --object command flag and a string with the required configuration to set the object. In this situation, a secret object is required. So, in this case an Ubuntu 22.04 cloud image, downloaded from https://cloud-images.ubuntu.com/jammy/) will be used. The command will look something similar to this:

qemu-img convert --object secret,id=sec0,file=/tmp/master.key -f qcow2 jammy-server-cloudimg-amd64.img -O qcow2 -o "encrypt.format=luks,encrypt.key-secret=sec0" root-crypt.qcow2

As you can see, the --object flag indicates that a secret is required to run the command, establishing an id to reference it later and the contents of the secret itself, that will be read from the file. In the output options format (the -o argument), it is specified that the image should have LUKS encryption with a secret identified by sec0. This will generate the image encrypted with proper LUKS headers that can be used with qemu.

Also, the image can be resized by using the same qemu-img utility, but the secret parameters are passed a little bit different:

qemu-img resize --object secret,id=sec0,file=/tmp/enc.key --image-opts "encrypt.key-secret=sec0,file.filename=root-crypt.qcow2" +10G

Finally, we have the image completely configured as we need.

The secret

Secrets in libvirt are configuration entities whose data should be kept stored securely inside libvirt. This includes private keys for authentication or encryption among other uses.

The first step is to define a secret, and for that, the following XML can be used:

<secret ephemeral='yes' private='yes'>
    <description>My super secret LUKS passphrase</description>
    <uuid>489049ca-5115-40ac-b9d6-795d1ab23468</uuid>
    <usage type="volume">
        <volume>{path-to-image}/root-crypt.qcow2</volume>
    </usage>
</secret>

Please note that there are two parameters in the secret tag. Ephemeral indicates that the secret won't be stored persistently, which increases the security of the key since it won't be saved on disk. Private indicates that once the key value is set it won't be shared with anyone, this is, libvirt callers or other libvirt nodes.

After that, the secret value should be set, and for that, there is the secret-set-value option of virsh.

virsh secret-set-value 489049ca-5115-40ac-b9d6-795d1ab23468 --file /tmp/enc.key --plain

Please note the --plain argument. This is required for libvirt not to decode the base64 beforehand. This is required since the key that Qemu used to encrypt the image was also the plain file.

Domain definition

The easiest way to create a fully working domain is to gather an example from the web or by using virt-install and then dumping the XML, since there is a little bit of boilerplate here. With this base, some modifications can be done in order to use the encrypted image with this machine. Here is a sample domain XML of a machine with networking and serial console, mandatory to use the domain through the console completely, without virtual graphics.

<domain type='kvm'>
    <name>vmtest</name>
    <metadata>
        <libosinfo:libosinfo xmlns:libosinfo="http://libosinfo.org/xmlns/libvirt/domain/1.0">
            <libosinfo:os id="http://ubuntu.com/ubuntu/22.04" />
        </libosinfo:libosinfo>
    </metadata>
    <memory unit='GiB'>4</memory>
    <vcpu placement='static'>2</vcpu>
    <resource>
        <partition>/machine</partition>
    </resource>
    <os>
        <type arch='x86_64' machine='pc-q35-6.2'>hvm</type>
        <boot dev='hd'/>
    </os>
    <cpu mode='host-passthrough' check='none' migratable='on' />
    <devices>
        <emulator>/usr/bin/qemu-system-x86_64</emulator>
        <disk type='file' device='disk'>
            <driver name='qemu' type='qcow2' />
            <source file="{path-to-image}/root-crypt.qcow2" index='2' />
            <target dev='vda' bus='virtio' />
            <encryption format='luks'>
                <secret type="passphrase" uuid="489049ca-5115-40ac-b9d6-795d1ab23468" />
            </encryption>
        </disk>
        <controller type='virtio-serial' index='0'>
            <alias name='virtio-serial0' />
            <address type='pci' domain='0x0000' bus='0x03' slot='0x00' function='0x0' />
        </controller>
        <serial type='pty'>
            <source path='/dev/pts/5' />
            <target type='isa-serial' port='0'>
                <model name='isa-serial' />
            </target>
            <alias name='serial0' />
        </serial>
        <console type='pty' tty='/dev/pts/5'>
            <source path='/dev/pts/5' />
            <target type='serial' port='0' />
            <alias name='serial0' />
        </console>
        <interface type='bridge'>
            <source bridge='virbr0' />
            <target dev='enp0s1' />
            <model type='virtio' />
            <alias name='net0' />
            <address type='pci' domain='0x0000' bus='0x01' slot='0x00' function='0x00' />
        </interface>
    </devices>
</domain>

Let's examine the disk section further.

<disk type='file' device='disk'>
    <driver name='qemu' type='qcow2' />
    <source file="{path-to-image}/root-crypt.qcow2" index='2' />
    <target dev='vda' bus='virtio' />
    <encryption format='luks'>
        <secret type="passphrase" uuid="489049ca-5115-40ac-b9d6-795d1ab23468" />
    </encryption>
</disk>

Here, the encryption related configurations are added. libvirt will take care of starting Qemu with the proper secret loaded, spinning up the virtual machine. Finally, only two commands are required for creating the virtual machine.

virsh define domain.xml virsh start vmtest

Now the virtual machine is running. We can access it by using the virsh console vmtest command if the kernel is configured to use the serial device (which ubuntu cloudimages do).

And that is it! Any questions or comments please reach me through my social networks or mail.