This document describes the threat model, implemented defenses, and operational security procedures of the SIMUT firmware. It must be kept in sync with the code — any security changes to the project require a review of this file.
Context: SIMUT is firmware for the Raspberry Pi Pico W (RP2040) that manages temperature/humidity sensors, exposes a dashboard and management interface via HTTP, has a CLI over USB and Bluetooth, and sends telemetry to a remote server. It is designed to operate on a trusted local network (industrial LAN or home automation); it is not hardened against an adversarial public network.
- Configuration integrity against accidental corruption (CRC32 +
dual-bank backup,
StorageManager). - User credentials in flash: HMAC-SHA256 hash with per-user salt + pepper derived from the chip serial. Plaintext passwords are never written to flash.
- Path traversal in uploads (
handleApiUploadvalidates filename and uploadDir — SEC-001/SEC-002). - CLI buffer DoS (
CLI_LINE_MAX=256discards streams without\n— SEC-005). - JSON formatting in
/api/lsagainst control bytes that would break the listing (WEB-001 —jsonEscapeFilename). - Login brute force (rate limiting + exponential lockout per IP slot).
- Crash autopsy after HW watchdog (
LogManager::performCrashAutopsy— F13.1) — forensic telemetry of the last freeze.
- Public/hostile network: plain HTTP without TLS. SHA-256 passwords in the client payload, but cookies and payloads are exposed on untrusted networks. Use a VPN or isolated network.
- LAN MitM: no mutual TLS authentication. An attacker on the path can forge responses or intercept cookies.
- Physical attack: access to USB UART gives privileged CLI without authentication (by design — console recovery). BOOTSEL pin access allows flashing arbitrary firmware.
- DMA/side-channel: the RP2040 chip has no secure enclave; any code running has full access to flash and RAM.
- Availability under DDoS: rate-limiting is per-connection and does not withstand a coordinated flood.
- Factory defaults: user
adminwith a random 8-character password from the alphabet[A-Z2-9](32 symbols, noO/0/I/1). Entropy ≈ 2^40 — sufficient against casual brute-force; change immediately after first access. The password is generated byrp2040.hwrand32()(ROSC, hardware). See SEC-003/F12.3. - Factory password exposure: printed once on USB Serial during
factory boot (banner
SEC-003: FACTORY DEFAULTS ACTIVE). Never persisted — cleared from RAM as soon as the admin changes the password or a non-factory config is loaded. mustChangePasswordflag: blocks navigation until changed on the 1st web login.- Rotation: via
/usersUI (admin edits own password) or CLIconf user pass <user> <newpass>. Periodic rotation recommended per operator policy. - Reset without changing factory:
conf system admin reset confirm(CLI) regenerates a random password and displays it on the console — useful if the admin forgets the password.
- Default:
1234— deliberately trivial, protects only against accidental tampering on the display. FLAG_MUST_CHANGE_PIN(SEC-004/F12.4): forces change on the 1st access to the display config menu. Overlay inSystemConfig.reserved[26..27].
- Authentication via display PIN. BT access without auth only allows
helpandlanguage. After auth, full CLI — same privileges as USB CLI.
- User
viewercreated at factory with limited permissions (PERM_DASHBOARD | PERM_HISTORY). Default public password (viewer), documented as such, withmustChangePassword=trueto force change even for the read-only account.
- Password hashes — currently in transition (F15):
- Legacy (
hashVersion=0, v14 and earlier): HMAC-SHA256 × 2500 rounds with salt = username.toLowerCase() (deterministic) + pepper derived from board serial. Output truncated to 30 hex chars (120 bits). Two devices with same user+pass generate different hashes via pepper, but within a device the salt is predictable. - v1 (
hashVersion=1, F15.2.c onward): HMAC-SHA256 × 5000 rounds with random per-user salt (8 bytes viahwrand32) + same pepper. Output 32 hex chars (128 bits, meets NIST minimum). Generated when creating/changing password or on factory reset. - v15 schema:
UserAccountgained fieldssalt[8]andhashVersionto support both schemes in parallel. Transparent migration: v13/v14 configs are read, users are markedhashVersion=0(legacy), and will be auto-upgraded to v1 on the next valid login (F15.2.c).
- Legacy (
- Sensitive flash fields (WiFi pass, telemetry API key): XOR obfuscation with a SHA-256(chipID + domain) keystream before writing. Not strong encryption — it is defense in depth against trivial flash dumps. An adversary with code execution on the chip extracts them easily.
- RAM-only secrets:
_initialAdminPassword(plaintext factory password) only in RAM; zeroed when admin changes password or a valid config is loaded. - Login nonces (
LoginState.nonce): 64 hex chars from SHA-256(hardware entropy × 4). Valid forNONCE_LIFETIME_MS = 60s. Atomically consumed after use.
- State per client IP in
_loginStates[LOGIN_STATE_SLOTS=8](LRU evict bylastActivity, but only among evictable slots — see SEC-006 below). - Exponential backoff:
(1 << failCount) × 1s, cap 300s. Resets to 0 after successful login. - Expired nonce counts as failure (same backoff).
- Failure log:
LOG_WARN SEC SEC_LOGIN_FAILwith reason (invalid nonce, expired nonce, invalid credential). - SEC-006/F15.1: the LRU evict algorithm ignores slots under active
lockout (
lockoutUntil > now). Locked slots become "sticky" until the penalty expires — prevents an attacker from escaping backoff by cycling through 8+ different IPs until the locked slot becomes LRU. If all 8 slots are locked simultaneously (edge case),/api/login_initresponds HTTP 429 withRetry-Afterin seconds.
- USB/BT buffer bound:
CLI_LINE_MAX = 256. Longer lines discarded +LOG_WARN CLI CLI_UNKNOWN_CMD. Prevents DoS likeyes | cat > /dev/ttyACM0(SEC-005/F12.5).
- During display interaction, heavy web handlers respond 503
(
TouchPriority::isActive()→rejectIfTouchPriority— REF-004). Not rate-limiting, it's UX priority, but also reduces the window for CPU-exhaustion attacks.
- Auth: both require a valid web login +
PERM_HISTORY/PERM_LOGSpermission respectively. Without a validSIMUTSESScookie → HTTP 403. - Atomic guard: each endpoint has a flag (
_inHistoryHandler,_inExportLogsHandler) that prevents 2 concurrent exports of the same type — the second receives HTTP 503{"error":"Already processing"}. Allows 1 history export + 1 log export simultaneously. - HeavyTaskGuard: serializes against flash writes (config saves,
sensor accept). Concurrency → HTTP 503
System Busy. - Hard range cap: 31 days per request (rejected with HTTP 400
Range exceeds 31 days). The JS client splits larger ranges into 24h chunks (~30 chunks for full history). - Server deadline:
WEB_LONG_HANDLER_DEADLINE_MS = 15s(calibrated bytools/test_perf.sh— 3d export ~10s typical case). On timeout, the handler aborts cleanly viaisHandlerOvertime()and the CRC32 trailer is not emitted — the client detects invalid CRC and retries with a smaller chunk. - Integrity: the
.simxbundle has a CRC32-IEEE-802.3 trailer. The client validates byte-by-byte before generating CSV — network failure/truncation detected deterministically. - Server-side filters:
/api/export/logs.bin?level=err|inf|allfilters on the server (reduces bytes on the wire and client CPU). History filters byfrom/to(epoch UTC, hard cap 31d).
handleApiExportHistoryiterates by day in the range. If the file for a day does not exist (LittleFS.exists() == false),dayStartis always advanced before anycontinue— prevents an infinite loop that would trigger the WDT on ranges covering dates without data.
- Binary files
/system.blog(current) and/system.old.blog(rotated),MAX_RECORDS_PER_FILE = 800. Each record has timestamp, core, level, tag, code, context. - Reading via web
/history→ Logs tab, CLIshow system log, or direct download/download?file=/system.blog. - Clear:
clear log confirm(CLI) or UI — audited action (LOG_CODE(LOG_WARN, "SEC", SYS_REBOOT_USER, ...)fires in the flow).
GET /api/sec_status(perm:PERM_SYS_CONFIG) exposes active attempts, lockouts, failCount, slot age — useful for triage.
- If a user is interacting on the display, logs are buffered in RAM and flushed on touch release. Prevents evidence loss on crash-during-interaction.
If compromise is suspected:
-
Remote triage:
GET /api/sec_status(if login still works) — how many active slots, failCounts, recent lockouts.GET /api/logsorshow system log— look forSEC_LOGIN_FAIL,SEC_CONFIG_CHANGED, unexpectedSYS_REBOOT_USER,STO_*indicating unsolicited writes.
-
Containment:
- Disconnect from the network (power cycle or remove WiFi).
- If physical access is available: USB CLI remains available even with the network down.
-
Revocation:
- Rotate admin:
conf user pass admin <newPassword>+write memory+reload confirm. - Revoke other users:
conf user del <name>. - If extensive compromise:
conf system factory confirm(wipes ALL config).
- Rotate admin:
-
Preservation:
- Download
/system.blogand/system.old.blogbefore factory reset — they are wiped along with everything. Files in/history/as well.
- Download
-
Recovery:
- After factory reset, reconfigure from scratch. The admin password appears once on USB Serial (connect to capture it).
conf system factory confirm
Effect:
StorageManager::resetToFactory()— appliesloadDefaults+ save + clean reboot.- Overlays in
reserved[]recreated with defaults (SetupFlagsMUST_CHANGE_PIN, NetworkTimedns_auto/ntp_enabled). - Users revoked (random admin, default viewer), sensors unmapped, telemetry cache zeroed on next load.
- Preserves:
/history/*.bin(collected sensor data),/system.blog(logs). To wipe, delete via/filesorclear log confirmbeforehand.
- Press BOOTSEL + reboot → UF2 mode.
- Flash a clean
.uf2or a Pico SDK clear tool. - Reflash SIMUT.
Wipes 100% of flash: code, config, history, logs.
- HTTP: port 80 (or value in
WebConfigData.port— 1..65535). Authenticated via session cookie after login. - mDNS:
<deviceName>.local(defaultsimut.local) — for discovery only; does not expose additional endpoints. - Bluetooth SPP:
SIMUT_CLI. Without PIN pairing (depends on the client stack); application-layer auth via display PIN. - USB CDC: serial always available, no auth (requires physical access).
- NTP: outbound UDP/123 traffic.
- Telemetry: outbound HTTP/HTTPS or MQTT/MQTTS to the configured
server (
cfg.telServer).
Permissions (bitmask): PERM_DASHBOARD, PERM_HISTORY, PERM_LOGS,
PERM_SYS_CONFIG, PERM_NET_CONFIG, PERM_FILE_READ,
PERM_FILE_UPLOAD, PERM_FILE_DELETE, PERM_USER_MGR.
Destructive endpoints require a specific permission + touch-priority
check (503 if user is on the display): /api/commit_all, /api/delete,
/api/mkdir, /api/clear_logs, /api/reset_touch_cal,
/api/force_chpass.
OTA endpoints (F-OTA):
GET /api/backup(PERM_FILE_READ) — generates a.bkpof the LFS tied to the chip_id. Read-only but exposes the entire FS contents, so perm = read.POST /api/restore?op=validate|apply(PERM_FILE_READfor validate,PERM_FILE_UPLOADfor apply) — apply is destructive (overwrites restored.bkpfiles); chip_id must match. F-RESTORE closed in v4.1.0 (98/100 PASS in loop_real, 0 ConnResets, 100% integrity — seedocs/F_RESTORE_BUGS.md). Apply is AUTO-REBOOT: after writing LFS, the device callsLogManager::safeReboot()to reload stale caches (display, sensor loader, theme). The client receives 200 OK or ConnectionReset (both valid — thedoRestoreUI inWebUI.hpolls/api/login_initfor up to 90s after apply to confirm boot). Core 1 stays paused during the entire upload (1 lockout transition instead of 1-per-chunk, avoiding deadlock).POST /api/restore?op=stage(ADMIN-ONLY since v4.2.2 —getAuthPerms() == PERM_FULL_ADMIN) — destructive (erases 1 MB of LFS to receive firmware RAW.bin). Permission pre-check atUPLOAD_FILE_STARTbefore erasure. Validation dry-run runs onop=stage&commit=1: size range + boot2 CRC-32/MPEG-2.POST /api/ota/apply(ADMIN-ONLY since v4.2.2) — IRREVERSIBLE DESTRUCTIVE: copies staging→app slot, watchdog reboot./config/system.binsnapshot preserved viaOTA_SNAPSHOT_OFFSET(last sector of the staging area) and restored inStorageManager::beginpost-apply (chpass, users, WiFi, sensors, MQTT, NTP — 11/11 fields preserved byte-for-byte). Other files (history/lang/themes/calib) require a.bkpdownloaded by the browser before upload + manual restore via/api/restore?op=apply. Recovery in case of brick: BOOTSEL +picotool load -x firmware.uf2.
Immediate action (no reboot): /api/set_time — sets RTC manually,
requires PERM_SYS_CONFIG.
- OTA via web (admin): admin with
PERM_FILE_UPLOADuploads the RAW.binvia/files, validates (boot2 CRC + size range), and applies via/api/ota/apply. Config snapshot is preserved through the apply. Threat surface: any compromised admin credential = ability to flash arbitrary firmware remotely. Mitigations: (1) rate-limit on/api/login_init+ exponential lockout; (2)mustChangePinat factory; (3) random admin password on factory reset (SEC-003); (4) device network access should be restricted (isolated subnet / VPN). UF2 is not yet signed — the operator is responsible for validating the binary origin. - OTA via BOOTSEL + UF2: remains available as recovery and as a secure path for the first flash. Requires physical access.
- Firmware integrity: UF2/BIN have no cryptographic signature; validation is limited to boot2 CRC-32/MPEG-2 heuristic (catches gzip/random, but does not prevent valid-signed malicious firmware). The operator is responsible for downloading the binary from a trusted channel.
- Rollback: flashing a previous UF2 restores. Config in
/config/survives OTA (snapshot) and USB reflash if the flash layout hasn't changed; if the LittleFS partition changes it may wipe everything (validate beforehand via/api/fs/manifestwhich returns fc/psz/crc/fw/cid).
Prerequisites:
- Admin account with
PERM_FILE_UPLOAD. - FS backup recommended ("Backup" button in
/files). - RAW
.binbinary (not.bin.gz— gzip path removed in v3.44.0-alpha2).
Steps:
- Access
/filesauthenticated, click "Firmware". - WARN modal explains non-config data loss + residual bricks.
- Auto-download backup of
.bkp(client-side parse compares BKP1 header with announcedX-Backup-PSize/X-Backup-PCrcheaders; abort on mismatch). - Select
.bin, client-side parser validates: range size + boot2 CRC +SIMUT_VERSIONregex. Final confirm modal with summary + downgrade warn. - Upload (POST
/api/restore?op=stage&commit=1) + apply (POST/api/ota/apply). Device responds 202 and reboots. - Post-boot (~30s), auto-redirect to
/login. Config snapshot restored — login works with same credentials.
Recovery / first-flash path. For routine updates, prefer OTA (§9.1).
Prerequisites:
- Physical access to Pico W (USB).
- UF2 from the official channel (GitHub releases).
Steps:
- Config backup: via web
GET /api/export/config.bin(ifPERM_FILES) or copy/config/system.binvia file manager download. Skip only if config-default is acceptable. - FS backup (recommended if LittleFS layout changed):
GET /api/fs/backup.tar(if exposed) or enumerate/api/ls+ download each file. Empirical lesson:uploadfson a new release has wipedsystem.bin+/history/when the layout changed. - Enter BOOTSEL: press BOOTSEL while plugging in USB or via
1200-baud trick (
stty -F /dev/ttyACM0 1200). The device appears as mass storage (RPI-RP2). - Copy UF2 to the mounted drive. Pico reboots automatically.
- Verify version after boot via
GET /api/perms(versionfield) or Serial banner: must match the published release. - Restore config if step 2 downloaded a backup: upload via
POST /api/uploadto/config/system.bin(needsPERM_FILES+PERM_SYS_CONFIG).
Broken boot recovery:
- BOOTSEL force: hold the BOOTSEL button during power-up; always enters recovery mode, even if firmware is frozen.
- If config is corrupted, factory reset via Serial CLI:
factory reset confirm(regenerates random passwords, keeps calib.csv).
Versioning:
- Git tags follow semver (
v<MAJOR>.<MINOR>.<PATCH>). - Patch bump = bug fix without schema change.
- Minor bump = feature or schema bump (CONFIG_VERSION incremented;
attemptLoadmigrates from previous versions transparently — seeStorageManager.cpp:480). - Major bump = compatibility break (rare).
Suspect a vulnerability? Report privately before publishing:
- Open a private issue on the project repository (if the repo is on GitHub/GitLab, use the security advisory channel).
- Or contact the maintainer directly via email (fill in the channel per the repository).
Avoid publishing to public forums/issues until coordination — devices in production may be compromised during the disclosure window.