Wanneer de debugger liegt

Een paar avonden geleden merkte ik onverwachte waarden in het geheugen op terwijl ik werkte met de Key Management Unit (KMU). Dit bleek een bekend probleem te zijn, maar het diagnosticeren ervan vereist inzicht in de interne System on Chip (SoC)-componenten en de manier waarop de debugger ermee communiceert.

Achtergrond: Beveiligingsfuncties van de nRF54L

De nRF54L-serie beschikt over een geavanceerde set beveiligingsmogelijkheden, waaronder:

  • Arm TrustZone-ondersteuning in de Cortex-M33-core.
  • Een CRACEN cryptografische accelerator.
  • Een Key Management Unit (KMU).

De KMU wordt gebruikt voor het opslaan van gevoelige gegevens, zoals keys-seeds en bijbehorende metadata, in de Secure Information Configuration Region (SICR). De SICR is onderverdeeld in slots. Deze slots worden aangestuurd door taken (tasks) naar de KMU te sturen, wat alleen kan gebeuren in de beveiligde modus (secure mode).

Normaal gesproken communiceert applicatiefirmware niet direct met de KMU. In plaats daarvan worden PSA-drivers gebruikt om het genereren, opslaan en gebruiken van keys te abstraheren. Een voorbeeld hiervan is de aanroep van psageneratekey(), wat uiteindelijk leidt tot een aanroep van importkeyfor_kmu() in de CRACEN PSA-driver:

static psa_status_t import_key_for_kmu(const psa_key_attributes_t *attributes, const uint8_t *data,
size_t data_length, uint8_t *key_buffer,
size_t key_buffer_size, size_t *key_bits)
{
    size_t opaque_key_size;
    psa_status_t status = PSA_ERROR_CORRUPTION_DETECTED;
    int slot_id = CRACEN_PSA_GET_KMU_SLOT(MBEDTLS_SVC_KEY_ID_GET_KEY_ID(psa_get_key_id(attributes)));
    psa_key_attributes_t stored_attributes;

    status = cracen_get_opaque_size(attributes, &opaque_key_size);
    if (status != PSA_SUCCESS) {
        return status;
    }

    if (key_buffer_size < opaque_key_size) {
        return PSA_ERROR_BUFFER_TOO_SMALL;
    }

    status = cracen_kmu_provision(attributes, slot_id, data, data_length);
    if (status != PSA_SUCCESS) {
        return status;
    }

    status = cracen_kmu_get_builtin_key(slot_id, &stored_attributes, key_buffer,
    key_buffer_size, key_buffer_length);
    if (status != PSA_SUCCESS) {
        return status;
    }

    *key_bits = psa_get_key_bits(&stored_attributes);
    return status;
}

KMU Slots en Provisioning

Een slot kan in drie toestanden verkeren: gewist (erased), geprovisioneerd (provisioned) of ingetrokken (revoked). Slots hebben een ID (0 - 249) en kunnen de volgende gegevens opslaan:

  • Metadata (32 bits)
  • Een bestemmingsadres (32 bits)
  • Een waarde (128 bits)
  • Een intrekkingsbeleid (revocation policy) (2 bits)

Het intrekkingsbeleid bepaalt hoe de status verandert wanneer een key in de geprovisioneerde status is en er taken worden uitgevoerd die naar het slot-ID verwijzen. Bij experimenten met de KMU is het ROTATING beleid het eenvoudigst; dit zorgt ervoor dat het slot terugkeert naar de gewiste status zodra er een revoke-taak wordt uitgevoerd.

Wanneer de PUSH-taak wordt uitgevoerd, wordt de waarde in het slot geschreven naar het bestemmingsadres dat is opgegeven tijdens het provisioneren. Dit proces is te zien in de implementatie van cracenkmukeyslotprovision():

