kiosk-exit-guard v1.2.5

Single-binary kiosk lockdown for Windows 11 Home and Windows Server 2022 (RDP / physical console). Embedded WebView2 kiosk, password-gated keyboard, Chrome uninstalled, Edge launches blocked at the OS level, self-installing, self-updating, all from one ~7.9 MB exe.

Download kiosk-exit-guard.exe
Latest release · Windows 11 Home + Server 2022 · amd64 · ~7.9 MB · unsigned · UAC consent required on first launch
Source on GitHub · all releases
Download integrity — kiosk-exit-guard.exe.sha256 sidecar. Every release publishes a tiny companion file alongside the exe (e.g. kiosk-exit-guard.exe.sha256) containing the SHA-256 hash of that release's binary as 64 hex characters on one line.

v1.2.5 — UI audit, zoom-target fix, stale-shortcut cleanup

UI/UX audit pass for unusual viewports (sideways 4K TVs at 300% display scaling, low-DPI 1080p landscape, etc.), plus a zoom-target fix so kiosk pages can't double-zoom on top of the v1.2.4 admin-configured zoom, plus stale-shortcut cleanup for upgrades from <v1.2.4.

v1.2.4 — usable admin surface (shortcuts, scheduled tasks, AnyDesk hotkey)

Desktop shortcuts no longer fire UAC and no longer fail with "Access is denied" for non-admin kiosk users. v1.2.4 introduces a shortcut → scheduled task → spawn-into-session indirection: each .lnk now points at schtasks.exe /Run /TN KioskExitGuard_<Action>, the task runs as NT AUTHORITY\SYSTEM with HighestAvailable, and the task body invokes a new --shortcut-handler <flag> entry that spawns the action's UI into the active user's session via the same elevated-token logic the Service uses. Shortcuts also moved from the per-user desktop to CommonDesktopDirectory so every logged-in user sees them.
Kiosk page now has a configurable default zoom (50–200%, persisted in HKLM). The first-run wizard grew a Default page zoom (%) field next to the URL input, and the "Change Kiosk URL" shortcut now also prompts for a zoom percent. runWebViewKiosk reads the persisted value and injects an IIFE inside the existing w.Init block that sets document.documentElement.style.zoom = pct / 100 on both DOMContentLoaded and load — covering the initial paint and any SPA re-render that overwrites inline styles on <html>. Default is 100%; existing installs without the registry key behave exactly as before. Setting 90 once persists across kiosk respawns, pause/resume cycles, and auto-updates.
Ctrl+Shift+Alt+K now triggers the password modal even when the keystroke is forwarded by AnyDesk, AutoHotkey, or any other SendInput-based remote tool. Before v1.2.4 the LL keyboard hook ignored all injected events wholesale, so an admin remoting into a fullscreen kiosk via AnyDesk had no way to reach the pause flow without physical keyboard access. v1.2.4 carves out exactly one combo from the ignore-injected rule. Every other injected event still falls straight through unmodified — AnyDesk typing, paste, mouse macros, etc. keep working as before.

v1.2.3 — service-spawn elevation hotfix

Companion hotfix to v1.2.2. With the WebView2 data-directory ACL fixed, the next layer of the Win 11 breakage surfaced: the Windows Service spun in a 2-second respawn loop, logging service: spawnControllerInSession(1) failed: CreateProcessAsUser: The requested operation requires elevation. every tick. No controller meant no kiosk window, no keyboard hook, and no filter enforcement. v1.2.3 makes the spawn succeed by handing CreateProcessAsUser the user's elevated linked token instead of the filtered one.

v1.2.2 — WebView2 data-directory hotfix

Fixes a v1.1.8 regression that broke every fresh install on Windows 11 where the kiosk account wasn't an Administrator. On launch — first-run wizard, kiosk page, password modal, toast, or the v1.2.1 auto-update notify — WebView2 popped "Microsoft Edge can't read and write to its data directory: C:\ProgramData\KioskExitGuard\WebView2\EBWebView — We couldn't create the data directory." v1.2.2 restores access in one line of ACL.

v1.2.1 — automatic update check + interactive prompt

The controller now tells you when a new version is out. Background runAutoUpdateChecker goroutine polls GitHub's /releases/latest on startup (after a 60 s settle delay) and every 24 h thereafter. When a newer version is published it spawns a branded WebView2 modal — "An SK Filter update was pushed. Update to v{newver} now?" — with [Update Now] / [Remind Me Later] buttons. Update Now hands off to the existing --update flow (password modal, SHA-256 verify, atomic swap, Service restart); Remind Me Later defers to the next 24 h tick. The notification is purely additive — admins who prefer to drive the update themselves via the desktop shortcut don't lose anything.

