Get listed

IoT security: unique secrets, rotatable TLS, signed OTA

A locked teal teapot with a coral default key beside it.

IoT vulnerabilities are usually a service you cannot patch, on a network you treated like a laptop LAN.

A camera, a DVR, or a badge controller still ships with a default password and an HTTP admin. CISA keeps adding those CVEs to KEV because they stay reachable.

The usual mistake is putting the device on the same VLAN as laptops and hoping the vendor image is current. Segmentation and a password you changed are the first controls. A full firmware program comes after.

This page is the classes of IoT hole that keep landing in KEV, and the network moves that contain a device you cannot rebuild.

CISA added CVE-2024-3272 to the Known Exploited Vulnerabilities catalog on 11 April 2024. NVD classifies it CWE-798, hardcoded credentials, on D-Link DNS-320L and related NAS units that the vendor had already declared end of life. Eighteen days later, on 29 April 2024, the UK PSTI regime made a universal default password illegal on consumer connectable products sold in that market. In August 2026 the same three failures still ship in firmware: a shared admin secret, a TLS stack nobody can renew, and an update URL that does not verify a signature.

This page is for people who write the firmware, the update service, and the first-boot wizard. It does not walk a payload against a camera you do not own.

Hardcoded accounts are still CWE-798

NVD page for CVE-2024-3272. The description is a hard-coded account on nas_sharing.cgi. CISA’s required action, dated 11 April 2024 with a 2 May 2024 due date for federal agencies, is retire and replace. D-Link told reporters the line was out of support and could not be patched. That is the 2026 shape: not a clever protocol break, a secret that outlived the vendor’s interest.

Mirai in 2016 walked a short list of factory passwords on Telnet. The UK government’s 29 April 2024 notice still names that wave and the “admin” / “12345” class. ETSI EN 303 645 provision 5.1-1 is the standard PSTI points at: unique per product, or set by the user, and not derived from a public identifier in the clear.

Three forms still show up in firmware reviews:

  • Shared default. admin / admin on every unit. The PSTI ban is this form.
  • Derived from a public field. Password equals the MAC or the serial printed on the sticker. Unique, and guessable from the aisle.
  • Compiled in. A service account in the image, no UI to change it, still valid after the user “set a password.” CVE-2024-3272 is this form. CWE-798 is the name.

Debug ports are the same class. A UART shell with no secret, or an ADB listener on a production build, is a default account you did not print in the manual. Leave them off in the image you flash at the factory.

Ship a unique secret, or force first-boot

Identifiers on this page stay deviceId, factorySecret, and adminHash. Provision them on a line the firmware cannot guess. Write them into a region the update image does not overwrite. Do not bake one password into app.bin.

# factory provision host you own. Run once per unit.
# Does not go in the firmware repository.
python3 - <<'PY'
import hashlib, os, secrets, json, sys
device_id = sys.argv[1]
factory_secret = secrets.token_urlsafe(24)
salt = os.urandom(16)
admin_hash = hashlib.scrypt(
 factory_secret.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32
)
record = {
 "deviceId": device_id,
 "factorySecret": factory_secret,
 "salt": salt.hex(),
 "adminHash": admin_hash.hex(),
}
open(f"provision/{device_id}.json", "w").write(json.dumps(record))
print(device_id, factory_secret)
PY

Print factorySecret on a card in the box, or inject it into the unit’s secure element. The cloud enroll path must treat that value as a one-time bootstrap. After first login, store adminHash only. Rotate by writing a new hash, never by falling back to a compiled default.

The other legal shape is a first-boot wizard that refuses to open LAN listeners until the user sets a password. That is PSTI’s “defined by the user” branch. Implement it as a fail-closed state machine, not a banner the user can skip.

// device: listeners stay closed until provisioned
function listenersAllowed(state) {
 if (state.adminHash && state.bootPasswordSet) return true;
 if (state.factorySecretBound && state.bootPasswordSet) return true;
 return false;
}

function onBoot(state) {
 if (!listenersAllowed(state)) {
 openSetupApOnly();
 return;
 }
 openLanServices();
}

TLS nobody can rotate

