Findings about the device itself, collected while reverse-engineering it, with the mitigations that follow from them. This is not a vulnerability-disclosure policy for this repository.
These are properties of the device, not of anything installed on the Mac:
-
The setup AP is not a security boundary, and its key is the same on every unit. A factory-fresh device hosts a WPA2 access point
U-Clockuntil it is adopted.libzknet.sostores no key: it derives one withPKCS5_PBKDF2_HMAC_SHA1frompersist.sys.softap.pwdoverpersist.sys.softap.ssid, and falls back to the constant12345678when that property is empty — which is how it ships. Both inputs are firmware constants, so the derived 64-hexwpa_pskin/data/misc/wifi/hostapd.confis identical on every TC002, which is why the key appears as a literal in no binary yet opens any unit's AP. Anyone in radio range — no LAN access needed — can join and reach the whole unauthenticated HTTP API on192.168.100.1, includingPOST /setWifiConfig, which moves the clock onto a network they control. Recovered by deriving candidates against one unit's stored key and confirmed by opening a different unit's AP (SETUP.md). Adopt promptly, on a network you trust; flashing the runtime (INSTALL.md) removes the stock setup flow entirely. -
No authentication on any endpoint. Anyone on the LAN can read and write every setting, including triggering
/resetConfigand/update./updatetakes the firmware download URL and checksum from the request body, so it will fetch and flash whatever it is pointed at, and the flasher checks no signature, only a device code, a crc and an md5 that anyone can compute (FIRMWARE.md), so a LAN neighbour can put arbitrary code on the device./setSnlikewise lets anyone rewrite the device serial. -
Credentials are returned in plaintext.
/getCalendarreturns calendarpasswordfields and/getSocialreturns OAuthtokenvalues in clear text over unencrypted HTTP. -
No TLS. Everything is plain HTTP on port 80.
-
adb is open on 5555, and over the usb cable with no pairing step, and
adbdruns as root — everything on the device runs as uid 0 with no privilege separation. -
The wifi PSK is stored in cleartext in
/data/setting.iniat mode0666(world readable and writable), alongside the device serial and social tokens, and the/setWifiConfighandler also logs it in clear tologcat(Saving WiFi - SSID: %s, pwd = %s). It is not exposed over HTTP — every endpoint was checked for the literal value — but anyone who can reach port 5555 gets a root shell and can read both. Note also that it is not readable back over HTTP but it is sent in the clear during adoption, over the shared-key AP in item 1. -
Writes are forgeable from any web page. The device ignores
Origin, accepts JSON bodies sent astext/plain, and approvesPOSTin its CORS preflight (see HTTP-API.md). So a page on any website can fire/resetConfig,/update,/setWifiConfigor/setMqttConfigat the device from a visitor's browser with no user interaction — the browser hides the reply, but the device has already acted. Verified with a no-op cross-originPOST /setConfigfromOrigin: http://evil.example:200, saved. Nothing on the device prevents this. Some browsers' private-network-access protections may, but that varies by browser and version and was not tested here. The display itself is in the same position:POST /api/custom?name=<app>(see HTTP-API.md) lets any LAN host — or any web page a LAN user visits — put arbitrary text or images on the clock, or wipe a custom app by posting{}. -
All cloud traffic is plain HTTP. Device registration, bearer tokens, and any CalDAV username/password you configure go to
api.ulanzistudio.comunencrypted. SeeCLOUD.md. -
The cloud secret key is logged in cleartext to
logcat, which is readable over the unauthenticated adb. -
Cloud registration is unauthenticated. It needs only the serial and MAC, which
/getBasegives to anyone on the LAN, and which the device also broadcasts to the whole segment every second on udp/55555 (seeSETUP.md), so no request is even needed. The consequence of a third party re-registering your device was not tested.
Reasonable mitigation: put the device on an isolated IoT VLAN/SSID and restrict which hosts may reach it. That also bounds the cross-site write exposure to browsers on the hosts you let through. If you don't use the cloud-backed apps, also block its outbound internet, and avoid giving it calendar credentials you care about.
while the custom runtime described in RUNTIME.md is running,
the stock app and its unauthenticated api are not, and the picture changes:
- the clock advertises itself over mdns.
netdanswers fortc002-<full mac>.localand_tc002._tcp, so anyone on the lan can enumerate the clocks on it and learn each one's mac and address without authenticating;dns-sd -B _tc002._tcpis enough. that is the point of the feature, and it discloses nothing the stock firmware's udp/55555 broadcast did not already, but it is one more reason the clock belongs on a network you control. the responder's ownership rules (a source port of 5353 and an observed ttl of 255) keep off-link packets from renaming it, and are not authentication: another host on the same link can claim the name. it is on by default; themdnssetting turns it off, which withdraws the name from every cache on the lan. seeRUNTIME.md. - every api route needs a bearer token, including reads. two random
256-bit tokens (control and admin) are generated per runtime directory,
compared in constant time, stored at mode 0600 under
/data/tc002/state/credentials/(directory 0700) — on the jffs2 partition, not tmpfs, so they survive a power cycle and are cleared only by a factory reset, and never logged or returned. durable settings and the mqtt password need the admin token; the password is never returned by the api. the file is labelled text (control=<64 hex>/admin=<64 hex>) so that choosing a token is deliberate: an admin token satisfies control routes too, so an integration handed the wrong one is over-privileged and nothing says so. named client tokens (RUNTIME.md) give an integration its own credential: issued byPOST /api/v1/tokensunder the admin token, revocable one at a time byDELETE /api/v1/tokens/{name}, taking effect on the next request with no restart. openGET /eventsstreams also close when netd receives the complete replacement token set after revocation, rotation or loss of thelogsscope. a client token holds a set of scopes rather than a rank —notifyalone is a token that can raise a notification and do nothing else — and nevertokens, so it cannot mint more of itself. a command from one is named in the log ring, and the secret is returned exactly once.inputis the scope to grant deliberately: injecting button events drives the physical ui, and the knob's hold opens the device menu, which reaches brightness, the night schedule, the ip layout, mqtt and ntfy on or off, and a reboot — whichdisplay,settingsandrebootgate over http. so/inputreaches pastsettingshere rather than merely as far. that was always true of any token that could post to/input; scopes are what make it refusable.POST /reboothas a scope of its own (reboot) so that the one route which takes the clock off the network can be granted withoutsettings; it is not an mqtt command topic. gap: the two built-in tokens still have no rotation — changing either means deleting the file and restarting, which invalidates every client at once. - browser writes require bearer authentication and an allowed origin. the
device-hosted
/api/docspage may call its own origin (http://plus the request host). any otherOriginneeds the explicit allow list, empty by default. no cors headers or cookie credentials are used. reference assets are public and contain no device state; every/api/v1route is authenticated. the destructive stock endpoints (update,resetConfig,setWifiConfig,setSn) do not exist. - writable home-assistant discovery is separately opt-in.
discovery_controlsdefaults to false. enabling it also grants broker writers access to an explicit allowlist of durable clock/time/night settings. mqtt cannot set the opt-in or change discovery, credentials, script execution, battery or other privileged settings. turning it off refuses those durable writes and removes the writable discovery records. existing transient mqtt controls keep their existing behavior. - the network daemon is unprivileged.
tc002-netdruns as uid 1001 with two inherited descriptors and no access to the token or settings files. gap:/dev/socket/property_serviceis world-writable on this init, so the uid change alone does not deny it the property service. - scripts run on the device, and that is the largest thing a token buys.
the berry interpreter (
SCRIPTING.md) is off by default andtc002-berrydis not spawned untilberry.enabled. storing a script needs the admin token, not the control token, because a script drives the panel indefinitely where a notification is one event. there is deliberately noevalroute: nothing accepts source and runs it without storing it, so there is no separate arbitrary-code surface to gate. what a stored script can reach is bounded by construction rather than by policy — it runs as uid 1001 with no network descriptor (it cannot bind, connect or resolve), no filesystem (BE_USE_FILE_SYSTEMis off,be_filelib.cis not compiled in, and the entry points the linker still wants refuse), and no binding to settings, tokens or credentials. a fixed heap and a handler deadline mean a runaway script is stopped inside its own vm, and a wedged one is a process the supervisor kills. gap: scripts are stored in clear on/data, readable by root, and anyone with the admin token can replaceautoexec, which runs on every start. - still no tls on the api. the api tokens travel in plain http; anyone on
the network path can read them. the build reports
transport: plaintextand is intended for an isolated lan only. the mqtt connection can use tls against a ca you install (RUNTIME.md, mqtt), which keeps the broker password and the broker traffic off the wire; without it they travel in clear too. - adb can be gated by a physical gesture. in the
hardenedprofile the supervisor turnsadbdoff at boot and on for fifteen minutes when the knob is held for three seconds; the defaultdevprofile leaves adbd as it is. neither has been exercised on the device yet. - the cloud client is gone with the stock app: nothing talks to
api.ulanzistudio.com, and no calendar or social credentials are sent anywhere. the clock is set by the runtime's own sntp client against a server you configure, which is unauthenticated — seeRUNTIME.md.
the mqtt password, when set, is written in clear to the runtime's settings
file (/data/tc002/state/config/config.json, mode 0600, root only). tmpfs — ✗ it is the
persistent jffs2 partition, so a plaintext broker password now survives reboots in flash rather
than vanishing with tmpfs. that is a change in exposure, not just in path.