//nefariousplan

CVE-2026-33317: OP-TEE PKCS#11 Has Two Length Fields. Only One Bounds Anything.

pattern

cve

proof of concept

OP-TEE is the secure world. That is the entire premise of the project. A Linux process in the normal world hands a request across the TrustZone boundary, the PKCS#11 Trusted Application reads it, and whatever it writes after that point is supposed to land inside the secure heap by design, not by accident. The request the proof of concept sends is twenty-four bytes. The Trusted Application reads two of those bytes, treats them as a length, allocates sixteen bytes of secure-world memory, and then writes sixteen more bytes past the end of that allocation. The function it goes through is the one whose only job is to bound those writes.

The boundary OP-TEE exists to enforce

OP-TEE is the secure-world OS for ARM TrustZone. Its entire reason for existing is to give an embedded device two execution worlds with hardware-enforced isolation between them. Linux runs in the normal world. Trusted Applications run in the secure world. The TrustZone bit on every memory access decides which side gets to touch which physical pages, and the secure side gets to touch both. That asymmetry is the product. Embedded engineers ship OP-TEE because they want a place to store keys and run crypto where a compromised Linux kernel cannot read the bytes back out.

The PKCS#11 Trusted Application is one of the stock OP-TEE TAs. It implements the PKCS#11 keystore API on top of the secure world, so applications get a Cryptoki-shaped interface that runs in S-EL0 instead of in the kernel. A Linux process calls C_GetAttributeValue through libteec, the request crosses the SMC into the secure monitor, and the PKCS#11 TA reads a serialized template buffer out of shared memory and dispatches on it. The shared memory is the only thing the normal world is allowed to write. Anything the TA does after parsing that buffer happens on the secure side of the boundary.

The advisory for CVE-2026-33317 says entry_get_attribute_value() is missing bounds checks on that serialized template. The PoC published alongside the disclosure boots QEMUv8 with stock OP-TEE 4.10 plus the vulnerable commit, opens a session against the PKCS#11 TA from a normal user, sends one malformed request, and the TA panics with a heap-allocator assertion fired from inside the secure world. The assertion is the allocator noticing its own metadata has been overwritten. The thing that overwrote it was a sixteen-byte copy that the TA executed on behalf of the caller, from inside the parsing routine that exists to keep callers from doing exactly that.

The 24-byte request

Read the PoC source and the entire attack is twenty-four bytes laid out in shared memory. The structures are defined in ta/pkcs11/src/pkcs11_attributes.h and they look like this:

struct pkcs11_object_head {
    uint32_t attrs_size;
    uint32_t attrs_count;
    uint8_t  attrs[];
};

struct pkcs11_attribute_head {
    uint32_t id;
    uint32_t size;
    uint8_t  data[];
};

Both structures are an eight-byte header followed by a flexible array member that holds the variable-length payload. The outer header announces the total payload size in attrs_size and how many attribute records are packed into it. Each attribute record then announces its own data size in its size field.

The PoC writes:

[ session_handle : 4 ]
[ obj_handle     : 4 ]
[ object_head    : attrs_size=8, attrs_count=1 ]
[ attribute_head : id=CKA_LABEL, size=16 ]

Twenty-four bytes total. The outer template claims its payload is eight bytes, which is exactly enough to fit one attribute header and zero bytes of data. The single attribute header inside that eight-byte payload then claims its own data is sixteen bytes long. The two length fields disagree by sixteen bytes, and nothing inside entry_get_attribute_value() notices the disagreement.

Before sending the malformed request, the PoC creates an AES session object with CKA_LABEL = "AAAAAAAAAAAAAAAA". That sixteen-byte label is the value the TA will end up copying out of the object's real attribute store and into a destination derived from the malformed template. The label is the payload. The malformed template is the targeting primitive.

Two length fields, only one bounds anything

Here is the vulnerable loop at commit 06c4e95e, ta/pkcs11/src/object.c lines 828 to 872, edited only for column width:

cur = (char *)template + sizeof(struct pkcs11_object_head);
end = cur + template->attrs_size;

for (; cur < end; cur += len) {
    struct pkcs11_attribute_head *cli_ref = (void *)cur;
    struct pkcs11_attribute_head  cli_head = { };
    void *data_ptr = NULL;

    TEE_MemMove(&cli_head, cli_ref, sizeof(cli_head));
    len = sizeof(*cli_ref) + cli_head.size;
    /* ... */
    data_ptr = cli_head.size ? cli_ref->data : NULL;
    rc = get_attribute(obj->attributes, cli_head.id,
                       data_ptr, &cli_head.size);
}