v1.2.0 — consolidation + audit fixes

One CRITICAL struct-layout bug + three HIGH install/update issues + medium UX gaps closed. v1.2.0 is the consolidation tag for the v1.1.5–v1.1.11 burst plus the audit pass that ran on top of it.

v1.1.11 — Server 2022 RDP session-id fix

The kiosk now actually spawns the controller on a headless RDP'd Server 2022. Field discovery: the affected install was Windows Server 2022 accessed over RDP, not (only) Win11 Home. quser reports administrator rdp-tcp#0 ID 2 Active while WTSGetActiveConsoleSessionId() returns the empty physical-console session ID 1. The supervisor hardcoded sessionID = WTSGetActiveConsoleSessionId() and never asked "is anyone else logged in elsewhere?" — no controller ever spawned, the kiosk was unprotected after every reboot. Fix: new pickActiveUserSession() in service_windows.go walks WTSEnumerateSessionsW, calls WTSQuerySessionInformationW(WTSUserName) on every WTSActive session, and picks the console session if it qualifies (preserves Win11 / laptop behavior) or the lowest-numbered other WTSActive session with a logged-in user (Server 2022 / RDP). supervisorLoop logs service: spawning controller in session N (state=Active, user logged in) so admins reading kiosk-exit-guard.log can see which session won. Plus three smaller items: restartExplorer reads HKLM\...\Winlogon\Shell and skips the kill / restart when the registered shell isn't explorer.exe (Server Core / custom-shell setups); IFEO removal and Chrome uninstall silently absorb "not installed" instead of logging confusing errors on fresh Server 2022 boxes; and the --update flow drops the "Checking GitHub for updates…" toast and the standalone confirm — the password modal's subtitle mentions the new version number, combining confirm + auth into one screen. See the changelog for per-finding root cause + fix.

Recent hardening (v1.1.8 – v1.1.10)

Three releases tightening the install, update, and service-spawn paths into one narrative arc.

For the full release-by-release history, including the v1.1.0 Service supervisor introduction and the v1.1.1 – v1.1.7 stabilization burst that preceded the v1.1.8 audits, see the changelog.

Features

Automatic update checkv1.2.1

Background runAutoUpdateChecker goroutine polls GitHub's /releases/latest on startup (after a 60 s settle delay) and every 24 h thereafter. Network errors silently logged; admins reading kiosk-exit-guard.log see every check outcome (on latest (v%s) or new version v%s available). Only runs in the long-lived controller — every short-lived flag invocation returns before the goroutine launches.

Interactive update promptv1.2.1

When a newer release is published, the controller spawns a branded WebView2 modal — "An SK Filter update was pushed. Update to v{newver} now?" — with [Update Now] / [Remind Me Later]. Update Now hands off to the existing --update flow (password modal, SHA-256 verify, atomic swap, Service restart). 60 s auto-dismiss, Global\KioskExitGuardAutoUpdateNotify mutex prevents stacking, fullscreen topmost via the same makeModalFullscreenTopmost+forceForeground idiom as the password modal.

Server 2022 RDP supportv1.1.11

Supervisor walks WTSEnumerateSessionsW and picks the lowest-numbered WTSActive session with a logged-in user (verified via WTSQuerySessionInformationW(WTSUserName)), preferring the console session when it qualifies. Win11 / laptop behavior unchanged; Server 2022 / RDP newly supported.

Update UI: confirm + auth in one screenv1.1.11

"Checking GitHub for updates…" toast and the separate "download and install?" zenity.Question are gone. Password modal's subtitle mentions the new version number, combining confirm + auth into one screen. The "you're on v%s. No update available." branch is kept so clicking Update is never silent.

restartExplorer respects the registered shellv1.1.11

Reads HKLM\...\Winlogon\Shell and skips the taskkill /F /IM explorer.exe when the registered shell isn't explorer.exe (Server Core, custom shells). NoTaskbar policy still gets written; it just won't take effect until next logon.

WTS process enumerationv1.1.10

