diff options
| author | Lena <lena@omega> | 2026-06-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-06-01 00:00:00 +0000 |
| commit | 9a0e81b32808cf0d6060f10cef66c400550eb5f4 (patch) | |
| tree | 417cb2a2a3a6ff5f3f09140ec8549f869fcec737 | |
| download | win11-qemu-install-9a0e81b32808cf0d6060f10cef66c400550eb5f4.tar.gz | |
Installing Windows 11 under QEMU by hand means clicking through all of
Setup and OOBE and bypassing the TPM and Secure Boot checks every time.
Automating what can be automated makes a throwaway lab VM reproducible.
| -rw-r--r-- | README | 133 | ||||
| -rwxr-xr-x | win11-qemu-install | 232 |
2 files changed, 365 insertions, 0 deletions
@@ -0,0 +1,133 @@ +win11-qemu-install +================== +Mostly-unattended install of Windows 11 onto a disk image under QEMU/KVM. +The script generates an Autounattend.xml, hands it to Windows Setup on a +virtual floppy, and runs QEMU against the install ISO and a target disk +created beforehand with qemu-img. The answer file presets the en-US locale, +bypasses the TPM 2.0, Secure Boot, and minimum RAM checks via LabConfig, +creates one local administrator, and auto-logs in once. The pages it leaves +alone (EULA, edition, target disk) are clicked once over VNC. + +Whatever disk is picked on Setup's disk page is wiped. + + +Requirements +------------ + - qemu-system-x86_64 with KVM and seccomp sandbox support + - a POSIX shell, sed, id, and mktemp + - a Windows 11 install ISO and a disk image made with qemu-img + - a VNC client such as remote-viewer for the Setup pages + - optionally the VirtIO driver ISO + +Run as an unprivileged user with access to /dev/kvm. The script refuses to +run as root. + + +Usage +----- +Create a disk, then run the install: + + qemu-img create -f qcow2 win11.qcow2 64G + ./win11-qemu-install win11.iso win11.qcow2 + +QEMU runs in the foreground. Connect a VNC client and click through the +pages the answer file leaves alone: + + remote-viewer vnc://127.0.0.1:5900 + +A third argument names the VirtIO driver ISO. The install disk is then +attached over VirtIO, which is faster: + + ./win11-qemu-install win11.iso win11.qcow2 virtio-win.iso + +Setup shows an empty disk list until viostor is loaded by hand: + + Load driver -> virtio CD -> viostor -> w11 -> amd64 + +Without it the disk is attached over SATA/AHCI and Setup sees it with no +extra drivers. The same driver ISO covers network and balloon after the +install. + + +Configuration +------------- +Environment variables, all optional. + + - RAM_MB guest memory in MB during install (default 8192) + - SMP guest CPU count during install (default 8) + - IMAGE_FORMAT disk image format: qcow2 or raw (default qcow2) + - LOCAL_USER administrator account name (default user) + - LOCAL_PASS administrator account password (default 123456) + +Example: + + RAM_MB=16384 SMP=4 LOCAL_USER=admin LOCAL_PASS=hunter2 \ + ./win11-qemu-install win11.iso win11.qcow2 + + +Networking +---------- +The installer runs with -nic none. With no way to reach Microsoft, OOBE +settles for a local account instead of forcing an online one, and restarting +inside Setup changes nothing. Networking arrives when the installed image is +booted afterwards, with a NIC model Windows has an in-box driver for: + + qemu-system-x86_64 -enable-kvm -machine q35 -cpu host -m 8192 \ + -drive file=win11.qcow2,if=ide \ + -nic user,model=e1000e \ + -vnc 127.0.0.1:0 -usb -device usb-tablet + +Use if=virtio for the drive if the install ran in VirtIO mode. virtio-net +stays dead until NetKVM is installed from the driver ISO; e1000e works out +of the box. + + +Quirks +------ +The windowsPE pass is minimal on purpose. Automating the EULA, edition, and +disk pages with UserData, DiskConfiguration, and DriverPaths makes the +Windows 11 24H2 setup engine abort right after boot ("Windows installation +encountered an unexpected error", extend code 0x40031), so those pages are +left to the VNC session. + +The answer file is written to a per-run mktemp directory (mode 0700, under +TMPDIR), reaches the guest as fat:floppy:, and is removed by an EXIT trap. +SIGKILL and host failure cannot run the trap; remove stale +win11-qemu-install.* directories from TMPDIR after either. + +QEMU runs as a background command so HUP, INT, and TERM stop it and then +clean up, exiting 129, 130, or 143. In the foreground the trap would not run +until QEMU had already exited. + +LOCAL_USER and LOCAL_PASS are XML-escaped, so values containing & < > " ' +are safe. + + +Debugging +--------- +Watch the install over VNC. While QEMU runs the generated answer file can be +read at win11-qemu-install.*/Autounattend.xml under TMPDIR. + + - "must not contain a comma": QEMU separates drive options with commas; + use a path without one. + - Black VNC screen: Setup is still booting. + - Empty disk list in Setup: load the VirtIO driver as shown above, or + rerun without the VirtIO ISO for the SATA/AHCI path. + - "installation encountered an unexpected error" right after boot: the + answer file automates more of windowsPE than the setup engine accepts. + - KVM errors on start: check that /dev/kvm exists and is accessible. + + +Security +-------- +The default password is 123456 and the account is a local administrator, so +this is for throwaway lab VMs. Set LOCAL_PASS for anything kept. VNC has no +authentication and any local host user can reach the guest console; it is +bound to 127.0.0.1, and the QEMU monitor, serial port, and guest network are +not exposed. + +The answer file holds the password in plain text in the 0700 temp dir. QEMU +refuses to run as root and runs under its seccomp sandbox with privilege +elevation, process spawning, obsolete system calls, and resource-control +changes denied, but it still has the invoking user's file access. Use a +dedicated unprivileged account when installing untrusted media. diff --git a/win11-qemu-install b/win11-qemu-install new file mode 100755 index 0000000..8426f09 --- /dev/null +++ b/win11-qemu-install @@ -0,0 +1,232 @@ +#!/bin/sh +set -eu + +# win11-qemu-install WINDOWS_ISO VM_DISK [VIRTIO_WIN_ISO] +# +# Mostly-unattended install of Windows 11 onto VM_DISK, a disk image created +# beforehand with qemu-img. The answer file presets the language, bypasses the +# TPM, Secure Boot, and RAM checks, and automates OOBE. Setup's remaining pages +# (EULA, edition, target disk) are clicked once over VNC on 127.0.0.1:5900. + +[ "$#" -eq 2 ] || [ "$#" -eq 3 ] || { echo "usage: $0 WINDOWS_ISO VM_DISK [VIRTIO_WIN_ISO]" >&2; exit 64; } + +[ "$(id -u)" -ne 0 ] || { echo "error: refusing to run QEMU as root" >&2; exit 1; } + +WIN_ISO=$1 +VM_DISK=$2 +VIRTIO_ISO=${3:-} +[ -r "$WIN_ISO" ] || { echo "error: WINDOWS_ISO not readable: $WIN_ISO" >&2; exit 1; } +[ -r "$VM_DISK" ] && [ -w "$VM_DISK" ] || { echo "error: VM_DISK must be readable and writable: $VM_DISK" >&2; exit 1; } +[ -z "$VIRTIO_ISO" ] || [ -r "$VIRTIO_ISO" ] || { echo "error: VIRTIO_WIN_ISO not readable: $VIRTIO_ISO" >&2; exit 1; } + +RAM_MB=${RAM_MB:-8192} +SMP=${SMP:-8} +IMAGE_FORMAT=${IMAGE_FORMAT:-qcow2} +LOCAL_USER=${LOCAL_USER:-user} +LOCAL_PASS=${LOCAL_PASS:-123456} + +validate_positive_integer() { + case $2 in + ''|*[!0-9]*|0) echo "error: $1 must be a positive integer: $2" >&2; exit 1 ;; + esac +} + +reject_comma() { + case $2 in + *,*) echo "error: $1 must not contain a comma: $2" >&2; exit 1 ;; + esac +} + +validate_positive_integer RAM_MB "$RAM_MB" +validate_positive_integer SMP "$SMP" +case $IMAGE_FORMAT in + qcow2|raw) ;; + *) echo "error: IMAGE_FORMAT must be qcow2 or raw: $IMAGE_FORMAT" >&2; exit 1 ;; +esac +reject_comma WINDOWS_ISO "$WIN_ISO" +reject_comma VM_DISK "$VM_DISK" +reject_comma VIRTIO_WIN_ISO "$VIRTIO_ISO" +reject_comma TMPDIR "${TMPDIR:-/tmp}" + +command -v qemu-system-x86_64 >/dev/null || { echo "error: need qemu-system-x86_64" >&2; exit 1; } + +xml_escape() { + sed \ + -e 's/&/\&/g' \ + -e 's/</\</g' \ + -e 's/>/\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\'/g" +} + +LOCAL_USER_XML=$(printf '%s' "$LOCAL_USER" | xml_escape) +LOCAL_PASS_XML=$(printf '%s' "$LOCAL_PASS" | xml_escape) + +# The answer file carries LOCAL_PASS in plain text; mktemp gives a 0700 dir. +WORK=$(mktemp -d "${TMPDIR:-/tmp}/win11-qemu-install.XXXXXX") +QEMU_PID= +trap 'rm -rf "$WORK"' EXIT + +# QEMU runs in the background so a trapped signal is handled while it is +# still alive; in the foreground the trap only runs once QEMU has exited. +# QEMU shuts down on TERM, so that is what gets sent on whatever arrived. +stop() { + trap - HUP INT TERM + if [ -n "$QEMU_PID" ]; then + kill "$QEMU_PID" 2>/dev/null || : + wait "$QEMU_PID" 2>/dev/null || : + fi + exit "$1" +} +trap 'stop 129' HUP +trap 'stop 130' INT +trap 'stop 143' TERM + +# The windowsPE pass stays minimal on purpose: LabConfig bypasses and +# language only. Automating the EULA, edition, and disk pages with UserData, +# DiskConfiguration, and DriverPaths makes the Windows 11 24H2 setup engine +# abort right after boot (extend code 0x40031), so those pages are clicked +# once over VNC instead. +cat <<EOF >"$WORK/Autounattend.xml" +<?xml version="1.0" encoding="utf-8"?> +<unattend xmlns="urn:schemas-microsoft-com:unattend" + xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"> + <settings pass="windowsPE"> + <component name="Microsoft-Windows-International-Core-WinPE" + processorArchitecture="amd64" + publicKeyToken="31bf3856ad364e35" + language="neutral" + versionScope="nonSxS"> + <SetupUILanguage> + <UILanguage>en-US</UILanguage> + </SetupUILanguage> + <InputLocale>0409:00000409</InputLocale> + <SystemLocale>en-US</SystemLocale> + <UILanguage>en-US</UILanguage> + <UserLocale>en-US</UserLocale> + </component> + + <component name="Microsoft-Windows-Setup" + processorArchitecture="amd64" + publicKeyToken="31bf3856ad364e35" + language="neutral" + versionScope="nonSxS"> + <RunSynchronous> + <RunSynchronousCommand wcm:action="add"> + <Order>1</Order> + <Description>Create LabConfig</Description> + <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /f</Path> + </RunSynchronousCommand> + <RunSynchronousCommand wcm:action="add"> + <Order>2</Order> + <Description>Bypass TPM</Description> + <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path> + </RunSynchronousCommand> + <RunSynchronousCommand wcm:action="add"> + <Order>3</Order> + <Description>Bypass Secure Boot</Description> + <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path> + </RunSynchronousCommand> + <RunSynchronousCommand wcm:action="add"> + <Order>4</Order> + <Description>Bypass RAM check</Description> + <Path>cmd /c reg add HKLM\\SYSTEM\\Setup\\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path> + </RunSynchronousCommand> + </RunSynchronous> + </component> + </settings> + + <settings pass="oobeSystem"> + <component name="Microsoft-Windows-International-Core" + processorArchitecture="amd64" + publicKeyToken="31bf3856ad364e35" + language="neutral" + versionScope="nonSxS"> + <InputLocale>en-US</InputLocale> + <SystemLocale>en-US</SystemLocale> + <UILanguage>en-US</UILanguage> + <UserLocale>en-US</UserLocale> + </component> + + <component name="Microsoft-Windows-Shell-Setup" + processorArchitecture="amd64" + publicKeyToken="31bf3856ad364e35" + language="neutral" + versionScope="nonSxS"> + <OOBE> + <HideOnlineAccountScreens>true</HideOnlineAccountScreens> + <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE> + <HideEULAPage>true</HideEULAPage> + <ProtectYourPC>3</ProtectYourPC> + </OOBE> + + <UserAccounts> + <LocalAccounts> + <LocalAccount wcm:action="add"> + <Name>${LOCAL_USER_XML}</Name> + <DisplayName>${LOCAL_USER_XML}</DisplayName> + <Group>Administrators</Group> + <Password> + <Value>${LOCAL_PASS_XML}</Value> + <PlainText>true</PlainText> + </Password> + </LocalAccount> + </LocalAccounts> + </UserAccounts> + + <AutoLogon> + <Enabled>true</Enabled> + <Username>${LOCAL_USER_XML}</Username> + <LogonCount>1</LogonCount> + <Password> + <Value>${LOCAL_PASS_XML}</Value> + <PlainText>true</PlainText> + </Password> + </AutoLogon> + </component> + </settings> +</unattend> +EOF + +echo 'use: remote-viewer vnc://127.0.0.1:5900' + +# WinPE scans floppy drives for Autounattend.xml on its own; a vvfat floppy +# holding the single file is the simplest way to hand it over. usb-tablet +# gives the VNC pointer absolute positioning. No NIC: with no way to reach +# Microsoft, OOBE settles for a local account instead of forcing an online +# one. +set -- qemu-system-x86_64 \ + -enable-kvm \ + -machine q35 \ + -cpu host \ + -smp "$SMP" \ + -m "$RAM_MB" \ + -boot order=d \ + -sandbox on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny \ + -drive file="$WIN_ISO",format=raw,media=cdrom,if=ide,index=1 \ + -drive if=floppy,file="fat:floppy:$WORK",format=raw,readonly=on \ + -vnc 127.0.0.1:0 \ + -monitor none \ + -serial none \ + -parallel none \ + -usb \ + -device usb-tablet \ + -nic none + +if [ -n "$VIRTIO_ISO" ]; then + echo "virtio mode: enabled; when Setup's disk list is empty:" + echo " Load driver -> virtio CD -> viostor -> w11 -> amd64" + set -- "$@" \ + -drive file="$VM_DISK",format="$IMAGE_FORMAT",if=virtio \ + -drive file="$VIRTIO_ISO",format=raw,media=cdrom,if=ide,index=2 +else + echo "virtio mode: disabled; disk attached over SATA/AHCI, no extra drivers" + set -- "$@" \ + -device ich9-ahci,id=sata \ + -drive file="$VM_DISK",format="$IMAGE_FORMAT",if=none,id=systemdisk \ + -device ide-hd,drive=systemdisk,bus=sata.0 +fi + +"$@" & +QEMU_PID=$! +wait "$QEMU_PID" |