Image internals¶
How the kiosk image is made and protected. To build it, test it, install it and maintain it, see Building the image, Testing with QEMU, Initial installation and System updates.
The image is built with mkosi from Debian 13 (trixie) packages.
Partitions¶
The disk image has two system slots (A/B updates) and a data partition:
| Partition | Filesystem | Content |
|---|---|---|
| ESP | vfat, 512 MB | systemd-boot and the UKIs (pandora-box_<version>.efi), all signed |
root, slot A (pandora-box_<version>) |
EROFS, read-only, 1 GB | the whole system but /var (about 400 MB used) |
| root-verity, slot A | 64 MB | dm-verity hash tree of the root filesystem |
| root-verity-sig, slot A | 16 KB | signature of the root hash |
root, root-verity, root-verity-sig, slot B (_empty) |
same sizes | the next version (A/B updates) |
data (pandora-box-data) |
ext4, 1 GB in the image | /var: scan reports, quarantine, logs, signed signature sets (clamd reads its databases there) |
The version of the image is the date and time of its build, UTC, like the
serial of a signature set: 20261005143700 (image/build.sh sets it,
--image-version=N overrides it). It is in /etc/os-release
(IMAGE_VERSION), in the labels of its partitions (pandora-box_<version>,
_verity, _veritysig: 36 characters at most) and in the name of its UKI.
The root filesystem is read-only by design (EROFS cannot be written at all):
the system and its configuration only change with a new image. Everything
the kiosk writes is in /var. The data partition is the last one: at every
boot, systemd-repart grows it to fill the disk
(/usr/lib/repart.d/20-var.conf), then systemd-growfs grows its
filesystem (x-systemd.growfs in /etc/fstab), so the image can be written
to a disk of any size. As /etc is read-only, the machine ID is generated
again at every boot (a new journal folder per boot, within the journald
size limits).
The image contains:
- the kernel, systemd and udev, Python 3 and clamd (
clamav-daemon), with no network service:systemd-networkd,systemd-resolved, freshclam and the ClamAV on-access scanner are disabled (10-pandora-box.preset); - pandora-box in
/usr/lib/pandora-box(/usr/bin/pandora-box), its configuration in/etc/pandora-box/pandora-box.tomland its service, which draws the graphical touch interface on the first console (framebuffer, see Screen); - no
loginprogram and no root password: no interactive session is possible.
Signing, Secure Boot and dm-verity¶
The boot chain is verified from the firmware to every block of the system:
- with Secure Boot, the firmware only starts systemd-boot if it is signed
with a key of its
db, and systemd-boot only starts a signed UKI; - the UKI holds the kernel, the initrd and the kernel command line, which
contains the dm-verity root hash (
roothash=): changing it breaks the signature; - the root filesystem is opened with dm-verity: every block read is checked against the hash tree, whose root is that hash. A modified block cannot be read (I/O error, "data block ... is corrupted" in the kernel log);
- with Secure Boot, the Debian kernel enables its lockdown mode
(
integrity): even root cannot modify the running kernel.
/var is not protected by dm-verity: it holds the data of the kiosk, never
programs or configuration.
The image is signed with image/mkosi.key and image/mkosi.crt (systemd-boot,
UKI, root hash). When they do not exist, image/build.sh generates a
development key pair, valid 10 years, that never leaves the developer's
computer (ignored by git). A kiosk must only trust the release keys of the
project, which are not defined yet: they will be kept out of the repository,
and mkosi can sign with a key in a hardware token
(--secure-boot-key-source=provider:pkcs11).
The image also trusts the signature sets signed with image/update.key: its
public key image/update.pem is installed in /usr/share/pandora-box/keys/
(under dm-verity). image/build.sh generates a development pair when they
do not exist; the test profile signs its signature set with it. See
Signatures.
On a kiosk, the certificate is enrolled in the firmware in place of the
Microsoft keys (PK, KEK and db), so that it starts nothing else. The ESP
contains the keys in the format expected by the firmware
(loader/keys/auto/): with the firmware in Secure Boot setup mode (keys
cleared in the firmware setup), systemd-boot enrolls them at the first boot
(secure-boot-enroll force). Enrolling only the project certificate also
refuses the option ROMs signed by Microsoft: a computer whose display or
disk controller needs one (a graphics card, for instance) would not start
it. The virtual machines enroll the certificate with virt-fw-vars instead
(image/vm/machine.py).
systemd-boot has no menu (/efi/loader/loader.conf, timeout
menu-disabled): it starts the default entry, the newest version not marked
bad, and does not read the keyboard. A keyboard plugged into the kiosk
cannot select another version, edit the kernel command line (refused with
Secure Boot anyway), power off the machine or reboot into the firmware
setup. The ESP is not part of the image updates: this file stays as
installed.
Screen¶
The kiosk draws its graphical interface into the framebuffer of the graphics
driver (/dev/fb0), without a display server or a browser, and puts the
first console (tty1) in graphics mode so that the kernel does not draw text
over it: see interface.md. The screen of the reference
hardware is a Waveshare 7-inch HDMI LCD (1024x600). The firmware of the
ThinkCentre starts in 1024x768, and the Intel driver keeps that mode: the
bottom of the interface would be off the screen. The kernel command line
therefore sets the mode of the screen, video=1024x600M@60 (CVT timings, as
Waveshare documents them); with another screen, change it in
image/mkosi.conf. The Intel driver still keeps the framebuffer of the
firmware, large enough for that mode: the kiosk only draws its visible part,
the size of video=, and follows a change of the framebuffer size. It logs
the size and the pixel format it draws for (display_started).
The curses interface (kiosk.interface = "curses", administrator mode) draws
text on the console instead: it shrinks the console to the screen of
video= (8x16 font: 37 lines, 128 columns) the same way, and logs the size
it draws for (display_started, display_resized).
USB devices¶
A kiosk must only use USB storage devices: a key that is also a keyboard (BadUSB) could type commands, a network adapter could open a network.
- USBGuard (
/etc/usbguard/rules.conf) allows hubs and the devices whose interfaces are all mass storage; it blocks every other device, including a key that has a storage interface and another one. Blocked devices are listed in/var/log/usbguard/usbguard-audit.log. - No device is authorized before USBGuard has started: the kernel
command line sets
usbcore.authorized_default=0. If USBGuard does not start, no USB device is usable. - Drivers removed from the image (
KernelModulesExclude=inimage/mkosi.conf): USB network adapters, wireless, Bluetooth, USB serial adapters and modems, plus the staging drivers and batman-adv, which would bring the wireless modules back as dependencies. Such devices have no driver even if they were authorized. - USB HID is kept for the touchscreen of the reference hardware, a Waveshare 7-inch HDMI LCD (C), allowed by its own rule:
allow id 0eef:0005 serial "220211" name "WS170120" with-interface 03:00:00 via-port "1-8"
its USB touch controller (eGalax) with its single generic HID interface (a
keyboard has 03:01:01), on the internal port of the enclosure, this unit
only. A device that copies its identifiers on another port, or that adds a
keyboard interface, is blocked; so is every keyboard and mouse. Another
unit or port: adapt the rule to the line the audit log shows when it is
blocked. The kernel (mousedev) turns a touch into a left click on
/dev/input/mice, which the kiosk reads to confirm a cleaning.
The test image allows the touch tablet of the virtual machine (QEMU
usb-tablet, same interface) in its own rule
(/etc/usbguard/rules.d/50-vm-test.conf): the test confirms a cleaning
with a touch. The keyboard of the virtual machines is not a USB device.
System hardening¶
After the ANSSI configuration recommendations for GNU/Linux systems:
- Kernel settings (
/usr/lib/sysctl.d/90-pandora-box.conf): kernel logs and addresses hidden, no ptrace, no kexec, no unprivileged eBPF, user namespaces or userfaultfd, io_uring disabled, no SysRq, no core dumps, protected links and FIFOs, no IP forwarding or redirects. - Kernel command line: memory initialized on allocation and free, no
slab merging, randomized page allocator and kernel stack, no legacy
vsyscall, no debugfs, lockdown in
confidentialitymode (Secure Boot only enablesintegrity: even root cannot read kernel memory), IOMMU in strict mode and no DMA before the kernel starts (DMA attacks through ports). - Firewall (
/etc/nftables.conf): every packet is dropped, in and out, except on the loopback interface. - No login: no login console on the screens (
getty@andgetty-staticdisabled), nologinprogram, no root password, Ctrl-Alt-Del masked. - clamd sandbox (
clamav-daemon.service.d/50-pandora-box.conf): no network, read-only system, no device, system call filter,MemoryDenyWriteExecute(the Debian clamd interprets bytecode signatures, without JIT); it runs as theclamavuser. - Kiosk service (
packaging/systemd/pandora-box.service): read-only system, system call filter, limited capabilities. It keepsAF_NETLINK(udev events), the capabilities needed to start the worker sandbox (namespaces, change of user, loopback interface of the workers) and cannot haveMemoryDenyWriteExecuteorRestrictSUIDSGID: the first one would also apply to the YARA-X workers, which compile rules to native code, the second one refuses a system call bubblewrap needs. - Kernel modules removed: network protocols the kiosk never uses (DCCP,
SCTP, RDS, TIPC) and FireWire, another way to reach the memory of the
kiosk, besides the USB network, Bluetooth and Wi-Fi drivers. The protocols
and FireWire are also listed in
/etc/modprobe.d/pandora-box.conf, where the auditing tools look for them. - Audit log (
/etc/audit/audit.rules, auditd): loading of kernel modules, mounts (except those of systemd and bubblewrap), every program started, ptrace, changes of the clock, of the signature sets and of the staged image updates. The rules are locked (-e 2): changing them needs a reboot. They are loaded byauditctl -R(augenruleswrites into/etc, read-only). The log is in/var/log/audit/audit.log, for instanceausearch -k signatures -i.
The test profile includes lynis, run by the automated test: hardening index
73, no warnings. Its remaining suggestions do not apply to a kiosk without
login (password policy, banners, package tools, fail2ban) or come later
(remote logging, phase 5).
Scan worker sandbox¶
The scan workers parse hostile files with several engines (libmagic, YARA-X,
the clamd client...): they are assumed compromisable (scan.sandbox,
src/pandora_box/sandbox.py).
- One engine per worker: a compromised engine (a crafted file
exploiting YARA-X or libmagic) cannot forge the result of another engine.
The kiosk hashes each file itself, asks the worker of each hash engine
(MalwareBazaar, Hashlookup) with the hashes only, then passes the file to
a worker of each content engine (ClamAV, YARA-X), and combines their
results itself; a worker only reports the result of its own engine.
Content engines have
scan.workersworkers each, hash engines one.scan.workers = "auto"(the default) scans one file per CPU at the same time, as many as the memory allows (1.5 GB for clamd and the system, then 256 MB each, for the YARA rules mostly): 6 on the ThinkCentre M720q (6 cores, 8 GB), 1 or 2 on a computer with 2 GB. - No access to the device: the kiosk opens each file safely (no link followed, the file of the inventory) and passes the open descriptor to the workers of the content engines, which never open a file of the device; the workers of the hash engines never get the file.
- bubblewrap: new PID, IPC, UTS, cgroup and network namespaces (no
network interface but loopback); a file system holding only
/usr, a few files of/etc, the signature folders of the enabled engines (read-only), the YARA-X cache and the clamd socket folder: no device mount point, no reports, quarantine, logs or configuration. - Dedicated user
pandora-box-scan(packaging/sysusers/), switched to bysetpriv: no capability, no supplementary group,no_new_privs. - System call filter (
src/pandora_box/seccomp.py), installed once the engines are loaded: no program execution, debugging, mounts, namespaces, eBPF, kernel modules, io_uring, network sockets... - Untrusted answers: workers answer in JSON (never pickle), limited in
size and validated (
src/pandora_box/protocol.py); the path and size of the file always come from the kiosk. A malformed answer kills the worker and the file is reported as an error.
bubblewrap runs as root (the kiosk service), so unprivileged user namespaces stay disabled.
The virtual machine test checks these settings and prints the exposure of
the services measured by systemd-analyze security (0 to 10, lower is
better): about 1.6 for clamd, 3.8 for the kiosk (which starts the worker
sandbox), 2.8 for USBGuard.
Unified kernel image¶
The UKI (about 40 MB) holds the kernel, the initrd of the image
(image/mkosi.images/initrd/, without the udev hardware database, charset
conversions, Perl, documentation and networkd) and an initrd of kernel
modules. That one only holds what mounts the root filesystem
(KernelModulesInitrdInclude= in image/mkosi.conf): disk controllers
(NVMe, SATA, Intel VMD, eMMC, virtio), dm-verity, EROFS and CRC32C, which
EROFS loads by name. The other modules are loaded from the root filesystem.
A computer whose disk controller is not in this list cannot find its root
filesystem: add its module there.