Cross-session process enumeration uses WTSEnumerateProcessesExW from wtsapi32.dll (Win32's service-designed API) instead of gopsutil's userland snapshot, which was returning empty on some installs when called from Session 0. Candidate list broadened to explorer.exe · sihost.exe · taskhostw.exe · RuntimeBroker.exe · StartMenuExperienceHost.exe, each validated against its canonical %SystemRoot% path via QueryFullProcessImageName.

30 s password-modal auto-dismissv1.1.9

A user who pressed Ctrl+Shift+Alt+K, saw the modal, then walked away used to leave the fullscreen password screen up forever. askPasswordModalInProcess arms time.AfterFunc(30*time.Second, ...); kgSubmit / kgCancel reset the timer so an actively-typing user isn't yanked mid-attempt.

Controller mutex stops logon kiosk blinkv1.1.9

v1.1.4 co-installed both Service and AtLogon task; at logon both fired within ~1 s and the loser was killed by the winner's killRunningController. New Global\KioskExitGuardControllerRunning named mutex: second mover logs and os.Exit(0)s cleanly. No respawn loop, no kiosk blink.

Exe relocated to %ProgramFiles%v1.1.8

First-run copies the exe to %ProgramFiles%\KioskExitGuard\ and re-execs from there before registering the Service binary path, scheduled task, or desktop shortcuts. SCM-registered path now lives in an admin-only directory — a kiosk user can no longer swap the binary and have the supervising Service respawn attacker code as LocalSystem.

--update SHA-256 verified + admin-only stagingv1.1.8

Downloaded exe stages under %ProgramData%\KioskExitGuard\staging\ (DACL via icacls /inheritance:r) instead of %TEMP%; SHA-256 verified against a kiosk-exit-guard.exe.sha256 sidecar asset published by the release workflow. Mismatch aborts with a zenity.Error.

HKLM hash DACL admin-onlyv1.1.8

Default HKLM\Software inherits BUILTIN\Users:KEY_READ — any local user could read the bcrypt hash and crack it offline. SetNamedSecurityInfo(SE_REGISTRY_KEY, SDDL=D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)) on every controller startup grants only SYSTEM + Administrators; existing installs heal on first launch of v1.1.8.

WebView2 profile under %ProgramData%v1.1.8

All four webview2.NewWithOptions call sites (password modal, toast, first-run wizard, kiosk page) share %ProgramData%\KioskExitGuard\WebView2\ with the same locked-down DACL. The default %LOCALAPPDATA% profile was kiosk-user-writable — a poisoned service worker could have intercepted the password modal's kgSubmit host-object call.

Service + scheduled task co-installedv1.1.4

Both auto-start mechanisms registered on install. Service is the in-session respawn supervisor (LocalSystem in Session 0). Scheduled task is the AtLogon fallback when WTSQueryUserToken + the v1.1.3 user-session-process fallback both fail. Closes the "filter only runs when I re-click the exe" failure mode.

Zoom shortcuts allowedv1.1.5

Ctrl+0, Ctrl+-, Ctrl++ / Ctrl+= (plus numpad variants) pass through to the kiosk WebView2 page so admins / kiosk visitors can adjust zoom without entering the password. Ctrl-only — Win+0 / Alt+- still hit the lockdown.

WebView2 kiosk window

The kiosk page lives in this exe, not a Chrome subprocess. Fullscreen, topmost, frameless, JS-locked to refuse navigation away from the kiosk URL. Context menus, dev tools, status bar, zoom, and NewWindowRequested all disabled / rejected.

Windows Service supervisor

KioskExitGuardSvc runs as LocalSystem in Session 0 and respawns the user-session controller via CreateProcessAsUserW. Kiosk user can't reach SCM, so they can't stop the supervisor.

Password-gated keyboard hook

LL keyboard hook captures every Ctrl/Win/Alt combo plus Win-alone, opens the SK Filter password modal. Correct password re-injects the original combo via SendInput with a per-process random ExtraInfo nonce. Wrong password / cancel = swallowed. Ctrl+R, F5, and Ctrl-zoom combos pass through.

Chrome uninstalled on first run

Reads Chrome's UninstallString from the registry and runs the official uninstaller with --force-uninstall. Clean removal, idempotent if Chrome isn't installed.

Edge + accessibility helpers IFEO-blocked

HKLM\…\Image File Execution Options\msedge.exe\Debugger redirects launches to kiosk-exit-guard --silent-exit. Same for chrome.exe and the accessibility helpers (sethc, osk, narrator, utilman, magnify) that close the Sticky-Keys-5x-Shift and Ease-of-Access escapes. Re-applied on every controller launch so a Windows Update can't strip them.

UAC elevation

Embedded manifest. Every manual launch requests admin consent. The Service spawns the controller via CreateProcessAsUserW with the user's linked-elevated token so logon launches are silent.

HKLM password storage

Bcrypt hash in HKLM\Software\KioskExitGuard\PasswordHash. Admin-write only — a standard kiosk user can't bypass by deleting a file. DACL is tightened to SYSTEM + Administrators on every controller startup (v1.1.8+).

