The 24-hour clock starts on 11 September. I ran my own firmware against it and failed eleven ways.
Audited 4 September 2026 · published 11 September 2026 · ~10 minute read · every claim below traces to a file and line
Most of what you read this week will explain the regulation. This is an audit of a real STM32 firmware against it — mine — published with the failures intact.
Scope note. This audit describes the state of DCSwitch on 4 September 2026 and is written in that tense. On 5 September I fixed two of the three code defects below in source: the OTA image is now verified by SHA-256 and an ECDSA P-256 signature before the bootloader erases anything, and the initial password is derived per-unit from the MCU UID. They build and the host test suites pass. I have not flashed the board yet — which is the same gap this post is about, so I am not going to pretend otherwise. The third, cleartext HTTP on the endpoint that accepts firmware, is still open, and fixing the other two is what made it the worst defect in the product. Nothing below has been edited to match. The pre-fix state is what is published, because a remediated audit is a brochure, and a document that quietly updates itself proves nothing about what was true on the day. The fixes get a part two, not an edit.
On 11 September 2026, Article 14 of the EU Cyber Resilience Act (Regulation (EU) 2024/2847) starts to apply. If you make a product with digital elements and sell it in the EU, you now have a reporting duty with a clock attached — fifteen months before the rest of the regulation lands in December 2027.
What Article 14 actually asks
Two things are reportable: an actively exploited vulnerability in your product, and a severe incident affecting its security. “Actively exploited” has a specific meaning — reliable evidence that someone has exploited it in a system without permission. Not a scanner finding. Not a theoretical bug.
When one of those happens you file through the ENISA Single Reporting Platform, which routes to your national CSIRT coordinator. In France that is CERT-FR, at ANSSI. The platform is scheduled to be operational on the same date the duty starts.
The cascade — and note that the two tracks end differently:
| Actively exploited vulnerability | Severe incident | |
|---|---|---|
| Early warning | 24 hours | 24 hours |
| Notification | 72 hours | 72 hours |
| Final report | 14 days after a fix or mitigation is available | one month from the 72 h notification |
There is a fourth obligation that gets less attention than the clock. Under Article 14(8) you must also inform the affected users, with guidance on what they should do, preferably in machine-readable form. If you are slow, the CSIRT may tell them instead of you.
Microenterprises are not exempt. Article 33 lets small and micro enterprises use a simplified technical-documentation format, to be specified by the Commission — that is paperwork relief for Annex VII in 2027, not relief from reporting in 2026.
The question underneath the clock
Strip away the procedure and Article 14 asks you one operational question, twice:
Which of the units you have shipped contain this, and how do you reach the people holding them?
Twenty-four hours is not much time to write a report. It is no time at all to go and find out what is inside your own product. So the deadline is not really a reporting problem. It is a bookkeeping problem that only becomes visible on the worst day of your year.
I wanted to know whether I could answer it. So I audited myself.
The subject
DCSwitch is a DC switching controller I built for my own house, running
on a NUCLEO-F767ZI development board and sold to nobody: an STM32F767,
bare metal, no vendor HAL — src/hal.h is my own register definitions,
and there is no CMSIS and no ST HAL in the tree. Ethernet, an HTTP server and a REST API,
an MQTT client, a browser UI served from flash, and dual-bank over-the-air updates.
It is not on the market. That is precisely why I can publish this level of detail about it, and it does not change what the audit found. Everything below is a finding about a real firmware that would be in scope if I shipped it — which, when I started, I assumed I nearly could.
Failure one: I cannot tell you what I shipped
The whole networking layer — TCP/IP stack, HTTP server, MQTT client — is Mongoose, a single-file C library. Here is how it enters my build:
REPO="https://raw.githubusercontent.com/cesanta/mongoose/master"
curl -sL "${REPO}/mongoose.c" -o mongoose.c
curl -sL "${REPO}/mongoose.h" -o mongoose.h
That is scripts/get_mongoose.sh, and it is eight lines that quietly defeat
a 24-hour clock. master. Not a tag, not a commit, not a checksum. Two builds a
week apart can contain different TCP/IP stacks, and nothing anywhere records which one went
into which binary.
It gets worse when you look for the record that would rescue it. The build
tree is not a working copy. git status returns fatal: not a git
repository; no remote, no commit identifier anywhere in the artifact. There is no CI;
the build is a make on my desk.
And then there is the part I did not expect, which is the most useful thing in this
section. There is a repository named after this product on my own network. I would
have told you, in good faith, that DCSwitch was under version control. It has three
commits, the last in February 2025, with the commit message
“Upload files to /”. It contains a handful of Python scripts from an
earlier, unrelated iteration of the idea — its README says solarman. Not
one line of the firmware in this audit is in it.
So the honest version of this finding is worse than “no version control”,
because it is the version that actually happens: a repository exists, it is named
correctly, and it contains none of what shipped. Anyone auditing by asking
“is this under version control?” gets a truthful yes. The question that
would have caught it is “show me the commit that produced the binary in the
field” — and that is the question Article 14 asks you on a 24-hour
clock. And the only build identity the running firmware carries is this, from
src/main.c:
printf("Compiled: %s %s\r\n", __DATE__, __TIME__);
A compile timestamp. Which tells you nothing about which Mongoose is inside.
So: no pin, no commit, no CI, no SBOM, and an identity string that cannot answer the question. If someone reported an actively exploited vulnerability in my network stack on a Monday morning, I could not tell them which units were affected, because I could not tell myself.
Failure two: seventeen CVEs match the version string. Three of them are real.
Having assembled the inventory by hand — twenty minutes, because the third-party surface is genuinely small — I had a version number for the first time: Mongoose 7.20.
The current release is 7.23. So the first thing the version number bought me was the knowledge that I am three releases behind, which I did not previously know either.
Then I looked up the advisories. There are seventeen against Mongoose ≤ 7.20 or < 7.22: TLS certificate verification, GCM tag handling, an mDNS stack overflow, SSI directory traversal, XSS in directory listings, HTTP request smuggling, a TCP option handler that can be driven into an infinite loop.
If you stop there — and a spreadsheet SBOM listing “Mongoose 7.20” does stop there — you now owe your customers a statement about seventeen vulnerabilities. That is a bad week, and most of it would be wrong.
Which of them are actually in my binary?
A questionnaire cannot answer that. Neither can the version number, because whether a function is in your firmware depends on how you built it. Mine is built like this:
#define MG_ENABLE_TCPIP 1 // built-in TCP/IP stack: yes
#define MG_ENABLE_DRIVER_STM32F 1
#define MG_TLS MG_TLS_NONE // TLS: compiled out
#define MG_ENABLE_SSI 0
#define MG_ENABLE_DIRECTORY_LISTING 0
Two artefacts settle it: the symbol table of the ELF, and the linker map — where anything listed under Discarded input sections was garbage-collected and is not in the binary at all.
$ arm-none-eabi-nm -n build/firmware.elf | grep -iE 'handle_opt|getpeer'
0800c25c t handle_opt
0800a954 t getpeer.isra.0
$ scripts/map-check.sh # section above or below the discard boundary?
handle_opt: LINKED IN
getpeer: LINKED IN
handle_mdns_record: DISCARDED
mg_http_serve_ssi: DISCARDED
mg_http_next_multipart: DISCARDED
mg_http_serve_dir: DISCARDED
mg_tls_init: DISCARDED
So, after about forty minutes:
Two apply. CVE-2026-6985 — the TCP option handler
infinite loop, remote, no authentication, sitting at 0800c25c, fixed in 7.21.
CVE-2026-2967 — improper verification of the communication source in
getpeer, at 0800a954, also fixed in 7.21 — though OSV records
no fixed version for it, so that had to be settled by diffing the source rather than by
reading the database.
A third is linked in and still not reachable as I ship it.
CVE-2026-2966, weak DNS transaction IDs. The resolver is in the binary, and on
the symbol table alone I first called it applicable. That was one rung too shallow.
mg_resolve() takes a literal-IP fast path through mg_aton() and
never sends a query; the only outbound connection in the firmware is the MQTT client; and its
config field is called broker_ip. But nothing enforces that name — the
field is a 48-byte string validated only for non-emptiness, the firmware has an IP parser it
never applies to it, and my own documentation says “IP or hostname”. So the
resolver fires if an operator types a hostname, which my documentation invites. That makes it
deployment-dependent, not inapplicable. The field name, the documented contract and
the documented example give three different answers, which is finding A2 again in a different
costume.
Twelve do not. Every TLS advisory was preprocessed out of existence by one
#define. The mDNS overflow, the SSI traversal, the multipart CRLF injection and
both directory-listing XSS issues were dropped by the linker because nothing referenced
them.
Two more I cannot answer from the code at all. The request-smuggling pair needs a reverse proxy in front of Mongoose to desynchronise against. Standalone on a LAN: not exploitable. Behind an HTTP/1.0 proxy: exploitable. The build does not decide that. The deployment does, and the deployment is the customer's.
That is the difference between “we may be affected by seventeen CVEs, we are looking into it” and “two, here are the addresses, one upgrade closes both, and three more depend on how you deploy us.” One of those fits in a 24-hour early warning.
The part that should worry you
Those twelve are not absent by design. They are absent because --gc-sections
dropped sections nothing referenced.
That is a security property held by accident. The day I add mDNS so the
device announces itself on the network — an obvious feature for this product, maybe a
Tuesday-afternoon commit — handle_mdns_record comes back, and with it a
remotely reachable stack buffer overflow. No code review flags that, because the diff adds
a feature and does not mention a CVE from six months ago.
An SBOM listing “Mongoose 7.20” is wrong in both directions. It alarms you about twelve things that are not there, and it stays silent on the day one of them comes back. The build configuration and the link map are not optional metadata. They are the part that makes the component list mean anything.
Failure three: no way to hear, no way to tell
Suppose someone finds a vulnerability in my product and wants to do the right thing. Where do they send it?
Nowhere. There is no security.txt, no SECURITY.md, no
published contact, no disclosure policy. The 24-hour clock starts when you become
aware — so with no inbound channel, awareness arrives when a customer complains
or a journalist calls, which is both late and expensive.
And in the other direction, Article 14(8) says I must inform affected users. I have no user register and no notification channel. I can build and sign an update, and I have no way to tell anyone it exists.
Nothing watches upstream either. I found the advisory above by searching during this audit — not because any standing process told me.
Failure four: the things a questionnaire would have scored as compliant
Then I read my own code properly, which is where it stopped being paperwork.
The OTA image is unauthenticated. (Fixed in source on 5 September, after this audit — see the scope note.) My bootloader decides an update is
genuine by scanning Bank 2 for the magic word 0x4F544100 —
"OTA\0" — and checking a size bound. No signature. No hash. No version
check. Then it erases the application and copies the new image over it. Anything that can
write Bank 2 runs on next boot.
The default credentials are admin/admin.
(The shared password was fixed in source on 5 September — see the scope note. The
username is still admin, deliberately: a username is not a secret.) From
src/web.c:
static auth_config_t s_auth = {.username = "admin", .password = "admin"};
They are changeable through the config. Nothing forces the change — and the config
parser re-applies admin/admin as the fallback whenever the fields
are empty.
It is all plain HTTP. MG_TLS_NONE, listening on
http://0.0.0.0:80. So the HTTP Basic Auth credentials cross the network
base64-encoded and otherwise in the clear — including on the request that uploads new
firmware, which is the same request as the unauthenticated update path above.
Fill in a compliance questionnaire honestly and this firmware has authentication, has access control, has a documented update mechanism. Three ticks. Read it for four hours and you find an unsigned firmware update path reachable over cleartext HTTP with a default password.
That gap — between what a product declares and what its binary does — is the entire reason I think this regulation needs engineers pointed at it, and not only consultants.
The pass, because one thing was already right
The update mechanism is good, and it was the expensive part. DCSwitch has dual-bank OTA: the bootloader stages an image in Bank 2, erases Bank 1 sectors 1–6, copies, clears the flag, and resets. Configuration lives in its own sector and survives every update. Annex I Part I requires that you can distribute security updates to your products; this can, today, without a redesign.
That is worth saying plainly, because the picture is otherwise bleaker than it should be. Signing the image is a bounded piece of work on top of a delivery path that already exists. Most of what Article 14 needs from a small hardware company is not engineering. It is bookkeeping, a published email address, and a list of who has your devices.
Five things, in the order I would do them
Roughly a day of work each, and the first two are what turn the 24-hour clock from impossible into merely urgent.
- Pin your dependencies to a commit or a tag, with a checksum. If a
curlfrom a moving branch is in your build, nothing downstream of it can be trusted to be reproducible. - Generate an SBOM from the build, and record the build configuration and the
link map with it. Twelve of my seventeen advisories evaporated on one
#defineand a--gc-sectionspass. A component list without build flags tells you the wrong thing in both directions. - Publish a way to be told. A disclosure policy and a monitored
address. RFC 9116
security.txtis a cheap way to advertise it — good practice, not a CRA requirement; what Annex I Part II actually asks for is the policy. - Write down how you reach your users before you need to. Article 14(8) is a duty to notify, and it is unsatisfiable if you have no channel.
- Watch upstream. Subscribe to advisories for what is actually in your binary. This only works once you have done (1) and (2).
Then rehearse it. Pick a component, pretend an advisory dropped this morning, and see whether you can answer which units, and how do we reach them before the day is out. That rehearsal costs an afternoon and tells you more than any gap analysis.
What this does not cover
The licence question, which the same inventory work catches and which is not a CRA
matter. Mongoose is GPL-2.0-only or commercial. If you ship it in a commercial
product you have made a licensing decision, and it is worth knowing which one you made. The
CRA does not police that — but the SBOM you build for the CRA is where you will find
out.
And a caveat that should not need saying: none of this is legal advice, and reading it does not make anyone CRA-compliant. It is one engineer's audit of one firmware, published because the failures are more useful than the successes.