Lab Kiosk OS & Edge SaaS

Kiosk Hardening

Everything that stands between a curious user and a shell. Each layer assumes the others may fail, which is why there are so many of them.


Layer 1 — Immutable storage

Keyboard and mouse

Three layers, because each one only catches what the layer below it let through.

  1. Openbox binds nothing. rc.xml has an empty <keyboard> section and a Root mouse context with no menu, and it is installed to ~/.config/openbox/rc.xml and /etc/xdg/openbox/rc.xml — the two paths openbox-session actually reads. Shipping it to /etc/openbox/rc.xml alone left Alt+Tab, Alt+F4, Super+E and the right-click desktop menu working on every installed machine.
  2. The keys are taken off the keyboard. labkiosk-lock-keys rewrites the X keymap at session start so every F key, both Super keys, the menu key, Print Screen, Pause, Scroll Lock, Insert and the XF86 media block carry no symbol at all. Nothing can bind a key that produces nothing — not even Chromium's built-in accelerators. Re-run it after any keyboard-layout change.
  3. The extension refuses the rest. Every Ctrl/Alt/Meta combination, every non-printable key outside Backspace, Delete, Enter, Shift, Caps Lock, Tab, Escape and the cursor/page keys, and the context menu, are blocked in the capture phase, in every frame.

The single exception is the clipboard (Ctrl+A/C/V/X/Z/Y) on the setup wizard's own loopback origin, so an administrator can paste the enrolment key. No page a user can reach has that origin.

/etc/overlayroot.conf:

overlayroot="tmpfs:recurse=0"

recurse=0 has to be part of the value. overlayroot reads only the overlayroot and overlayroot_cfgdisk variables from this file, so a separate overlayroot_options= line does nothing and leaves recurse at its default of 1 — which overlays every fstab entry with a RAM upper layer, LABKIOSK_DATA included, and quietly loses every enrolment at reboot.

The real root filesystem is mounted read-only, with a tmpfs overlay on top. Every write — browser cache, agent logs, downloads, user files, session state — lands in RAM and is gone at power-off.

This holds on live media and on installed disks. An installed disk boots the very same filesystem.squashfs the ISO carries, copied into its image store, through live-boot with the same overlay. Two consequences follow:

  • Zero flash wear. Thin-client SSDs as small as 12 GB with limited write cycles are never written to during operation.
  • Every boot is a clean boot. Nothing a user does survives a reboot, so there is no persistence for malware, no accumulated profile corruption, and no stale configuration.

The single exception on an installed disk is /etc/labkiosk, mounted from the LABKIOSK_DATA partition so that an enrolment survives. → Disk Installer

toram is opt-in

auto/config's --bootappend-live contains no toram. The default entry boots with overlayroot=tmpfs and reads the squashfs from the medium as it goes. The boot menu additionally offers Lab Kiosk OS (Load into RAM - toram), which copies the whole image into RAM first — pick that when the USB stick should be removable after boot, and expect it to need more RAM than the image size.


Layer 2 — No route to a shell

ControlWhereEffect
root lockedpasswd -l rootNo root login by any path
getty@tty1..6 maskedsystemctl maskCtrl+Alt+F1…F6 reaches nothing
serial-getty@ maskedsystemctl maskNo serial console login
debug-shell.service maskedsystemctl maskNo systemd emergency shell
DontVTSwitch "true"/etc/X11/xorg.conf.d/10-kiosk-lockdown.confX refuses VT switching
DontZap "true"same fileCtrl+Alt+Backspace cannot kill X
Empty rc.xml keybindings/etc/openbox/rc.xmlAlt+Tab, Alt+F4, Ctrl+Alt+Del are inert
No SSH serverpackage listNothing listens for remote login

Why the kiosk account has an empty password, not a locked one

passwd -d "$KIOSK_USER" is deliberate and must stay. A locked kiosk account previously deadlocked nodm's PAM stack into a black screen on boot.

What makes an empty password safe here is that no login path exists to use it: nodm is pinned to NODM_USER=kiosk and starts a session for a fixed user with no password to check, every getty is masked, and no SSH server is installed. There is no prompt anywhere that would accept it.