Pause hotkey + desktop shortcut

Ctrl+Shift+Alt+K or the "Pause SK Filter" desktop shortcut. Password → duration picker (1 / 5 / 10 / 20 / 30 / 45 min or Custom 1–100). Edge becomes launchable, HKCU lockdown lifts, kiosk window closes. Auto-resume when the timer expires.

Registry policy lockdown

Filter active: DisableTaskMgr, NoRun, NoTrayContextMenu, NoViewContextMenu, NoTaskbar all set to 1 under HKCU. Cleared during pause or by --reset.

Self-update with SHA-256 integrity check

"Update SK Filter" shortcut hits GitHub's releases/latest, downloads the matching kiosk-exit-guard.exe.sha256 sidecar asset (64-hex digest), recomputes the SHA-256 of the freshly-downloaded exe and aborts on mismatch, then atomic-renames the running exe (.old rollback target) and restarts the Service. v1.2.0 makes a missing sidecar prompt default-No instead of silently proceeding.

What the password modal looks like

How it works

One binary; the first command-line argument selects the role. Since v1.1.0 there are two long-lived processes per device: a Windows Service supervisor running as LocalSystem in Session 0, and a user-session controller it spawns via CreateProcessAsUserW.

--service-run — SCM only. Runs as LocalSystem in Session 0. pickActiveUserSession() walks WTSEnumerateSessionsW to find the user's session (v1.1.11), gets the user token, spawns the controller into the user session, watches it, respawns on death. No UI.
Controller (no args) — spawned by the Service via CreateProcessAsUserW into the user session. Owns the LL keyboard hook, filter-mode state, registry lockdown, and supervises the WebView2 kiosk child.
--webview — spawned by the controller when filter mode is active. Renders the fullscreen WebView2 kiosk window. Dies when the controller pauses or restarts.
--ask-password / --show-toast — short-lived child processes for the branded password modal and toast renders. Spawned by whatever caller needs them; isolates WebView2 instantiation to a fresh process to dodge the go-webview2 second-instance panic.
--silent-exit — how IFEO-blocked launches reach us. Windows passes the target exe's path as an extra arg; we exit immediately so the Chrome / Edge / accessibility-helper launch fails invisibly.

Setup (first run)

1. UAC consent 2. relocate to %ProgramFiles%\KioskExitGuard\ + re-exec
3. WebView2 auto-install (if missing) 4. branded wizard: password + kiosk URL
5. uninstall Chrome silently 6. IFEO redirects (chrome.exe + msedge.exe + 5 a11y helpers)
7. four desktop shortcuts 8. install KioskExitGuardSvc + AtLogon task 9. tighten %ProgramData% DACL

Boot sequence (steady state)

Windows boot SCM auto-starts KioskExitGuardSvc (Session 0, LocalSystem)
↓ user logs on (locally or via RDP)
pickActiveUserSession() finds the right session walks WTSEnumerateSessionsW, picks console if it has a user, else lowest-numbered other Active session with a user
WTSQueryUserToken (or user-session-process fallback) + CreateProcessAsUserW
Controller (user session) applies lockdown, installs LL hook, runs message loop
↓ filter mode = ON
--webview kiosk window opens (fullscreen, topmost)

If the controller dies (crash, manual kill, etc.), the Service spawns a fresh one within ~1 s. The kiosk user can't stop the Service — SCM rejects non-admin commands. The co-installed AtLogon scheduled task acts as a fallback for installs where the Service spawn path fails outright; Global\KioskExitGuardControllerRunning keeps exactly one controller alive when both fire.

Pressing a blocked combo (filter active)

user presses Win+R LL hook captures combo → swallows event
--ask-password child process opens (frameless, topmost, autofocused)
↓ correct password
SendInput re-injects Win+R with kioskMarker in ExtraInfo → Run dialog opens for real

The re-injected events carry a random per-process nonce in ExtraInfo so the LL hook recognizes them as our own and lets them through. Wrong password → keystroke stays swallowed, "Wrong password" toast appears.

Pausing the SK Filter

Two equivalent entry points. Both invoke the same password modal and duration picker.

option A · hotkey Ctrl+Shift+Alt+K (handled inline by the controller)
option B · desktop "Pause SK Filter.lnk" (spawns a fresh --pause process; UAC consent)
SK Filter password modal (autofocused, 30 s inactivity timeout)
↓ correct password
kiosk killed; pause-just-applied.flag written; duration picker (1 / 5 / 10 / 20 / 30 / 45 / Custom 1–100)
Edge IFEO block lifted · HKCU lockdown removed
↓ timer fires (1–100 minutes later)
filter re-activates automatically — back to baseline