cur starts at the first byte after the outer header. end is computed from template->attrs_size, which is the value the attacker controls. With attrs_size = 8, end lands eight bytes past cur, and the loop condition cur < end is satisfied for exactly one iteration. So far the bound is doing its job. The loop will run once, then stop.

The problem is what happens inside that one iteration. cli_head is filled by copying eight bytes from cur. That copy stays inside the eight-byte payload region, because sizeof(struct pkcs11_attribute_head) is exactly eight. Fine. Then data_ptr is set to cli_ref->data, which is the address one byte past cli_ref's header. That address is cur + 8, which equals end. data_ptr now points at the first byte past the buffer the TA allocated for this template.

The TA then calls get_attribute() with data_ptr as the destination and cli_head.size as the size. get_attribute() lives in ta/pkcs11/src/attributes.c:

if (attr_size && *attr_size < size) {
    *attr_size = size;
    return PKCS11_CKR_BUFFER_TOO_SMALL;
}
if (attr)
    TEE_MemMove(attr, attr_ptr, size);

The guard is the second word in the request. It asks whether the caller's announced size is smaller than the attribute's real size. If the caller asked for too little, the function refuses to copy and reports the required size instead. The PoC sets cli_head.size = 16 and the real label is sixteen bytes long. The guard passes. TEE_MemMove copies sixteen bytes from the object's attribute store to data_ptr. data_ptr is cur + 8. The buffer that cur lives inside is sixteen bytes total. The copy starts at offset sixteen and runs to offset thirty-two.

Sixteen bytes of attacker-controlled label data land in the next allocation on the bget freelist. The captured secure-world log shows the next allocator operation tripping the corruption check:

E/TA:  assertion 'BH((char *) b - b->bh.bsize)->prevfree == 0'
       failed at lib/libutils/isoc/bget.c:1022 in brel()
E/TC:? 0 TA panicked with code 0xffff0000

prevfree is the bget header field that indicates whether the physically preceding block is on a freelist. The assertion is the allocator noticing that someone wrote into that field through what should have been an unrelated heap pointer. The PoC achieves this with the very first request after object creation. The first malformed call corrupts the heap. The TA panics on the very next bookkeeping operation.

The loop has two length fields available to it. template->attrs_size bounds the outer iteration count. cli_head.size is read from inside that bounded payload but is used to drive a write to a pointer derived from cur, with no requirement that cur + sizeof(header) + cli_head.size fall back inside the buffer. The outer bound is enforced. The inner bound is read but never compared against anything. The two fields exist precisely to permit cross-checking, and the only check the loop performs is that the outer field stays inside the outer field. The shape of that mistake has a name: Frame Bounds Headers Only. The frame is enforced; the headers inside it are read for content and trusted for length.

Every other entry point sanitizes its template. GetAttributeValue did not

The PKCS#11 TA has a function called sanitize_client_object() in ta/pkcs11/src/sanitize_object.c. Its job is to walk a serialized attribute template, validate the inner headers against the outer header, and rebuild a clean copy that the rest of the TA can rely on. Near the top:

if (ADD_OVERFLOW(sizeof(head), head.attrs_size, &sz_from_hdr) ||
    size < sz_from_hdr)
    return PKCS11_CKR_ARGUMENTS_BAD;

It uses ADD_OVERFLOW for the same reason every other safe length check uses it, because the alternative is integer wraparound producing a false-positive pass. It then iterates the inner attribute records and confirms each one fits within the announced payload. It returns PKCS11_CKR_ARGUMENTS_BAD the moment a single inner record overhangs.

That function is wired into create_attributes_from_template(), which is in turn called by entry_create_object(), entry_set_attribute_value(), and entry_copy_object(). Three of the four PKCS#11 entry points that accept a serialized template route through it before doing anything else. The fourth, entry_get_attribute_value(), does not.

The reason it does not is structural. The other three entry points use the inbound template as a source of attribute values to install on an object, so the TA needs a sanitized copy that it can read from safely. entry_get_attribute_value() uses the inbound template as a destination, copying real attribute values from a stored object into the caller's request buffer. The author of entry_get_attribute_value() decided the template was a write target, not a read source, and concluded that sanitization was a different problem.

