Skip to content

Security: angeloINTJ/simut

Security

SECURITY.md

SIMUT Security

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.


1. Threat Model

Protected

  • 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 (handleApiUpload validates filename and uploadDir — SEC-001/SEC-002).
  • CLI buffer DoS (CLI_LINE_MAX=256 discards streams without \n — SEC-005).
  • JSON formatting in /api/ls against 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.

Not Protected (out of scope)

  • 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.

2. Credentials & Rotation

Admin (web account)

  • Factory defaults: user admin with a random 8-character password from the alphabet [A-Z2-9] (32 symbols, no O/0/I/1). Entropy ≈ 2^40 — sufficient against casual brute-force; change immediately after first access. The password is generated by rp2040.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.
  • mustChangePassword flag: blocks navigation until changed on the 1st web login.
  • Rotation: via /users UI (admin edits own password) or CLI conf 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.

Physical Display PIN

  • 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 in SystemConfig.reserved[26..27].

Bluetooth CLI

  • Authentication via display PIN. BT access without auth only allows help and language. After auth, full CLI — same privileges as USB CLI.

Viewer (read-only account)

  • User viewer created at factory with limited permissions (PERM_DASHBOARD | PERM_HISTORY). Default public password (viewer), documented as such, with mustChangePassword=true to force change even for the read-only account.

3. Secret Storage

  • 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 via hwrand32) + same pepper. Output 32 hex chars (128 bits, meets NIST minimum). Generated when creating/changing password or on factory reset.
    • v15 schema: UserAccount gained fields salt[8] and hashVersion to support both schemes in parallel. Transparent migration: v13/v14 configs are read, users are marked hashVersion=0 (legacy), and will be auto-upgraded to v1 on the next valid login (F15.2.c).
  • 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 for NONCE_LIFETIME_MS = 60s. Atomically consumed after use.

4. Rate Limiting & Lockout

Web Login

  • State per client IP in _loginStates[LOGIN_STATE_SLOTS=8] (LRU evict by lastActivity, 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_FAIL with 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_init responds HTTP 429 with Retry-After in seconds.

CLI

  • USB/BT buffer bound: CLI_LINE_MAX = 256. Longer lines discarded + LOG_WARN CLI CLI_UNKNOWN_CMD. Prevents DoS like yes | cat > /dev/ttyACM0 (SEC-005/F12.5).

Touch Priority

  • 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.

CSV Export — /api/export/history.bin and /api/export/logs.bin

  • Auth: both require a valid web login + PERM_HISTORY / PERM_LOGS permission respectively. Without a valid SIMUTSESS cookie → 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 by tools/test_perf.sh — 3d export ~10s typical case). On timeout, the handler aborts cleanly via isHandlerOvertime() and the CRC32 trailer is not emitted — the client detects invalid CRC and retries with a smaller chunk.
  • Integrity: the .simx bundle 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|all filters on the server (reduces bytes on the wire and client CPU). History filters by from/to (epoch UTC, hard cap 31d).

Backend Hardening Against Infinite Loops

  • handleApiExportHistory iterates by day in the range. If the file for a day does not exist (LittleFS.exists() == false), dayStart is always advanced before any continue — prevents an infinite loop that would trigger the WDT on ranges covering dates without data.

5. Audit

Persistent Logs

  • 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, CLI show 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).

Status Endpoint

  • GET /api/sec_status (perm: PERM_SYS_CONFIG) exposes active attempts, lockouts, failCount, slot age — useful for triage.

Immutable Logs During Touch

  • 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.

6. Incident Response

If compromise is suspected:

  1. Remote triage:

    • GET /api/sec_status (if login still works) — how many active slots, failCounts, recent lockouts.
    • GET /api/logs or show system log — look for SEC_LOGIN_FAIL, SEC_CONFIG_CHANGED, unexpected SYS_REBOOT_USER, STO_* indicating unsolicited writes.
  2. Containment:

    • Disconnect from the network (power cycle or remove WiFi).
    • If physical access is available: USB CLI remains available even with the network down.
  3. 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).
  4. Preservation:

    • Download /system.blog and /system.old.blog before factory reset — they are wiped along with everything. Files in /history/ as well.
  5. Recovery:

    • After factory reset, reconfigure from scratch. The admin password appears once on USB Serial (connect to capture it).

