Found this at 2 AM, reading ESP-TEE source I had no business reading. One line looked off. It was off by a single character. That character had re-opened a CVSS 9.3, inside the fix meant to close it.
TL;DR
Two sibling flaws in the ESP-TEE secure-service wrappers. Same code, opposite primitives.
The write (CVE-2026-45328, CVSS 9.3 critical, CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H; the C:H term is the chained worst case, see the impact section). Its root cause is incomplete pointer validation. Espressif fixed it, then one omitted commit re-opened the write in v6.0.1: a single character, = instead of &= inside _ss_esp_tee_sec_storage_aead_encrypt(), dead-stores the five-term pointer-validation chain the moment aad_len != 0. A REE app points the iv argument into TEE M-mode memory; the TEE then calls esp_fill_random(iv, iv_len) before the AEAD body runs, writing attacker-length random bytes to the attacker-chosen TEE address.
The read (CVE-2026-45329, CVSS 7.1, high). Several of the same wrappers validate only some of the pointer arguments, leaving input pointers unchecked. A REE app points an input into TEE-exclusive memory; a crypto/storage peripheral (ECC, SHA, or the SPI/flash path) running in M-mode reads it and hands the result back to the REE, exposing TEE code, data, and cryptographic keys.
Put them together and an unprivileged application reads across, and writes across, the Trusted Execution Environment boundary. What we proved on silicon is the controlled-address write (it faults inside M-mode: a reliable DoS). The read follows from Espressif’s published advisory (CVE-2026-45329), and M-mode code execution needs a value-controlled write chained on top, not the random-value SID 179 write alone. Scoped honestly, that is still enough to matter.
Write confirmed on real hardware: ESP32-C6FH4 rev v0.2, ESP-IDF v6.0.1 (the release the = regression shipped in). The read is per the published advisory, not re-demonstrated here.
Affected SoCs: ESP32-C5, ESP32-C6, ESP32-C61, ESP32-H2
Affected SIDs (same bypass class): 179 (aead_encrypt), 180 (aead_decrypt) on all four SoCs, plus 102 (esp_ds_sign) and 103 (esp_ds_start_sign) on C5, C6 and H2 (the C61 has no DS peripheral, so those two services do not exist there). The controlled write is 179/180 and 103; 102 injects an unvalidated parameter pointer into the DS peripheral rather than writing.
Part 1: The Write (CVE-2026-45328)
The trust boundary
The ESP-TEE (Trusted Execution Environment) is Espressif’s hardware-assisted isolation layer on their RISC-V SoCs. Execution is split into two worlds:
- TEE: M-mode, higher privilege, owns exclusive secure SRAM regions, manages keys and crypto peripherals.
- REE: the normal application firmware, unprivileged.
REE code cannot touch TEE memory directly. It asks the TEE to act on its behalf through a secure service dispatch table: numbered service IDs (SIDs) map to wrapper functions. Each wrapper is supposed to validate the pointer arguments the REE handed in, using esp_tee_ptr_in_ree(), before calling the real peripheral driver.
Those wrappers are the entire security boundary. esp_tee_ptr_in_ree() accepts non-secure SRAM, the non-secure DROM mapping, and, where supported, RTC data memory, and rejects any TEE or secure address, so a wrapper that applies it to every pointer can never be tricked into operating on TEE memory. If a wrapper skips or discards that check, the TEE runs a peripheral against an attacker-controlled pointer at M-mode privilege.
The TEE is the part everyone assumes is safe. That assumption is exactly what this bug turns into a controlled-address write into it.
The original defect
Espressif’s advisory describes the root cause as incomplete pointer validation: the wrappers checked only some of the caller-supplied pointers with the old esp_tee_ptr_in_ree() helper, leaving output pointers, pointers inside struct contexts, and the end addresses of length-bounded buffers unchecked, and the bare-pointer range check did not account for address-space overflow (ptr + len wrapping past the top of the address space). The wrapper layer that is meant to be the boundary was only partly enforcing it. That is CVE-2026-45328 as it shipped in v6.0 and v5.5.4.
The fix (v5.5.5, and the v6.0 line) rewrites the validation: every pointer now goes through a new overflow-safe helper, esp_tee_buf_in_ree(), which rejects a zero length and any len that would overflow the start address, then range-checks both the start and the last byte of the buffer, and the individual results are accumulated with &=.
The fix that wasn’t: one character
Here is where it gets interesting. When that fix was ported to the release/v6.0 branch, one of the commits was omitted from the v6.0.1 release. The whole regression reduces to a single character. In components/esp_tee/subproject/main/core/esp_secure_services_iram.c, inside _ss_esp_tee_sec_storage_aead_encrypt() (SID 179), the additional-authenticated-data check ships with a plain = instead of &=:
// INCOMPLETE FIX, ESP-IDF v6.0.1 (one fix commit was omitted from the release/v6.0 port)
bool valid_addr = (esp_tee_buf_in_ree(ctx, sizeof(esp_tee_sec_storage_aead_ctx_t)) &&
esp_tee_buf_in_ree(ctx->input, ctx->input_len) &&
esp_tee_buf_in_ree(iv, iv_len) &&
esp_tee_buf_in_ree(tag, tag_len) &&
esp_tee_buf_in_ree(output, ctx->input_len));
if (ctx->aad_len != 0) {
valid_addr = esp_tee_buf_in_ree(ctx->aad, ctx->aad_len); // BUG: '=' should be '&='
}
if (!valid_addr) { return ESP_ERR_INVALID_ARG; }
ESP_FAULT_ASSERT(valid_addr);
return esp_tee_sec_storage_aead_encrypt(ctx, iv, iv_len, tag, tag_len, output);The five-term chain correctly validates every pointer, ctx, input, iv, tag, output, through the new overflow-safe esp_tee_buf_in_ree(), and stores the result in valid_addr. Then, if the caller supplied additional authenticated data (aad_len != 0), the conditional block overwrites valid_addr with a plain = instead of accumulating with &=. The five earlier checks are discarded. A REE caller that passes a valid, non-secure aad buffer now gets valid_addr = true no matter where iv, tag, output, or input point. The exact write the fix was meant to close is re-opened, in the release that was supposed to close it.
One character apart. That character re-opened a CVSS 9.3 critical, inside the fix itself.
This regression lives only in v6.0.1. v6.0 and v5.5.4 use &= (their weakness is the older overflow-prone esp_tee_ptr_in_ree() check, the original defect above); v6.0.2 and v5.5.5 use &= with the overflow-safe helper. The same = appears in the sibling decrypt wrapper (SID 180). We found it while reviewing the fix across branches, reported it, and Espressif confirmed it, correcting advisory GHSA-mmgp-73p4-92xp to mark v6.0.1 as affected and fixed in v6.0.2, all tracked under the same CVE-2026-45328.
From bypass to controlled-address write
Bypassing the check is only half a vulnerability. It becomes a write primitive because of what the wrapper calls next. The encrypt wrapper is a one-line passthrough to tee_sec_storage_crypt_common() in tee_sec_storage.c, and that is where the unchecked iv is used: after the key lookup and import, and before the AEAD body runs, it is handed straight to esp_fill_random() (line 689):
esp_fill_random(iv, iv_len); // runs before the AEAD body (from source audit)
The TEE randomises the IV before it runs the AEAD body, so nothing about the ciphertext has to line up first. When iv points into TEE M-mode memory, the TEE writes iv_len bytes to the attacker-chosen address. Note exactly what the attacker controls: the destination (iv) and the length (iv_len), but not the content, which is random. Controlled address and length, uncontrolled value. That distinction matters for what this primitive can and cannot do, and we come back to it in the impact section.
Blast radius: four handlers
The same = dead-store pattern appears in three sibling handlers that share the validation architecture:
| SID | Handler | Dead-stored checks | Write sink | Exploitability |
|---|---|---|---|---|
| 179 | aead_encrypt | iv / tag / output / input / ctx | esp_fill_random(iv), no eFuse gate | Confirmed on hardware |
| 180 | aead_decrypt | iv / tag / output / input / ctx | output memcpy (post tag-verify path) | Same typo; harder path |
| 102 | esp_ds_sign | data (input ptr) | DS reads params from data | Gated: requires burned HMAC-DS eFuse |
| 103 | esp_ds_start_sign | esp_ds_ctx + data | *esp_ds_ctx = ctx (writes TEE heap ptr) | Gated: requires burned HMAC-DS eFuse |
SIDs 102 and 103 only reach the dangerous code path after ets_hmac_calculate_downstream() returns ETS_OK, which requires a provisioned eFuse key. On a development device without it they return ESP_ERR_HW_CRYPTO_DS_HMAC_FAIL before the write. SID 179 has no such gate, which is why it is the one we proved on silicon.
Proof on real silicon
The bench:
- Target: ESP32-C6FH4 rev v0.2 (MAC
98:A3:16:8E:AF:D4) - SDK: ESP-IDF v6.0.1,
CONFIG_SECURE_ENABLE_TEE=y,CONFIG_SECURE_TEE_SEC_STG_MODE_DEVELOPMENT=y - Console:
/dev/ttyACM0(USB-Serial/JTAG)
The proof is a contrast harness. It makes the same AEAD-encrypt call twice with an identical iv aimed at a TEE address, changing only aad_len. That single delta isolates the dead-store as the trigger:
#define TEE_DRAM_TARGET 0x40801044UL /* inside TEE/secure SRAM; MUST be rejected */
uint8_t *tee_iv = (uint8_t *)TEE_DRAM_TARGET;
aead_ctx_t ctx = { .key_id = "pwn", .aad = aad, .aad_len = 16,
.input = input, .input_len = 16 };
/* G1: aad_len = 0 -> full && chain runs, TEE pointer rejected */
aead_ctx_t g1 = ctx; g1.aad_len = 0;
r = svc_aead_encrypt(&g1, tee_iv, 16, ree_tag, 16, ree_out);
/* r == 0x102 (ESP_ERR_INVALID_ARG): validation works as intended */
/* G2: aad_len = 16 -> the '=' dead-stores the iv check, gate bypassed */
r = svc_aead_encrypt(&ctx, tee_iv, 16, ree_tag, 16, ree_out);
/* the TEE faults taking an M-mode store aimed at 0x40801044 */On the wire, the two calls behave exactly as the source predicts:
I (461) TEE-AEAD-POC: gen_key(AES256,"pwn") -> 0x0
I (468) TEE-AEAD-POC: [G1] aad_len=0, iv->TEE 0x40801044 : calling...
I (469) TEE-AEAD-POC: [G1] ret=0x102 (EXPECT 0x102 => validation works)
I (473) TEE-AEAD-POC: [G2] aad_len=16, iv->TEE 0x40801044 : gate BYPASS + write-fault...
Guru Meditation Error: Core 0 panic'ed (Store access fault). Exception was unhandled.
Fault addr: 0x40030d1e | Origin: M-mode
MEPC : 0x40030d1e RA : 0x4080640c
S1 : 0x40801044 A0 : 0x40801044 A3 : 0x40801048
MSTATUS : 0x00001891 MTVEC : 0x40800001
MCAUSE : 0x00000007 MTVAL : 0x40801044
Rebooting...G1 returns 0x102: with aad_len = 0 the validation runs and the TEE address is rejected. G2, the only change being aad_len = 16, bypasses the check and the TEE faults. The registers name the culprit precisely: MCAUSE = 0x7 (Store/AMO access fault), MTVAL = 0x40801044 (exactly the attacker-supplied iv, the store target), and MSTATUS.MPP = 0b11, so the fault was taken inside the TEE at M-mode privilege. One label to read carefully: the panic handler prints the faulting instruction as Fault addr (0x40030d1e, equal to MEPC); the faulting data address is MTVAL. What this proves is the boundary bypass, an unprivileged app steered an M-mode store to an address of its choosing, and the range check that should have rejected it never ran. The store faults here because that target is PMP-protected, which is also why the same primitive is a reliable denial of service.
A hardware security boundary, and an unprivileged app just steered an M-mode store to an address of its choosing. The check that should have stopped it was discarded one line earlier.
Impact: what a TEE write buys you
Be precise about the primitive first. Via SID 179 the attacker controls the address and the length of the write, but the bytes are random. That already gives two things directly, and opens a path to more once a value-controlled write is chained.
Directly, from the SID 179 random-value write:
- Boundary bypass. An unprivileged REE app makes the TEE issue an M-mode store to any address it chooses. The isolation the TEE exists to provide no longer holds.
- Reliable DoS on OT/ICS devices. Aim the write at protected TEE SRAM and the M-mode store faults into an unrecoverable panic. A single service call crashes the target into an M-mode panic and reboot, trivially loopable into a persistent denial of service, which matters for safety-critical industrial gear. No precise target address is needed; the only precondition is a usable AES256 key in secure storage, which the caller can provision itself in development mode. We proved it on the ESP32-C6; C5, C61 and H2 share the same wrapper code and the same SID 179/180 service entries.
With a value-controlled write path (for example the output pointer of SID 180, aead_decrypt):
- Key and secret disclosure. Corrupt the
outputpointer of a decrypt call so the TEE writes decrypted data or key material into REE-readable memory. Combined with the read in Part 2, this is direct extraction of the secrets the TEE is meant to protect. - Escalation to M-mode code execution. A chosen-value write into a TEE dispatch or interrupt function pointer redirects the next service call into attacker code at the highest privilege on the chip. From there the flash controller is reachable (a persistent implant surviving reboot and OTA) and the Secure Boot verification state is corruptible. These follow from a value-controlled write, not from the random-value SID 179 write on its own.
The fix
Espressif’s fix rewrites the validation rather than patching one line. Per the advisory, the wrappers now route every pointer argument through a new overflow-safe helper, esp_tee_buf_in_ree(), which range-checks the full extent of each buffer (start and end) instead of a bare pointer. That closes both the unchecked-pointer cases and the address-space overflow the old esp_tee_ptr_in_ree() checks missed. The complete fix ships in v5.5.5 and v6.0.2; v6.0.1 received the helper rewrite but was missing the commit that restores the &= accumulation on the AEAD aad check, which is the regression documented above.
For the specific dead-store shown earlier, the local defect is exactly one character, the = that should accumulate:
// esp_secure_services_iram.c, SID 179 (encrypt) / 180 (decrypt)
if (ctx->aad_len != 0) {
valid_addr &= esp_tee_buf_in_ree(ctx->aad, ctx->aad_len); // was '='
}
// esp_secure_services.c, SID 102 (103 is analogous, plus a *esp_ds_ctx check):
// bail out on the first check,
// then accumulate, so nothing can be dead-stored later
bool valid_addr = esp_tee_buf_in_ree(data, sizeof(esp_ds_data_t));
if (!valid_addr) { return ESP_ERR_INVALID_ARG; }
size_t n = get_ds_msg_sign_len(data->rsa_length);
valid_addr &= (n > 0) && esp_tee_buf_in_ree(message, n) && esp_tee_buf_in_ree(signature, n);The broader lesson is the pattern: per-service validation spread across several statements and accumulated by hand is one typo away from being dropped. Folding it into a single helper that returns bool, consumed immediately as if (!ok) return ESP_ERR_INVALID_ARG;, removes the failure mode. Unit tests that call each service with each pointer, one at a time, aimed into TEE memory and asserting rejection, would have caught every instance.
Part 2: The Read (CVE-2026-45329)
The mirror image
The write is one half of the story. The same wrapper layer has a second, quieter flaw, pointing the other direction.
Where the encrypt path dead-stores a check it did run, several sibling wrappers never validate their input pointers at all. They check where output goes, but not where input comes from. So a REE caller can hand a wrapper an input pointer aimed straight into TEE-exclusive memory. The wrapper passes it to a hardware crypto/storage peripheral (ECC, SHA, or the SPI/flash path) running in M-mode. The peripheral reads from the attacker-chosen TEE address and returns its output to the REE:
/* Illustrative: the read pattern (CVE-2026-45329), not a runnable PoC.
* The wrapper validates the output pointer but never the input pointer. */
uint8_t *tee_secret = (uint8_t *)0x40801000; /* TEE-exclusive SRAM */
uint8_t digest[32]; /* REE-readable buffer */
/* A hash/crypto service whose input pointer is left unchecked:
* input aimed at TEE memory, output back into the REE. */
esp_tee_service_call(/* argc */, /* SHA service id */,
(uint32_t)tee_secret, /* input -> TEE memory (unchecked) */
(uint32_t)sizeof(digest),
(uint32_t)digest); /* output -> REE (checked) */
/* digest now holds a hash of TEE-resident bytes, computed in M-mode and
* handed back to the REE. With a hash service this is a byte-wise oracle:
* aim the input at one address with len 1 and brute-force each byte offline
* (256 candidates per position). Sibling services whose peripheral returns
* the input-derived bytes more directly (not a digest) leak them outright. */Point a hash or crypto input into the TEE’s secure SRAM and the M-mode peripheral reads it for you: with a digest service you recover the bytes as a hash oracle, one position at a time; with a service that returns its input-derived output directly, you read them straight out. No fault, no crash, no trace.
That is an out-of-bounds read across the TEE/REE boundary (CWE-125, with information disclosure CWE-200): TEE code, TEE data, and the cryptographic keys the whole isolation exists to protect. Scope is Changed, confidentiality High, and it needs zero TEE privileges (CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N).
Pair it with the write from Part 1 and the boundary is gone in both directions: read the keys out, then write to corrupt whatever is left.
The fix (read)
The read is the mirror image of the write, and so is its fix. Those wrappers were missing input-pointer checks entirely, so the patch adds the esp_tee_buf_in_ree() validation on the input pointers that were never checked in the first place. The same helper, applied to the arguments the encrypt path merely dead-stored.
Timeline
| Date | Event |
|---|---|
| May 2026 | Both flaws reported to Espressif PSIRT |
| 2026-05-13 | Read advisory published, GHSA-w82j-7q63-7pqm (CVE-2026-45329) |
| 2026-06-03 | Write advisory published, GHSA-mmgp-73p4-92xp (CVE-2026-45328); advisory lists the fix in v5.5.5 and v6.0.1 |
| Aug 2026 | While reviewing the fix across branches, found the write re-opened in v6.0.1 (the = regression); confirmed on hardware (ESP32-C6, v6.0.1) and reported to Espressif |
| Aug 2026 | Espressif confirmed one fix commit was omitted from the v6.0.1 release; advisory GHSA-mmgp-73p4-92xp corrected, v6.0.1 marked affected and fixed in v6.0.2 |
Affected Versions
Affected SoCs: ESP32-C5, ESP32-C6, ESP32-C61, ESP32-H2 (all RISC-V TEE-capable targets).
| Version | Read (CVE-2026-45329) | Write (CVE-2026-45328) |
|---|---|---|
| < v5.5.0 | not affected (component absent) | not affected |
| v5.5.0 to v5.5.3 | not affected (predates the defect) | not affected |
| v5.5.4 | affected | affected |
| v6.0 | affected | affected (original overflow-prone check) |
| v6.0.1 | fixed | still affected (the = incomplete fix) |
| v5.5.5 | fixed | fixed |
| v6.0.2 | fixed | fixed |
The nuance is the v6.0 line: the read is fixed in v6.0.1, but the write is only fully fixed in v6.0.2, because the v6.0.1 release was missing one of the write’s fix commits. Update targets: on the 5.5 line, move to v5.5.5; on the 6.0 line, move to v6.0.2.
Advisories: GHSA-mmgp-73p4-92xp for the write (CVE-2026-45328), and GHSA-w82j-7q63-7pqm for the read (CVE-2026-45329).
References
- GHSA-mmgp-73p4-92xp, Espressif advisory for the write (CVE-2026-45328)
- GHSA-w82j-7q63-7pqm, Espressif advisory for the read (CVE-2026-45329)
components/esp_tee/subproject/main/core/esp_secure_services_iram.candesp_secure_services.c, vulnerable wrapperscomponents/esp_tee/subproject/components/tee_sec_storage/tee_sec_storage.c, write sink- Espressif ESP32-C6 Technical Reference Manual, RISC-V privilege model, M-mode memory map