static int cracen_kmu_key_slot_provision(const nrfx_kmu_key_slot_data_t *key_slot_data,
uint32_t slot_id)
{
    int kmu_status;
    uint8_t orig_write_buf_size;
    cracen_kmu_key_slot_provision_write_enable_set(true, &orig_write_buf_size);
    kmu_status = nrfx_kmu_key_slot_provision(key_slot_data, slot_id);
    cracen_kmu_key_slot_provision_write_enable_set(false, &orig_write_buf_size);
    return kmu_status;
}

De definitie van nrfxkmukeyslotdata_t is te vinden in de Nordic Zephyr Hardware Abstraction Layer (HAL):

typedef struct __PACKED
{
    uint32_t                     keyslot_value[KEY_SLOT_WORDS_COUNT]; ///< Key data to be provisioned.
#if NRF_KMU_HAS_REVOKE_POLICY || defined(__NRFX_DOXYGEN__)
    uint32_t                     revoke_policy;                       /**< Key revoke policy.
                                                                      *   @ref nrfx_kmu_rpolicy_t
                                                                      *   holds possible values. */
#endif
    uint32_t                     keyslot_dest;                        /**< Key slot destination when
                                                                      *   performing key push. */
#if NRF_KMU_HAS_METADATA || defined(__NRFX_DOXYGEN__)
    nrfx_kmu_key_slot_metadata_t metadata;                            ///< Metadata to write to keyslot.
#endif
} nrfx_kmu_key_slot_data_t;

Hoewel de KMU hardwarematig flexibel is, leggen de ondersteunde PSA-drivers restricties op aan de waarden. Voor metadata wijst de PSA-driver bijvoorbeeld specifieke betekenissen toe aan elke bit:

typedef struct kmu_metadata {
    uint32_t metadata_version: 4;
    uint32_t key_usage_scheme: 2;
    uint32_t reserved: 8;
    uint32_t algorithm: 6;
    uint32_t size: 3;
    uint32_t rpolicy: 2;
    uint32_t usage_flags: 7;
} kmu_metadata;

Het experiment met GDB

Om de KMU handmatig te testen op een nRF54LM20 DK, maakte ik gebruik van GDB. Omdat de toegangspoortbescherming niet was ingeschakeld, was het SPIDEN (Secure Privileged Invasive Debug Enable) signaal hoog, waardoor de J-Link debugger met beveiligde privileges kon werken.

Stappenplan voor provisioning en push

  1. Status controleren: Ik controleerde of de KMU klaar was door het STATUS-register (0x50049400) uit te lezen.

``gdb (gdb) x/1xw 0x50049400 0x50049400: 0x00000000 ``

  1. Data structuur bouwen: Met een Python-script maakte ik een binair bestand (kmusrc.bin) conform de nrfxkmukeyslotdatat struct.

```python import struct open("kmu_src.bin", "wb").write( bytes.fromhex("abc123abc123abc123abc123abc123ab") # Value

  • struct.pack(

"<III", 3, # Revocation Policy 0x20000000, # Destination Address 0xDEF678DE, # Metadata ) ) ```

  1. Data naar geheugen schrijven:

``gdb (gdb) restore kmu_src.bin binary 0x20001000 ``

  1. KMU configureren: Het adres van de struct naar het SRC-register (0x50049504) schrijven en slot 9 selecteren in het KEY_SLOT-register (0x50049500).

``gdb (gdb) set (unsigned int)(0x50049504) = 0x20001000 (gdb) set (unsigned int)(0x50049500) = 9 ``

  1. RRAMC configureren: De RRAMC CONFIG-register (0x5004e500) instellen op 1 om ongebufferde schrijfacties mogelijk te maken.

``gdb (gdb) set $rramc_config = (unsigned int)(0x5004e500) (gdb) set (unsigned int)(0x5004e500) = 1 ``

  1. Provisioning triggeren: Schrijf 1 naar TASKSPROVISION (0x50049000) en verifieer via EVENTSPROVISIONED (0x50049100).

