278 lines
13 KiB
Markdown
Executable File
278 lines
13 KiB
Markdown
Executable File
# From IEXPLORE.EXE to Rust: Rewriting a 2001 Kiosk Browser
|
|
|
|
**By Jeremy Anderson** — [dcos.net](https://dcos.net) — info@dcos.net
|
|
|
|
In December 2001, I was eighteen, coding on co-op while in school
|
|
for my first employer — a Boston-area MSP and programming company.
|
|
The company serviced medical offices, and one recurring problem was
|
|
the lobby computer: patients waiting for appointments would sit down
|
|
at it and immediately start looking for things they should not be
|
|
looking at, or worse, closing the browser and poking around the rest
|
|
of Windows. The receptionists were tired of babysitting it. My job
|
|
was to make the computer bulletproof enough to leave alone.
|
|
|
|
I shipped a solution in Visual Basic 6. It was, in retrospect, a
|
|
remarkable little artifact of late-90s teenage ingenuity — and
|
|
twenty-five years later, I decided to rewrite it properly.
|
|
|
|
## What the original did
|
|
|
|
The genius of the 2001 version was brute force. It embedded the
|
|
Internet Explorer COM control (`SHDocVw.dll`) in a maximized VB6 form
|
|
with `ControlBox = False` and `WindowState = 2` (maximized). It called
|
|
`SetWindowPos` with `HWND_TOPMOST` to keep the window on top of
|
|
everything. And — this is the part that made it actually work — it
|
|
called `SystemParametersInfo(97, True)`, the legendary undocumented
|
|
`SPI_SCREENSAVERRUNNING` flag that convinced Windows 9x it was running
|
|
a screensaver, which had the side effect of disabling Ctrl+Alt+Del,
|
|
Alt+Tab, and the Windows key.
|
|
|
|
That alone was not enough. A determined user could still close the VB6
|
|
app and reach the desktop. So the app did something beautifully
|
|
ruthless: on startup, it copied `C:\Program Files\Internet
|
|
Explorer\IEXPLORE.EXE` to `IEXPLORE.bak`, then deleted the original.
|
|
If the user managed to escape the kiosk, they would find no browser to
|
|
escape to. On unlock, the app copied the backup back into place.
|
|
|
|
The unlock code lived in plaintext — hardcoded in the form source of
|
|
the lobby build, read from `C:\windows\system\smt.txt` by the
|
|
earliest iteration. The UI was black background,
|
|
white text, Comic Sans. The menu was invisible by default and appeared
|
|
only when the mouse touched the top strip of the screen. The
|
|
delay loop before unlock was a raw `GoTo` counting loop, a CPU-burner
|
|
that pegged the processor at 100% for half a second.
|
|
|
|
The scrubbed source of all three iterations ships with this repository
|
|
under `history/vb6-2001/`.
|
|
|
|
It ran on Windows 95, 98, 98 SE, and ME. It explicitly refused to run
|
|
on NT, 2000, or XP because `SPI_SCREENSAVERRUNNING` does nothing on
|
|
NT-family kernels.
|
|
|
|
It worked. It ran in that lobby for years.
|
|
|
|
## Why rewrite it
|
|
|
|
The original program is unusable today for a list of reasons:
|
|
|
|
- It is VB6. The toolchain is dead. The runtime ships in Windows 11
|
|
only for legacy compatibility, and Microsoft has announced its
|
|
removal.
|
|
- It depends on `SHDocVw.dll`, which is Internet Explorer. IE is gone.
|
|
- `SPI_SCREENSAVERRUNNING` was a Win9x-only trick. Modern Windows
|
|
(NT kernel) ignores it entirely.
|
|
- The IEXPLORE.EXE shell game is impossible on modern Windows due to
|
|
filesystem permissions and Windows Resource Protection.
|
|
- Plaintext unlock-code storage — hardcoded in the form source, or a
|
|
plaintext file on disk — is indefensible.
|
|
- The Comic Sans UI is accessibility-hostile.
|
|
|
|
But the core idea — a public-facing browser that holds the perimeter
|
|
and never leaks session data between users — is more relevant than
|
|
ever. Medical lobbies still have lobby computers. Libraries still have
|
|
public terminals. Trade shows still have demo kiosks. The problem
|
|
shape has not changed; only the implementation needs to.
|
|
|
|
## The architectural decisions
|
|
|
|
A modern kiosk browser has to answer four questions:
|
|
|
|
1. **What renders the web content?**
|
|
2. **How is the perimeter enforced?**
|
|
3. **How is the operator UI protected?**
|
|
4. **How is session data sanitized?**
|
|
|
|
### Rendering: LibreWolf ESR
|
|
|
|
The constraint was no Chromium. Tauri was rejected because on Windows
|
|
it uses WebView2, which is Edge, which is Chromium. Electron was
|
|
rejected for the same reason and for its 150 MB binary size. Servo was
|
|
rejected because its web compat is still too inconsistent for
|
|
arbitrary patient-portal content.
|
|
|
|
LibreWolf ESR is the right answer. It is Gecko-based (no Chromium),
|
|
MPL 2.0 licensed, community-maintained, tracks Firefox ESR for
|
|
security, has Mozilla telemetry stripped, and ships enterprise
|
|
`policies.json` support for lockdown. It also navigated Mozilla's
|
|
trademark policy already, which means redistribution is straightforward.
|
|
|
|
### Perimeter: OS-level lockdown, not app-level
|
|
|
|
The original VB6 fought the OS from inside the app. It used Win32 API
|
|
calls to disable Ctrl+Alt+Del. It renamed system executables. This was
|
|
necessary on Windows 9x because the OS had no kiosk mode.
|
|
|
|
Modern operating systems do. Windows 10+ has AssignedAccess, which
|
|
configures a device as a dedicated kiosk at the OS level — single
|
|
auto-logon account, no shell, no escape. Linux has `cage`, a 3 MB
|
|
wlroots-based compositor that boots straight into a single client with
|
|
no window manager, no shell, no desktop.
|
|
|
|
The right architecture moves the perimeter enforcement out of the app
|
|
and into the OS, where it belongs. Vestibule does not try to disable
|
|
Ctrl+Alt+Del from inside the browser. AssignedAccess does that at the
|
|
session level, far more reliably than any app-level hook ever could.
|
|
|
|
### Operator UI: extension + Rust helper
|
|
|
|
The browser-level logic — URL filtering, hidden menu, session reset,
|
|
unlock dialog — lives in a WebExtension. This is the natural unit of
|
|
composition for Firefox-family browsers and gives us `webRequest`
|
|
blocking, `browsingData` sanitization, and `idle` detection for free.
|
|
|
|
The OS-level logic — password verification, power event detection,
|
|
signaling the supervisor — lives in a tiny Rust binary called `usher`.
|
|
It speaks Firefox Native Messaging (a stable, supported stdio-JSON
|
|
protocol) to the extension. The binary is 1.2 MB. The extension is
|
|
6 KB. Together they replace what would have been a 150 MB Electron
|
|
bundle.
|
|
|
|
### Session sanitization: three layers, defense in depth
|
|
|
|
The product contract is: a kiosk must never persist patient data
|
|
across sessions. Three enforcement layers:
|
|
|
|
1. **Block all save paths.** `policies.json` disables form history,
|
|
password manager, master password creation, Pocket, Screenshots,
|
|
Firefox Accounts, search suggestions, extension installs, popup
|
|
blocking override, and protocol handler registration.
|
|
`SanitizeOnShutdown` wipes everything on browser exit.
|
|
|
|
2. **Reset on trigger events.** The extension's `resetSession()`
|
|
invokes `browser.browsingData.remove()` with all eleven data types
|
|
on idle timeout, wake-from-sleep, unlock, and manual reset.
|
|
|
|
3. **OS-level defense.** AssignedAccess and `cage` ensure the browser
|
|
cannot be closed or escaped. The dedicated `vestibule-profile`
|
|
directory isolates the kiosk from any user profile data.
|
|
|
|
## The 2001 gestures that survived
|
|
|
|
Two original design decisions carry forward unchanged.
|
|
|
|
**The top-edge hidden menu.** The original VB6 form toggled its menu
|
|
in `picAddress_MouseMove`, based on `Y <= 10` (twips — effectively the
|
|
top edge of the screen). The modern equivalent is a content script
|
|
that listens for `mousemove` and shows a fixed-position overlay when
|
|
`clientY <= 3` pixels. Same gesture. Same UX rationale: the menu is invisible
|
|
by default, appears on a deliberate gesture, and disappears on
|
|
mouse-leave. Patients never see it. Operators always find it.
|
|
|
|
**The password unlock.** The original prompted for an unlock code and
|
|
compared it to the contents of `smt.txt`. The modern equivalent
|
|
prompts for an unlock code and verifies it against an Argon2id hash
|
|
stored at 0600 by usher. The flow is identical; the
|
|
storage and verification are not.
|
|
|
|
## What did not survive
|
|
|
|
**The IEXPLORE.EXE shell game.** Modern Windows filesystem permissions
|
|
make this impossible. The replacement is a URL allowlist enforced via
|
|
`webRequest.onBeforeRequest` in the extension. Stronger, safer, and
|
|
does not require renaming system executables.
|
|
|
|
**The CPU-melting delay loop.** The original used a `GoTo` counting
|
|
loop (`beginn: time = time + 1 ... GoTo beginn`) to burn half a second
|
|
before unlock. The modern
|
|
equivalent is `tokio::time::sleep(Duration::from_millis(500))`. Same
|
|
delay, 0% CPU.
|
|
|
|
**Plaintext password storage.** usher stores the Argon2id hash
|
|
(64 MiB memory cost) in a file owned and readable only by the kiosk
|
|
user — mode 0600 on Linux, default user ACL on Windows. Keyring
|
|
daemons are unavailable on minimal kiosk compositors like cage, so
|
|
file permissions are the enforcement boundary. The password itself is
|
|
never written anywhere.
|
|
|
|
**The Comic Sans UI.** The hidden menu uses system fonts
|
|
(`-apple-system, "Segoe UI", system-ui, sans-serif`). A retro 2001
|
|
theme is a Phase 5 option for those who want the nostalgia.
|
|
|
|
## Power state awareness
|
|
|
|
The original VB6 did not handle sleep because Windows 9x kiosks
|
|
typically did not sleep. Modern hardware does. Laptops used as kiosks
|
|
suspend on lid close. Mini-PCs suspend on idle. Tablets suspend on
|
|
inactivity.
|
|
|
|
Vestibule subscribes to the OS's power events. On Linux, usher
|
|
connects to D-Bus and listens for
|
|
`org.freedesktop.login1.Manager.PrepareForSleep(false)`, which fires
|
|
about two seconds after the system wakes. On Windows, usher registers
|
|
for `WM_POWERBROADCAST` and listens for `PBT_APMRESUMEAUTOMATIC`.
|
|
|
|
On wake, usher emits a `Wake` event to the extension, which calls
|
|
`resetSession("wake:suspend")`. The same path fires on idle timeout.
|
|
Both go through one code path, so behavior is identical regardless of
|
|
trigger.
|
|
|
|
The decision to detect rather than block sleep is deliberate.
|
|
Blocking sleep fights the OS — it causes battery drain on mobile
|
|
kiosks, thermal issues on fanless hardware, and interferes with
|
|
Windows Update overnight reboots. The OS power profile is the right
|
|
place to decide whether a device sleeps; that is a deployment-time
|
|
decision, not an app-runtime one. Vestibule reacts correctly when the
|
|
OS does sleep, and stays out of the way when it does not.
|
|
|
|
## What I learned doing this
|
|
|
|
The 2001 version took me about three weeks of evenings. I knew nothing
|
|
about Win32 API calls, COM interop, or filesystem security. I learned
|
|
all of it by reading `Declare Function` examples on Planet Source
|
|
Code and copying patterns I did not fully understand. The code was
|
|
procedural, repetitive, and full of `On Error GoTo` cascades that
|
|
existed to patch around the IEXPLORE.EXE rename failing halfway. It
|
|
worked because the problem was small and the OS was permissive.
|
|
|
|
The 2026 version took about a week of focused work. I know Rust, I
|
|
know Firefox extension APIs, and I know what the OS primitives
|
|
actually do. The code is threaded, table-driven, and structured around
|
|
a message-passing channel that makes the data flow obvious. It works
|
|
because the abstractions are right, not because I patched around
|
|
enough edge cases.
|
|
|
|
The instinct that survived intact is the one the original reviewer
|
|
called out: dig underneath the standard UI and manipulate the
|
|
environment directly. The 2001 version did this with Win32 API calls.
|
|
The 2026 version does it with D-Bus subscriptions, enterprise policy
|
|
files, and a message-passing channel between browser and helper. The
|
|
implementation is completely different. The instinct is the same.
|
|
|
|
## What comes next
|
|
|
|
Vestibule 1.2 ships the working core: real Argon2id unlock with file
|
|
storage at 0600, a dedicated unlock popup window, Windows power event
|
|
detection, the `librewolf.overrides.cfg` that selectively relaxes
|
|
LibreWolf's resistFingerprinting for SSO flows, per-origin preservation
|
|
for the data persistence allowlist — and, on top of that, the OS-level
|
|
provisioning wizards (an AssignedAccess setup wizard on Windows with a
|
|
Shell Launcher step-down, a `cage` systemd unit on Linux where the unit
|
|
IS the graphical session — no display manager at all), full
|
|
deprovisioning on both platforms, and the packaging itself: Inno Setup
|
|
on Windows and a Flatpak on Linux that acts as a self-describing
|
|
deployment kit. Since 1.2.2 the perimeter also extends to the web
|
|
itself: the URL policy blocks every domain by default and opens only
|
|
what the operator safelists — domain-based matching, with the home
|
|
page guaranteed reachable so the kiosk can never lock itself out.
|
|
|
|
Phase 3 adds crash auto-restart integration testing, telemetry hooks,
|
|
and the `always-on` power-inhibit mode. Later phases: Flathub
|
|
distribution, remote management, and the retro 2001 theme.
|
|
|
|
One platform decision is already settled: Vestibule targets Linux
|
|
first. The Windows stack works and stays CI-validated, but its
|
|
binaries ship unsigned — a code-signing certificate is an unfunded
|
|
line item unless a donation covers it — and Linux has no signing
|
|
gate to care about.
|
|
|
|
Phase 5+, deferred: webcam presence detection. When a webcam is
|
|
present, usher captures a baseline face embedding at session start and
|
|
watches for two conditions: no face visible for N seconds (user
|
|
walked away) or face embedding mismatch (different user approached).
|
|
Either triggers an immediate session reset, faster than the idle
|
|
timeout. Privacy review precedes any biometric code. Frames are
|
|
processed in-memory only; embeddings never leave the local process;
|
|
the webcam LED is respected.
|
|
|
|
*Jeremy Anderson, 2026. [dcos.net](https://dcos.net). info@dcos.net.*
|