7. Factory Reset

Via CLI (non-destructive to firmware)

conf system factory confirm

Effect:

  • StorageManager::resetToFactory() — applies loadDefaults + save + clean reboot.
  • Overlays in reserved[] recreated with defaults (SetupFlags MUST_CHANGE_PIN, NetworkTime dns_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 /files or clear log confirm beforehand.

Via Flash Wipe (total destruction)

  1. Press BOOTSEL + reboot → UF2 mode.
  2. Flash a clean .uf2 or a Pico SDK clear tool.
  3. Reflash SIMUT.

Wipes 100% of flash: code, config, history, logs.


8. Attack Surface

Exposed Ports/Protocols

  • HTTP: port 80 (or value in WebConfigData.port — 1..65535). Authenticated via session cookie after login.
  • mDNS: <deviceName>.local (default simut.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).

Permission-Gated Endpoints

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 .bkp of 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_READ for validate, PERM_FILE_UPLOAD for apply) — apply is destructive (overwrites restored .bkp files); chip_id must match. F-RESTORE closed in v4.1.0 (98/100 PASS in loop_real, 0 ConnResets, 100% integrity — see docs/F_RESTORE_BUGS.md). Apply is AUTO-REBOOT: after writing LFS, the device calls LogManager::safeReboot() to reload stale caches (display, sensor loader, theme). The client receives 200 OK or ConnectionReset (both valid — the doRestore UI in WebUI.h polls /api/login_init for 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 at UPLOAD_FILE_START before erasure. Validation dry-run runs on op=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.bin snapshot preserved via OTA_SNAPSHOT_OFFSET (last sector of the staging area) and restored in StorageManager::begin post-apply (chpass, users, WiFi, sensors, MQTT, NTP — 11/11 fields preserved byte-for-byte). Other files (history/lang/themes/calib) require a .bkp downloaded 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.


9. Updates

  • OTA via web (admin): admin with PERM_FILE_UPLOAD uploads the RAW .bin via /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) mustChangePin at 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/manifest which returns fc/psz/crc/fw/cid).

9.1 OTA Update Procedure (Web)

Prerequisites:

  • Admin account with PERM_FILE_UPLOAD.
  • FS backup recommended ("Backup" button in /files).
  • RAW .bin binary (not .bin.gz — gzip path removed in v3.44.0-alpha2).

Steps:

  1. Access /files authenticated, click "Firmware".
  2. WARN modal explains non-config data loss + residual bricks.
  3. Auto-download backup of .bkp (client-side parse compares BKP1 header with announced X-Backup-PSize/X-Backup-PCrc headers; abort on mismatch).
  4. Select .bin, client-side parser validates: range size + boot2 CRC + SIMUT_VERSION regex. Final confirm modal with summary + downgrade warn.
  5. Upload (POST /api/restore?op=stage&commit=1) + apply (POST /api/ota/apply). Device responds 202 and reboots.
  6. Post-boot (~30s), auto-redirect to /login. Config snapshot restored — login works with same credentials.

9.2 Update Procedure (USB Re-flash)

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:

  1. Config backup: via web GET /api/export/config.bin (if PERM_FILES) or copy /config/system.bin via file manager download. Skip only if config-default is acceptable.
  2. FS backup (recommended if LittleFS layout changed): GET /api/fs/backup.tar (if exposed) or enumerate /api/ls + download each file. Empirical lesson: uploadfs on a new release has wiped system.bin + /history/ when the layout changed.
  3. 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).
  4. Copy UF2 to the mounted drive. Pico reboots automatically.
  5. Verify version after boot via GET /api/perms (version field) or Serial banner: must match the published release.
  6. Restore config if step 2 downloaded a backup: upload via POST /api/upload to /config/system.bin (needs PERM_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; attemptLoad migrates from previous versions transparently — see StorageManager.cpp:480).
  • Major bump = compatibility break (rare).

10. Disclosure Policy

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.


There aren't any published security advisories