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 everyIMPORT_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:196–374):
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 fromServer.start() (f_ota_hotspot.dis:601):
The SSID is built with a format string over
cfg.hostname and cfg.mac[-4:]:
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:
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:
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):
/ 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_mimelabels unknown types, it does not refuse them) - no
realpath-style containment check after resolution
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:
: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-clientselect.pollregistered forPOLLIN(:435,:1230)- decodes the payload as UTF-8, catching
UnicodeErrorand skipping that message (:1304) json.loads(...)and thendata['cmd']— neither guarded (:1321,:1326)
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 inServer.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:
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):
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):
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-Keywith aConnection: Upgradeheader; there is noOrigincheck, 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_stepis a single global, not per-client state - clients accumulate in
Server.clientswith no cap;listen(3)sets a backlog, not a connection limit
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.