kiosk holds two narrow sudo grants — NOPASSWD on /usr/local/bin/labkiosk-install (/etc/sudoers.d/50-labkiosk-install), which is what lets the setup wizard run the guided installer, and on /usr/local/sbin/labkiosk-localization (/etc/sudoers.d/51-labkiosk-localization) for language and region. There is no general sudo access: live-config's sudo and policykit components, which would otherwise grant the live user NOPASSWD: ALL and every polkit action at every boot, are pre-seeded away — on the ISO and on installed disks, which boot through live-boot too. labkiosk-boot-slots, which chooses the image an installed disk boots, has no sudo rule at all.

Polkit power policy

/etc/polkit-1/rules.d/50-labkiosk-power.rules grants kiosk reboot and power-off through logind, and nothing else. That grant is what makes the operator's remote shutdown command work: the agent runs as kiosk, so without it the command would be accepted and then silently do nothing.


Layer 3 — Kernel hardening

/etc/sysctl.d/99-kiosk-lockdown.conf:

kernel.sysrq = 0              # No magic SysRq key
kernel.dmesg_restrict = 1     # Unprivileged users cannot read the kernel ring buffer
kernel.kptr_restrict = 2      # Kernel pointers hidden from everyone
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
fs.suid_dumpable = 0

Core dumps are disabled outright in /etc/security/limits.conf (* hard core 0, * soft core 0).


Layer 4 — Chromium managed policy

The browser runs --kiosk with a wiped profile on every launch, under an enterprise policy at /etc/chromium/policies/managed/policies.json.

Deny-all, then re-permit

"URLBlocklist": [
  "chrome://*", "edge://*", "about:flags", "about:version",
  "javascript://*", "view-source:*", "file://*",
  "http://*", "https://*"
]

Everything is denied, and the organization's URLAllowlist re-permits exactly its own domains plus its portal apps. view-source: is listed explicitly because DeveloperToolsAvailability does not cover it and the http/https entries do not match it.

The rest of the static policy

KeyValueWhy
DeveloperToolsAvailability2DevTools disabled entirely
IncognitoModeAvailability1Incognito disabled
DownloadRestrictions3All downloads blocked
AllowFileSelectionDialogsfalseWithout this an upload control still opens a filesystem browser over the kiosk — a file manager a user otherwise has no route to
PrintingEnabledfalse
PasswordManagerEnabled, AutofillAddressEnabled, AutofillCreditCardEnabledfalseNothing is retained between users
BrowserSignin0No Google sign-in
SyncDisabledtrue
DefaultSearchProviderEnabledfalseAn allowlisted kiosk has nowhere to search to
DefaultGeolocationSetting, DefaultNotificationsSetting2Deny without prompting — a kiosk has nobody to answer a permission prompt, and a modal would sit above the lock curtain
HardwareAccelerationModeEnabledtrueThin clients need it

Audio and video capture are deliberately *not* blocked. Language labs and video pages legitimately need the microphone and camera; taking them away breaks real room use.

One home for the policy

The static policy is declared exactly once, in usr/share/labkiosk/chromium-policy-base.json. Two consumers generate from it — the build hook 01-lockdown.hook.chroot, and sync_chromium_policies() in agent.py — and neither may carry its own copy of those keys. When the keys were declared twice, anything added to one and not the other silently vanished the moment a workstation enrolled.

# CI asserts the committed generated file matches its base
python3 distro-builder/tools/generate-chromium-policy.py --check

Never hand-edit the generated etc/chromium/policies/managed/policies.json.

Never add a blanket extension block

ExtensionInstallBlocklist: ["*"] makes Chromium refuse --load-extension altogether — silently removing the kiosk's own navigation bar and lock curtain. → Browser Extension


Layer 5 — In-page controls

The MV3 extension traps F12, Ctrl+Shift+I/J/C, Ctrl+U, F11, and right-click, and while the lock curtain is up it swallows every mouse, keyboard, and touch event in the capture phase. Its UI lives in a closed Shadow DOM so page script cannot reach it.

→ Browser Extension


Securing the boot chain

All of the above assumes an untampered kernel invocation. Someone with physical access who can edit the GRUB command line and append init=/bin/sh bypasses every layer at once.

The boot-menu password