Consumer devices still ship OpenSSL builds that cannot speak TLS 1.3, or a client cert with a 2018 notAfter, or a pinned CA whose replacement you never planned. The public web already moved. The CA/Browser Forum’s SC-081v3 cap on a new public subscriber certificate is 200 days as of 15 March 2026. A gadget that needs a 398-day public cert and a human with a USB stick will miss that window.

Three failures, three controls:

  • Protocol. Offer TLS 1.2 as the floor, 1.3 if the stack has it. Refuse 1.0 and 1.1. RFC 8996 deprecated those in March 2021. The TLS guide is the listener bar for anything that faces a browser. The same floor belongs on the device’s MQTT and HTTPS client.
  • Trust store. If you pin a CA, ship a signed update that can replace the pin. A pin with no rotation path becomes an outage, then a “disable TLS” field request. Prefer a small store you can update, plus a backup pin.
  • Clock. A device with no reliable time will reject valid certs or accept expired ones. If you cannot run NTP, ship a signed time hint in the update metadata and refuse a jump that the signature does not cover. I have not confirmed a single MCU vendor’s default RTC drift on 22 August 2026. Measure yours. Fail closed on a cert you cannot check, or you will fail open in the field.
# device-side check you run in CI against a fixture you own
openssl s_client -connect update.example:443 -tls1_2 \
 -CAfile firmware/ca-store.pem -verify_return_error </dev/null
# Expect verify OK. Repeat with -tls1 and expect failure.

# firmware tree: no 1.0, no leftover test CA
rg -n "TLSv1_1_method|SSL_OP_NO_TLSv1_2|VERIFY_NONE|admin:admin" \
 --glob '!third_party/**'

SSL_VERIFY_NONE on the update client is the bug that turns a hijacked coffee-shop Wi-Fi into an unsigned image with extra steps. Require the verify. The host that serves the image is a normal web service. Put it behind the Ubuntu hardening guide if you run it yourself: Ubuntu host guide.

The update channel is the product

An unsigned HTTP image is remote code execution you scheduled. A signed image with the signing key on the intern laptop is the same bug with a ceremony. A signed image and no support window is CVE-2024-3272 again: the unit is still on the network after you stopped caring.

EU Regulation 2024/2847, the Cyber Resilience Act, entered into force on 10 December 2024. Article 14 reporting of actively exploited vulnerabilities applies from 11 September 2026, including for units already sold in the EU. Full essential requirements apply from 11 December 2027. If you ship into the EU, signed OTA is no longer a nice-to-have. It is the way you meet a support period you must state.

Minimum pipeline:

  1. Build produces app.bin and a digest.
  2. A hardware token or an offline machine signs that digest with fwSignKey. The CI runner never holds fwSignKey.
  3. The device verifies with fwPub burned at provision or in a write-protected fuses region.
  4. Anti-rollback: a monotonic fwEpoch. Refuse an image whose epoch is lower.
  5. A/B slots so a bad verify boots the last good slot.
# offline signer. fwSignKey never enters CI.
openssl dgst -sha256 -sign fwSignKey.pem -out app.bin.sig app.bin

# device verify. Fail closed. Do not flash on any error.
python3 - <<'PY'
from pathlib import Path
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
import sys

pub = serialization.load_pem_public_key(Path("fwPub.pem").read_bytes())
blob = Path("app.bin").read_bytes()
sig = Path("app.bin.sig").read_bytes()
pub.verify(sig, blob, padding.PSS(
 mgf=padding.MGF1(hashes.SHA256()),
 salt_length=padding.PSS.MAX_LENGTH,
), hashes.SHA256())
print("VERIFY_OK")
PY
function epochOk({ current, incoming }) {
 return Number.isInteger(incoming) && incoming >= current;
}

Identifiers stay app.bin, app.bin.sig, fwSignKey, fwPub, fwEpoch, and VERIFY_OK. If verify throws, do not write the slot. A “best effort” flash after a bad signature is how a stale DNS record becomes a fleet.

PSTI also requires a public statement of the minimum security-update period, and a contact for vulnerability reports. Put both on a page that will still resolve after marketing redesigns the site. An expired update hostname is a botnet handoff. Keep the name, or keep a CNAME you control, for the whole support window you printed.