``gdb (gdb) set (unsigned int)(0x50049000) = 1 (gdb) x/1wx 0x50049100 0x50049100: 0x00000001 ``

  1. RRAMC herstellen:

``gdb (gdb) set (unsigned int)(0x5004e500) = $rramc_config ``

  1. Push operatie: Slot 9 selecteren en de TASKS_PUSH register (0x50049004) triggeren.

``gdb (gdb) set (unsigned int)(0x50049500) = 9 (gdb) set (unsigned int)(0x50049004) = 1 ``

Na controle van het EVENTS_PUSHED register (0x50049104), controleerde ik het bestemmingsadres 0x20000000:

(gdb) x/4wx 0x20000000
0x20000000: 0xab23c1ab 0xc1ab23c1 0x23c1ab23 0xab23c1ab

De anomalie

Ik herhaalde de stappen met slot 10 en een andere waarde (def456...), maar gebruikte hetzelfde bestemmingsadres (0x20000000). Tot mijn verbazing toonde GDB nog steeds de waarde van de vorige operatie (slot 9).

Zelfs na meerdere pogingen bleef de waarde in het geheugen hetzelfde. Na het wissen van het apparaat zag ik dat de eerste push correct werkte, maar alle daaropvolgende pushes naar hetzelfde adres geen update gaven in de GDB-output.

Analyse van de debug-architectuur

Het probleem leek te liggen bij een verouderde cache. De nRF54LM20 implementeert de Arm Debug Interface (ADI), specifiek de Arm CoreSight SoC-400. Deze heeft drie toegangspoorten:

  • Twee standaard AHB-AP's.
  • Eén custom CTRL-AP.

GDB communiceert met de J-Link hardware via de JLinkGDBServer. Om te testen of de push-operaties wel slaagden maar de reads verouderde waarden gaven, gebruikte ik het monitor ReadMemAP commando, waarmee direct via de toegangspoort gelezen kan worden.

(gdb) monitor ReadMemAP 0x0 0x20000000 4 0
O.K.:0xDE56F4DE,0xF4DE56F4,0x56F4DE56,0xDE56F4DE

Het resultaat was duidelijk: lezen via de AHB-AP gaf de correcte, bijgewerkte waarde, terwijl de standaard GDB examine (x) commando's verouderde waarden bleven tonen.

RSP Paketten en Caching

Door remote debug logging in te schakelen (set debug remote 1), kon ik zien dat de x opdracht en de monitor ReadMemAP opdracht verschillende RSP-pakketten gebruikten. De ReadMemAP opdracht wordt via een qRcmd pakket direct naar de server gestuurd.

Het onderzoek naar de onderliggende JLinkArm.dll bibliotheek wees uit dat er een geheugencache actief is. Het commando SetEnableMemCache beheert dit mechanisme en staat standaard aan.

De oplossing

Ik testte of het uitschakelen van de cache het probleem oploste:

(gdb) monitor exec SetEnableMemCache = 0

Na het uitschakelen van de cache en het opnieuw uitvoeren van de provisioning en push operaties, gaf het examine commando eindelijk de correcte, bijgewerkte waarden terug:

(gdb) x/4wx 0x20000000
0x20000000: 0xde56f4de 0xf4de56f4 0x56f4de56 0xde56f4de

Conclusie

Normaal gesproken is het niet raadzaam om caching uit te schakelen. De reden dat de cache verouderde waarden teruggaf, is dat de CPU was gepauzeerd (halted) terwijl we een geheugenadres probeerden te lezen dat we al eerder hadden bezocht.

In situaties waarin een peripheral (zoals de KMU van de nRF54LM20) gebruikmaakt van Direct Memory Access (DMA) en kan schrijven terwijl de core gepauzeerd is, kun je tegen dit probleem van een verouderde debugger-cache aanlopen. In dat geval is het essentieel om de busarchitectuur te begrijpen en de cache te omzeilen door direct via een toegangspoort te lezen.