A workstation always boots completely unattended. Every menu entry is marked --unrestricted — applied unconditionally by 02-security.hook.chroot, and written into every entry of the installed disk's boot menu (usr/share/labkiosk/boot/grub.cfg, a test checks it) — so powering on goes straight to the kiosk with no prompt. The password is asked for only when someone presses e to edit an entry, or c for the GRUB shell.

Do not commit a hash for an image you ship to more than one customer. A hash compiled into the ISO is one boot-menu password shared by every deployment that image produced: a leak at any single site compromises all of them, it cannot be rotated on machines already in the field, and grub.pin is tracked in git, so the hash is permanent in history and open to offline cracking by anyone who can read the repository.

Route 1 — per installation (the default)

The setup wizard collects the password at install time and derives the PBKDF2 digest in the browser. Each site, or each workstation, gets its own password, and the shipped ISO carries no secret at all. → Disk Installer

Route 2 — per-customer ISO

Supply the hash in the build environment rather than committing it. This also locks the live USB menu:

docker run --rm -it ghcr.io/akbhoi/labkiosk-iso-builder grub-mkpasswd-pbkdf2 -c 200000

docker run --privileged --rm \
  -e LABKIOSK_GRUB_PBKDF2="grub.pbkdf2.sha512.200000.YOUR.HASH" \
  -v "$PWD/distro-builder/out:/build/out" \
  ghcr.io/akbhoi/labkiosk-iso-builder

LABKIOSK_GRUB_PBKDF2 takes precedence over grub.pin, which remains as a fallback for a single organisation building an image for its own lab.

Where each mechanism applies

TargetMechanismSet by
Installed diskboot/grub/labkiosk-password.cfg on LABKIOSK_ROOT, outside every system image so updates keep it, sourced by the installed grub.cfgThe wizard, per installation (Route 1)
Live ISO, UEFIconfig/bootloaders/grub-pc/labkiosk-password.cfg, sourced by config.cfgauto/config from LABKIOSK_GRUB_PBKDF2 or grub.pin (Route 2)
Live ISO, legacy BIOSALLOWOPTIONS 0 + NOESCAPE 1 in config/bootloaders/*/stdmenu.cfg — no password needed, syslinux discards any kernel argument typed at the promptstatic config, always

With no hash supplied the build prints a note and produces an editable live menu — the correct default for a shipped product, because installed workstations get their password from Route 1. If a hash is supplied but is not a grub.pbkdf2.sha512. value, the build fails rather than shipping an image whose menu is unprotected in a way nobody noticed.

Other boot-chain settings

GRUB_TIMEOUT=3
GRUB_CMDLINE_LINUX_DEFAULT="consoleblank=0"
GRUB_DISABLE_RECOVERY="true"

The installed disk's boot menu is the static usr/share/labkiosk/boot/grub.cfg rather than one generated from these settings, and it matches them: timeout=3, consoleblank=0 on every kernel line, and no recovery entry. Recovery mode is a root shell by design and has no place on a kiosk. consoleblank=0 — rather than quiet loglevel=3 — is what resolved the black-screen boot deadlock; suppressing boot logs made a PAM autologin failure invisible.

BIOS / UEFI firmware

Software cannot defend against someone who can simply boot something else. After installing:

  1. Set a strong Supervisor/Administrator password in the workstation firmware.
  2. Set internal storage as the sole boot target.
  3. Disable the boot-device menu (F12) and USB booting.

Build pins fail closed

usr/share/labkiosk/grub.pin holds a value that cannot be verified from the repository — a password hash.

PinUnsetWrong
grub.pinBuilds with a loud warning, live menu editableBuild fails

Never invent a value to make a build go green.


What this does not defend against

Stated plainly, because a hardening page that claims completeness is worse than useless:

  • Physical disassembly. Anyone who can remove the drive can read it. Nothing on it is secret except an enrolment token, which can be revoked from the dashboard in one click.
  • A network-level attacker. The client trusts its control plane. HTTPS and the device token protect the channel; a compromised control plane can point the kiosk anywhere the allowlist permits.
  • A stolen operator session. Remote Control is protected by the console's sign-in, the workstations permission and a one-time session token, not by the 8-character RFB secret. Whoever holds an operator session with that permission can open a live desktop. → Remote Control

→ Security Model · Disk Installer · Building the ISO

This page is wiki/Kiosk-Hardening.md in the repository.