When the desktop button is used, a 2-second polling goroutine in the running controller picks up the state change from the pause file and mirrors it into in-memory flags. The v1.1.9 pause-just-applied.flag marker suppresses the controller's 30 s watchdog for the first 5 s after pause so it can't relaunch the kiosk before the sync loop catches up.

States

StateKeyboardTask ManagerRun dialogWebView2 kioskEdge
Active (default) Any Ctrl/Win/Alt combo → SK Filter password modal → re-injected if correct. Ctrl+R / F5 / Ctrl-zoom pass through. Disabled by policy Disabled by policy Fullscreen, topmost, frameless IFEO-blocked from launching
Paused (temporary, 1–100 min) All keys pass through Open Open Closed Allowed (IFEO block lifted)

Chrome remains uninstalled in both states — it was removed during first-run setup and isn't reinstalled by the pause. Only Edge re-launches are re-enabled during a pause.

When the pause timer fires, the state flips back to Active automatically: Edge IFEO block is re-applied, the registry lockdown is reapplied, and the WebView2 kiosk relaunches. No user action required.

Setup

  1. Download the exe (button above).
  2. Double-click. Approve the UAC prompt. The first-run wizard relocates the exe to C:\Program Files\KioskExitGuard\ if it's launched from anywhere else.
  3. WebView2 Runtime auto-installs if missing (~30 seconds, silent).
  4. First-run wizard opens — enter password (twice), confirm or change the kiosk URL.
  5. Setup then uninstalls Chrome, blocks Chrome+Edge+a11y helper launches, drops the four desktop shortcuts, and registers the Service + AtLogon task.
  6. The SK Filter is ON immediately after setup. Press Ctrl+Shift+Alt+K to pause it when needed.
SmartScreen on first launch. The exe is unsigned. Click More infoRun anyway. LL keyboard hook + unsigned exe matches keylogger heuristics — the alert is doing its job, but the exe is fine.
Server 2022 / stripped images. WebView2 Runtime isn't pre-installed on Server SKUs. First-run auto-detects and installs it before opening the wizard — no manual step needed. On a headless RDP'd Server 2022 the v1.1.11 pickActiveUserSession() automatically targets the RDP session instead of the empty physical-console session.

CLI flags

kiosk-exit-guard.exe                    # controller mode — LL hook + watchdog (spawned by Service)
kiosk-exit-guard.exe --service-run      # SCM-only — supervising Service (Session 0, LocalSystem)
kiosk-exit-guard.exe --service-install  # admin: register & start KioskExitGuardSvc
kiosk-exit-guard.exe --service-remove   # admin: stop & unregister the Service
kiosk-exit-guard.exe --pause            # password + duration picker (desktop button)
kiosk-exit-guard.exe --resume           # end pause early, no password (desktop button)
kiosk-exit-guard.exe --update           # GitHub releases/latest + SHA-256 + atomic replace (desktop button)
kiosk-exit-guard.exe --uninstall        # password + confirm, full teardown (desktop button)
kiosk-exit-guard.exe --launch-kiosk     # manually respawn the WebView2 kiosk child
kiosk-exit-guard.exe --set-password     # change the password (UAC-elevated)
kiosk-exit-guard.exe --set-url          # change the kiosk URL (UAC-elevated)
kiosk-exit-guard.exe --reset            # password-gated, clears registry policies, IFEO, filter mode
kiosk-exit-guard.exe --webview          # internal: renders the kiosk window
kiosk-exit-guard.exe --silent-exit      # internal: IFEO Debugger redirect handler
kiosk-exit-guard.exe --ask-password     # internal: child-process password modal
kiosk-exit-guard.exe --show-toast       # internal: child-process toast renderer

The controller is normally spawned by the Service into the active user session via CreateProcessAsUserW, not by the user directly. First-run setup registers the Service and starts it automatically. Verify with Get-Service KioskExitGuardSvc or sc query KioskExitGuardSvc.

--reset requires the configured password. If lost entirely, an admin can wipe HKLM\Software\KioskExitGuard via regedit to fully reset — see the admin runbook for the full recovery script.

What it doesn't block

Build from source

CI at .github/workflows/release.yml rebuilds and releases on every v* tag push, attaching both kiosk-exit-guard.exe and a kiosk-exit-guard.exe.sha256 sidecar that the in-app --update flow verifies against.

git tag v1.1.11 && git push origin v1.1.11

Local build:

go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest
goversioninfo -64 versioninfo.json
go build -ldflags="-H windowsgui -s -w" -o kiosk-exit-guard.exe