Four gates before a listener or a flash. A shared admin password skips the first two. An unsigned URL skips the third.
FACTORY
 write deviceId + adminHash
 burn fwPub, set fwEpoch = 1
 listenersAllowed == false

FIRST BOOT
 user sets password or presents factorySecret
 bootPasswordSet = true
 then open LAN

UPDATE
 fetch app.bin + app.bin.sig over TLS you verify
 check signature with fwPub
 check fwEpoch >= current
 write inactive slot, swap, keep last good

NEVER
 admin/admin in app.bin
 SSL_VERIFY_NONE on the client
 fwSignKey on the CI runner
 flash after verify fails

What to build if you write device software

OWASP’s IoT Top 10 is still the 2018 list. it. Item 1 is a weak guessable or hardcoded password. Item 4 is insecure update. Item 5 is insecure communication. Those three are this page. The 2018 list did not vanish because the year changed. PSTI and the CRA turned the first and the fourth into duties.

Build against that list as code, not as a slide:

  • Identity. deviceId unique. adminHash unique or user-set. No second account compiled in. Cloud enroll uses a bootstrap secret you rotate.
  • Transport. TLS 1.2 minimum on every socket that leaves the LAN, including MQTT. Verify the name.
  • Update. Signed, epoch-checked, A/B. Support dates public. Signing key offline.
  • Disclosure. A mailbox that a human reads. CRA Article 14 reporting from 11 September 2026 if you already sell into the EU.
  • Dependencies. A 2019 OpenSSL and a leftover BusyBox httpd are leftover remote code. Track them like any other app. The dependency guide is the process half.

Network isolation is the operator’s belt, not your excuse. A VLAN does not fix CWE-798. Ship the unique secret anyway. Tell the buyer to put cameras on a dead-end VLAN as well.

Prove the image before it leaves the factory

You are not scanning the public internet for cameras. You are grepping your tree and booting a unit you own.

  1. Strings and ripgrep on the image for admin, 12345, password, and known service accounts. A hit that is not the wizard copy is a ticket.
  2. Boot one unit from the factory image. Before any wizard step, a LAN connect to the admin port must fail. After the wizard, only the password you set must work.
  3. Point the update client at a fixture you own that serves a badly signed app.bin. The device must print a verify error and keep the current slot.
  4. openssl s_client -tls1 against your update name must fail. -tls1_2 must verify with your CA file.
# on an image you built. Expect no compiled secret.
strings app.bin | rg -n "admin:admin|root:root|password=1234|messagebus"

# fixture you admin. Serve a junk signature. Device log must show verify fail.
curl -sS -o /dev/null -w "%{http_code}" \
 --cacert firmware/ca-store.pem \
 "https://update.example/app.bin"
# Staging only. The device, not this curl, is the verifier.
// CI on the firmware repo
test("boot listeners stay closed", () => {
 const state = { adminHash: null, bootPasswordSet: false, factorySecretBound: false };
 expect(listenersAllowed(state)).toBe(false);
});

test("epoch rollback refused", () => {
 expect(epochOk({ current: 4, incoming: 3 })).toBe(false);
 expect(epochOk({ current: 4, incoming: 4 })).toBe(true);
});

Name epochOk in the same file as the flash path. A test that only checks the wizard copy will stay green while the updater still accepts epoch 0. If you cannot run the unit test on the device, run it on the host library the device links.

Questions we keep getting

Does PSTI apply if I only sell in the US?

PSTI is a UK market rule from 29 April 2024. The same three controls are still the right firmware. If you later sell in the UK or the EU, you will already have unique secrets, a stated support window, and a mailbox. CRA reporting starts 11 September 2026 for products on the Union market.

Can I pin TLS and never rotate?

Only if you are sure you will never replace the CA and never need a new public cert on a 200-day clock. That is a bet against SC-081v3. Ship a signed pin update, or use a store you can refresh.

Is a signed update enough after end of life?

No. Signing without a support window is how CVE-2024-3272 stayed exploitable. State the date. Stop selling. Tell buyers to retire the unit. Do not leave an update hostname to rot.