It was the same problem. The template is shared memory in both directions. Treating the destination half as exempt from layout validation means the destination's inner length fields go unchecked, and an inner length field that overhangs the outer length field will direct a write past the allocated buffer in exactly the way the validator was written to prevent. The validator exists. It is sitting in the file next door. The fix in commit 16926d5a does not call the validator. It inlines an equivalent check directly in the loop:

uintptr_t cli_end = 0;

if ((char *)(cli_ref + 1) > end) {
    rc = PKCS11_CKR_ARGUMENTS_BAD;
    goto out;
}

TEE_MemMove(&cli_head, cli_ref, sizeof(cli_head));

if (ADD_OVERFLOW(sizeof(*cli_ref), cli_head.size, &len) ||
    ADD_OVERFLOW((uintptr_t)cur, len, &cli_end) ||
    (char *)cli_end > end) {
    rc = PKCS11_CKR_ARGUMENTS_BAD;
    goto out;
}

Two ADD_OVERFLOW calls and a comparison against end. The cost of the missing check, written out in C, is six lines. The fix has been waiting in source for the entire life of the bug, in a file in the same directory, called by three of its four sibling entry points.

CVSS A:L is doing work A:H would describe more honestly

The advisory carries CVSS 8.7 with vector AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L. The scope-change bit is the important one. S:C is the score's way of acknowledging that the bug pierces the TrustZone boundary, where a normal-world process is reaching across into secure-world heap and corrupting it. That is the entire CIA hit. Confidentiality high, integrity high, scope changed.

Availability is rated low. The TA panics on the first malformed request and stays unavailable until reload. Every embedded device that uses the PKCS#11 TA as its keystore loses its keystore the moment any unprivileged Linux process opens a session and sends twenty-four bytes. On the kinds of devices OP-TEE actually ships on, set-top boxes, payment terminals, industrial gateways, that condition is closer to A:H than A:L. The score writer chose to read "the rest of the device keeps running" as low availability impact. The honest reading is that the keystore is the part of the device the operator paid for. The independent reproducer's notes rate the same bug 9.3 with A:H for that reason.

The scope-change bit also has a quieter implication. S:C is reserved for vulnerabilities whose impact crosses a security authority boundary. In OP-TEE's case, that authority boundary is the entire reason the project exists. The thing the score writer is acknowledging with one bit is that the bug invalidates the product's foundational claim. The product's claim is that a Linux process cannot reach into secure-world memory. The bug demonstrates a Linux process reaching into secure-world memory through an entry point in the TA that the documentation describes as a read.

Five years, three months

The vulnerable loop in entry_get_attribute_value() lands in master on January 13, 2021, in commit 06c4e95e, written as part of the initial PKCS#11 TA upstreaming. It carries the same shape from that commit forward. sanitize_client_object() is written in the same series, six commits later. The validator and the bug ship in the same release window, in the same directory, separated by the choice not to wire one into the other.

The fix lands in master on January 21, 2026, in commit 16926d5a, written by Etienne Carriere at STMicroelectronics. The advisory publishes on April 23, 2026. The fix sits in private review for ninety-two days from review-complete to public disclosure. That is a reasonable disclosure window for a bug of this severity.

The PoC reproducer's archive note in reproduction-log.md reads: "this reproduction log documents the local QEMUv8 run captured before the public advisory was published on 2026-04-23." The captured QEMU run is dated days before disclosure. The reproducer had a working remote heap corruption against the PKCS#11 TA before the patched OP-TEE release shipped. That is the artifact the published PoC is built on. The repository's stated purpose is to publish the reproducer after the fact, but the timestamp on the captured log says the work was done while the bug was still live.

Five years from the loop landing in master to the bug being filed. Three months from the fix being merged to the advisory being published. The bug is six lines short of safe in the file it was written in, sitting in a directory with a validator that has been doing the same check for three of its four sibling entry points the entire time. The entry point that needed it was the one that wrote into the buffer instead of reading from it, which is the entry point where the missing check has the largest blast radius.

OP-TEE is the trusted execution environment. The PKCS#11 TA is the keystore that runs inside it. The serialized template is the only thing the normal world is allowed to write into. The function that reads that template is entry_get_attribute_value(). The function that bounds the writes it triggers is entry_get_attribute_value(). The function that did not bound the writes it triggers, for five years and three months, is entry_get_attribute_value(). The function whose job is the bound is the function that wrote sixteen bytes into the secure-world heap.

The PKCS#11 TA had two length fields. Only one of them bounded anything; the other wrote sixteen bytes into the secure-world heap.