Skip to main content
f_ota/hotspot.py is the largest network surface in the image that nothing can reach. It is not a “hotspot updater”: it is a small but complete web server — a soft-AP, a TCP listener on port 80, an HTTP/1.0 request parser that serves files off the filesystem, and an RFC 6455 WebSocket endpoint that accepts JSON commands. None of it runs on v5.0.3, because no module in the image imports it and the one dispatcher branch that used to start it now raises instead. Everything below is therefore dormant code: worth documenting because it describes what the device did do and what would come back if the import were restored, not because it is exploitable today.
Everything on this page was read out of the frozen MicroPython bytecode in re/v5.0.3/mpy/*.dis; citations are file.dis:line. Claims that could not be read directly are marked inferred or not recoverable. The embedded web UI was additionally decompressed from the literal blobs in f_ota_f_assets.dis:68.

Reachability: nothing imports it — confirmed

CONFIRMED. Grepping every IMPORT_NAME across all 94 disassembled modules, the only file that mentions hotspot at all is f_ota_hotspot.dis itself. There is no IMPORT_NAME f_ota.hotspot anywhere, and no string constant naming it, so it cannot be reached by a dynamic __import__ either. CONFIRMED. The one historical entry point is gone. f_ota.main.ota_mgr(cmd) dispatches on the command ID, and command 2 — the hotspot command — is now:
(f_ota_main.dis:1126; see the command IDs table.) CONFIRMED. The module’s document root is never created either. Importing f_ota/hotspot.py is what would extract the web UI: the module body does import f_ota.f_assets (f_ota_hotspot.dis:316), and f_ota/f_assets.py runs extract_fs(__name__, '/m_assets/web', 'always', True) at its module scope (f_ota_f_assets.dis:113). f_ota_f_assets.dis is imported by nothing except f_ota_hotspot.dis, so on a v5.0.3 device /m_assets/web does not exist. So the correct reading is: a dead server, whose only caller was deliberately retired, whose content directory is never populated. See also the summary table on WiFi (OTA updates).

Module map

Everything is defined at module scope and instantiated at import time (f_ota_hotspot.dis:196374): Importing the module constructs Server() and WiFi(HOTSPOT) but starts nothing: the Server.__init__ body is a single self.is_running = 1 (:513), and WiFi.__init__ only does self.wlan = WLAN(mode) when self.wlan is None (f_lib_wifi.dis:260). The radio comes up only inside Server.start(), which calls hs_wifi.power_on() (:652) → wlan.active(True) (f_lib_wifi.dis:860). That network.WLAN(AP_IF) construction alone does not activate the interface is inferred from MicroPython semantics, not read from this image.

The soft-AP

All read from Server.start() (f_ota_hotspot.dis:601): The SSID is built with a format string over cfg.hostname and cfg.mac[-4:]:
Two notes on that. The gateway 192.168.0.1 is not inside the 10.9.8.0/24 the interface is given — read directly from the constant tuple at :688; whether the ESP-IDF driver rejects or silently ignores it is not recoverable from this image. And cfg.mac defaults to None (f_ota_config.dis:377); it is populated at the module scope of f_ota/main.py (f_ota_main.dis:293). Since cfg is a shared singleton, the SSID only formats if f_ota.main was imported first — which it always was on the old ota_mgr(cmd=2) path. Starting the server standalone would raise on the slice (inferred). start() also sets hs_wifi.protocol = 7 and hs_wifi.pm = 0 (:660, :663). These have no effect here: both attributes are read only inside WiFi.connect (f_lib_wifi.dis:299, :310, :353), which the hotspot never calls. power_on() does wlan.active(True) and nothing else. Before raising the AP, start() deletes and recreates cfg.save_to — the OTA staging directory, default next (f_ota_config.dis:389).

Shutdown

Server.stop() (:819) unregisters and closes the listening socket, closes every client, stops both named tasks (swallowing RuntimeError per task), waits 200 ms, calls hs_wifi.disconnect(power_off=True), and sets ota_status.hotspot_disconn. _check_inactivity() (:555) is meant to enforce a 5-minute idle timeout:
The threshold constant is 300000 ms (:573) and the poll is asyncio.sleep(15000) (:591) — sleep, not sleep_ms, both read straight from the opcodes. MicroPython’s asyncio.sleep takes seconds, and the module uses sleep_ms correctly everywhere else, so as written the first idle check happens after roughly four hours rather than fifteen seconds (inferred from the unit convention). The AP would stay up until then.

The HTTP request parser

Server.accept_conn() (:972) is the whole front door:
parse_request is three lines and holds no policy:
Because the 200 header block is written to the socket before the file is opened (:1125 sends the status line, :1148 opens the file), a request for a file that does not exist still receives HTTP/1.0 200 OK followed by an empty body; the OSError is caught and logged unless errno == 104 (:1169:1200). get_mime returns False for any extension outside the three-entry MIME_MAP (:1721), and that value is interpolated straight into the header, so unknown types are served with a literal Content-Type: False. Files are opened in text mode and iterated by line (open(file_path, 'r'), then conn.sendall(line) per line, :1148:1161), so only text-decodable files can be served at all; a decode failure on a binary file is not among the caught exceptions.

Path handling: no traversal guard

This is the security-relevant finding, and it is short enough to quote whole (f_ota_hotspot.dis:1691):
CONFIRMED — there is no guard. The complete set of transformations applied to a client-supplied path is: strip leading / characters, truncate at the first ?, and substitute index.htm for the empty string. The result is string-concatenated onto the m_assets/web/ prefix and handed to open(). What is not there, read by absence from those six lines:
  • no normalisation of . or .. segments, and no rejection of them
  • no check that the resulting path is still under m_assets/web/
  • no percent-decoding — and equally no rejection of encoded input, which simply fails to match a filename
  • no allow-list of extensions (get_mime labels unknown types, it does not refuse them)
  • no realpath-style containment check after resolution
The lstrip('/') does neuter an absolute path: every leading slash is removed, so a path starting at the filesystem root collapses back under the prefix. Relative parent-directory segments are untouched. The class of weakness is therefore directory traversal via unnormalised path concatenation — the textbook shape of CWE-22. Whether traversal actually escapes the prefix depends on the filesystem, and that is not recoverable from this module: it hinges on whether the mounted VFS resolves .. components, which is decided in the C VFS layer and not in any frozen module. _boot.dis performs the mount (_boot.dis:42) but names no filesystem class in its constant table. Treat the reachable-file set as “unknown, and not bounded by this code”. The practical mitigations today are the ones outside the module: nothing imports it, so the server never listens; and if it did, /m_assets/web would not exist, so even index.htm would 200-with-empty-body.

The WebSocket handshake

ws_handshake(headers, conn) (:1509) is a real RFC 6455 accept computation:
The magic GUID is the RFC 6455 one, read literally at :1601; the digest is SHA-1 and the encoding is base64 with the trailing newline sliced off (:1609:1615). So the accept token is correct. The response framing is not. The four response lines are joined with a bare CR, not CRLF — the constant at :1641 is b'\r', verified against the raw .mpy object table (the entry is a 1-byte bytes object holding 0x0d). Only the final blank line uses '\r\n\r\n' (:1653). The status reason is also Switching Protocol, singular, where RFC 6455 says Switching Protocols (:1621). Inferred: a conforming client would reject that response, which is further evidence this path has not been exercised. Also absent, by absence in those lines: no Sec-WebSocket-Version check, no Origin check, no subprotocol or extension negotiation, and no validation that the key is 16 bytes of base64 — any non-empty Sec-WebSocket-Key value is hashed and echoed back. The upgrade is only attempted when the raw request contains the exact line Upgrade: websocket — an exact string match against the list produced by req.split('\r\n') (:1026), so case or spacing variants that RFC 6455 permits fall through to the file-serving path instead.

Frames

Frame handling is not in this module. Client.__init__ wraps the accepted socket in the firmware’s native type: self.ws = websocket(sock, True) (:443), imported from the built-in websocket module (:247). Everything about opcodes, masking, fragmentation, control frames and length encoding lives in C. What this module does with the result:
  • client.ws.read() in a loop, non-blocking, driven by a per-client select.poll registered for POLLIN (:435, :1230)
  • decodes the payload as UTF-8, catching UnicodeError and skipping that message (:1304)
  • json.loads(...) and then data['cmd']neither guarded (:1321, :1326)
That last point matters for robustness: a payload that is not JSON, or a JSON object with no cmd key, raises out of websockets(), out of _run_listener(), and kills the listener task. There is no try around either call.

The command vocabulary

Five commands, dispatched by string compare in Server.websockets() (:1230). No authentication, no session, no ordering check — any connected client may send any of them at any time. save_wifi takes both values straight from the message:
Those are exactly the fields f_ota.config.get_known_networks() merges into the network list the device will later join — see known networks. Both keys are indexed, not .get(), so a save_wifi without them raises. Both update commands call the same one-line helper (:1496):
with ota_cmd=3, max_retries=1. perform.ota is the same handoff file every other OTA trigger writes, and command 3 is a firmware .bin update over WiFi — see the perform.ota handoff. Note what this module does not do: it never reboots. It writes the file and stops the server; the update runs on whatever soft reset happens next. show_wifi reads self.local_wifi.nearby_networks (:1342). CONFIRMED: local_wifi is a class attribute set to None (:489) and there is no STORE_ATTR local_wifi anywhere in the module — the only other occurrence in the file is that one read. As shipped, show_wifi raises AttributeError on None every time.

What get_device_info discloses

Sent unsolicited, immediately after a successful handshake, before the client has said anything (:1067:1075, definition at :1662):
Two fields: the device MAC address (f_lib.bitwise.get_mac_addr(), f_lib_bitwise.dis:77 — 12 lowercase hex characters) and the firmware release code, falling back to the string --. Nothing else — no serial, no location, no keys, no user data. Anyone who has associated to the AP and completed the upgrade receives both.

Authentication

CONFIRMED: there is none beyond the WiFi PSK. Across the whole module there is no comparison against any credential, token, cookie or nonce; the only secret in the file is the AP key at :682. Concretely:
  • the HTTP path never inspects a header other than for the upgrade probe, so any file the path resolves to is served to any associated client
  • the WebSocket handshake accepts any non-empty Sec-WebSocket-Key with a Connection: Upgrade header; there is no Origin check, which is what would normally stop a page in a browser on the same network from opening this socket
  • the command dispatcher does not track which client sent what, and ota_status.hotspot_step is a single global, not per-client state
  • clients accumulate in Server.clients with no cap; listen(3) sets a backlog, not a connection limit
The trust model is therefore exactly “whoever knows totem1234 and is in radio range” — and that key is a hard-coded constant shared by every device, the same one that appears as the totemupdate password in f_ota.config’s PERM_NETWORKS (see known networks). Given that model, the composition worth naming is that one unauthenticated WebSocket peer can both choose the network the device will join next (save_wifi) and queue a firmware update to run on the next reboot (update_nearby). The update itself still goes to the fixed S3 branch URL written by project_main.perform_ota(), and the integrity properties of that download are covered under OTA & rollback.

Defects that would prevent it working

Collected because together they are the strongest evidence that this path was never finished, independent of the import grep:

The embedded web UI

f_ota/f_assets.py carries the entire front end as three zlib blobs in its object table, extracted to /m_assets/web on import (f_ota_f_assets.dis:68, :113). They decompress cleanly to the declared sizes: The module’s constant table also carries the build stamp 2024/10/30 13:46:07. The JavaScript is the mirror image of the server and confirms the protocol from the client side: it opens ws:// + location.hostname + :80, sends conn_success 100 ms later, and has buttons that send show_wifi, save_wifi (with ssid and key read from a dropdown and a password field), update_nearby and no_update_nearby. It renders dev_info into two fields, the MAC and the version. It also handles two server messages — err and success — that the server in f_ota/hotspot.py never sends, and the page title is Totem Hotspot. There is no login, token or challenge anywhere in the UI, which matches the server having nothing to check.