Environnement u-boot sécurisé

12 janvier 2025 ― Pierre-Loup GOSSE

Résumé ― La sécurisation d'u-boot repose en partie sur la protection de son environnement. Le stocker dans une mémoire volatile le préserve des modifications maveillantes à froid, mais complique l'utilisation des systèmes de mise à jour comme SWUpdate ou RAUC qui nécessitent une écriture persistante dans l'environnement. Dans cet article nous verrons comment configurer l'environnement u-boot pour concilier ces deux besoins.

Environnement u-boot

L'environnement u-boot, dit u-env, contient une liste de variable permettant de contrôler le processus de démarrage vers le kernel. Au format key = value, ces variables peuvent contenir de simple valeur comme définir une suite d'instruction à exécuter. Par exemple, prenons ce petit environnement text-based :

variable=toto
print_variable=if [ -n ${variable} ] ; then echo ${variable} ; fi

Si chargé dans u-boot, exécuter run print_variable affichera "toto". On dit alors que print_variable est une commande.

Certaines variables sont utilisées par u-boot, par exemple bootcmd est la variable (commande) exécutée automatiquement au démarrage.

Avant d'aborder la sécurisation de l'u-env, voyons dans un premier temps comment celui-ci est stocké et chargé en mémoire.

Approfondir
Voici un article détaillant l'environnement u-boot.

Note
Les extraits de code sont volontairement simplifiés pour une meilleure lisibilité, veuillez-vous référer aux sources pour toute compréhension plus appronfondie.

Emplacement de l'u-env

L'u-env ne prend pas beaucoup d'espace mémoire, en général quelques kilo-octet suffisent, permettant ainsi à l'environnement d'être stocké là où il y a de la place pour peu qu'u-boot le supporte.

L'ensemble des stockages supportés, dit des locations, sont déclarés dans le dossier env d'u-boot. On retrouve dans le fichier Kconfig les variables ENV_IS_IN_* des locations supportées ainsi que leur driver .c associé. Par exemple, le fichier nand.c déclare l'env driver de la location NAND :

env/nand.c

#include <env_internal.h>

U_BOOT_ENV_LOCATION(nand) = {
    .location   = ENVL_NAND,
    ENV_NAME("NAND")
    .load       = env_nand_load,
#if defined(CMD_SAVEENV)
    .save       = env_save_ptr(env_nand_save),
#endif
    .init       = env_nand_init,
};

L'header include/env_internal.h énumère les locations supportées par u-boot et leur structure :

include/env_internal.h

enum env_location {
        ENVL_UNKNOWN,
        ENVL_EEPROM,
        ENVL_EXT4,
        ENVL_NAND,
        [...]
        ENVL_NOWHERE,
};

struct env_driver {
        const char *name;
        enum env_location location;
        int (*load)(void);
        int (*save)(void);
        int (*erase)(void);
        int (*init)(void);
}

/* Declare a new environment location driver */
#define U_BOOT_ENV_LOCATION(__name)                                     \
        ll_entry_declare(struct env_driver, __name, env_driver)

À la compilation, les locations supportées par défaut sont énumérées par ordre de priorité dans le tableau env_locations présent le fichier env.c :

env/env.c

static enum env_location env_locations[] = {
#ifdef CONFIG_ENV_IS_IN_EEPROM
        ENVL_EEPROM,
#endif
#ifdef CONFIG_ENV_IS_IN_EXT4
        ENVL_EXT4,
#endif
#ifdef CONFIG_ENV_IS_IN_NAND
        ENVL_NAND,
#endif
[...]
#ifdef CONFIG_ENV_IS_NOWHERE
        ENVL_NOWHERE,
#endif
};

Nous verrons par la suite comment u-boot charge l'u-env à partir de ces drivers.

Environnement par défaut

U-boot posséde un u-env par défaut intégré au binaire. Il existe deux façons de définir cet environnement par défaut :

  • Old-style C environment : la macro CFG_EXTRA_ENV_SETTINGS déclare l'environnement, chaque ligne doit se terminer par "\0". Chaque board déclare généralement sa config dans un header dans le dossier include/configs.

  • Text-based environment : utilise un fichier .env comme environnement par défaut si CONFIG_USE_DEFAULT_ENV_FILE est activé, le nom du fichier peut être défini avec CONFIG_ENV_SOURCE_FILE.

Dans les deux cas, l'environnement par défaut se retrouve déclaré dans la variable default_environment dans le header env_default.h :

env/env_default.h

const char default_environment[] = {
#ifndef CONFIG_USE_DEFAULT_ENV_FILE

#ifdef  CONFIG_USE_BOOTARGS
    "bootargs=" CONFIG_BOOTARGS         "\0"
#endif

[...]

#ifdef  CFG_EXTRA_ENV_SETTINGS
    CFG_EXTRA_ENV_SETTINGS
#endif

#else /* CONFIG_USE_DEFAULT_ENV_FILE */

#include "generated/defaultenv_autogenerated.h"

#endif
};

Cet environnement par défaut peut-être chargé par la location ENVL_NOWHERE.

Exemple de configuration

Pour illustrer les notions présentées prenons le cas d'une board avec une eMMC. L'eMMC possède 4 partitions hardware : deux partitions boot, une partition RPMB et une partition user. Nous souhaitons placer notre u-env dans la partition user, juste après la table de partition GPT. Pour être large nous choisissons la position 0x5000 (20480) soit le secteur 40 (512 bytes * 40). Notre environnement fera 8Kb (0x2000) et nous positionnons une copie à la suite.

À partir de ces informations, voici la configuration u-boot :

CONFIG_ENV_IS_IN_MMC=y              /* Supporte la location MMC */
CONFIG_SYS_MMC_ENV_DEV=1            /* Numéro du device MMC (équivalent au /dev/mmcblk1 sous Linux) */
CONFIG_SYS_MMC_ENV_PART=0           /* Partition HARDWARE de l'eMMC : 0 = partition user, 1 = première partition boot, 2 = deuxième partition boot */
CONFIG_ENV_SIZE=0x2000              /* Taille de l'u-env */
CONFIG_ENV_OFFSET=0x5000            /* Emplacement dans la partition hardware*/
CONFIG_SYS_REDUNDAND_ENVIRONMENT=y  /* Utilisation d'une copie */
CONFIG_ENV_OFFSET_REDUND=0x7000     /* Emplacement de la copie, en général à la suite (OFFSET + SIZE) */

Chargement de l'u-env

Au démarrage, u-boot cherche à charger son environnement. Le point d'entrée est la fonction env_load présent dans le fichier env.c :

env/env.c

int env_load(void)
{
    [...]

    for (prio = 0; (drv = env_driver_lookup(ENVOP_LOAD, prio)); prio++) {

        printf("Loading Environment from %s... ", drv->name);

        ret = drv->load();
        if (!ret) {
            printf("OK\n");
            return 0;
        }

        [...]

    }

    [...]

    return -ENODEV;
}

Cette fonction passe en revue les drivers par ordre de priorité croissant et s'arrête dès qu'un environnement est chargé. La fonction env_driver_lookup fait appel à la fonction weak env_get_location qui retourne la location du tableau env_locations présent à l'index prio :

env/env.c

__weak enum env_location env_get_location(enum env_operation op, int prio)
{
    return arch_env_get_location(op, prio);
}

__weak enum env_location arch_env_get_location(enum env_operation op, int prio)
{
    if (prio >= ARRAY_SIZE(env_locations))
        return ENVL_UNKNOWN;

    return env_locations[prio];
}

Un contructeur peut définir sa propre gestion des locations. Prenons le cas de STM, la fonction env_get_location est redéfinie dans le fichier stm32mp1.c et remplace la fonction weak :

board/st/stm32mp1/stm32mp1.c

enum env_location env_get_location(enum env_operation op, int prio)
{
    u32 bootmode = get_bootmode();

    if (prio)
        return ENVL_UNKNOWN;

    switch (bootmode & TAMP_BOOT_DEVICE_MASK) {
    case BOOT_FLASH_SD:
    case BOOT_FLASH_EMMC:
        if (CONFIG_IS_ENABLED(ENV_IS_IN_MMC))
            return ENVL_MMC;
        else
            return ENVL_NOWHERE;

    case BOOT_FLASH_NAND:
    case BOOT_FLASH_SPINAND:
        if (IS_ENABLED(CONFIG_ENV_IS_IN_UBI))
            return ENVL_UBI;
        else
            return ENVL_NOWHERE;

    case BOOT_FLASH_NOR:
        if (CONFIG_IS_ENABLED(ENV_IS_IN_SPI_FLASH))
            return ENVL_SPI_FLASH;
        else
            return ENVL_NOWHERE;

    default:
        return ENVL_NOWHERE;
    }
}

Ici, aucun tableau de location n'est utilisé. La location dépend du device surlequel la board démarre, si la location n'est pas supportée alors l'environnement par défaut (ENVL_NOWHERE) est utilisé.

Sécuriser l'u-env

Il est facile de constater que l'u-env est modifiable si celui-ci est présent sur un stockage persistant. Si nous reprenons notre exemple d'u-env dans l'eMMC, un binaire tel que fw_setenv du paquet fw-utils, permet de modifier l'u-env depuis l'userspace Linux. Lorsqu'il s'agit de sécuriser notre système cela représente une faille de sécurité non négligeable.

L'article Securing U-boot, qui présente entre autres des vecteurs d'attaque, aborbe le sujet de l'u-env. Par le non support d'u-env signé par u-boot, la solution proposée est de n'utiliser que l'u-env par défaut, la modification à chaud depuis la RAM peut alors être bloquée en désactivant le shell u-boot et en retirant le JTAG sur la board.

Cependant, comment faire lorsqu'il est nécessaire d'avoir des variables persistantes ? Notamment pour les systèmes de mise à jour tel que SWUpdate ou RAUC, de plus en plus utilisés dans les systèmes embarqués. C'est ce que nous allons voir en combinant plusieurs fonctionnalités d'u-boot.

Writeable list

U-boot offre une fonctionnalité intéressante : la writeable list. Cette fonctionnalité repose sur les flags des variables u-boot et permet de définir une liste de variable accessible en écriture dans l'u-env.

Flags

Les flags permettent de spécifier le type de donnée des variables et leur accès, dont :

  • s : string
  • d : decimal
  • x : hexadecimal
  • b : boolean
  • r : read-only
  • o : write-once
  • w : writeable

La syntaxe "variable:flag" attribue le ou les flag à la variable, par exemple "foo:s" n'autorise que les chaînes de caractère pour la variable foo. Les structures sont déclarées dans le header env_flags.h.

Il existe deux méthodes pour définir les flags :

  • Soit en définissant une variable .flags dans l'u-env, à chaud avec setenv .flags bar:sr ou bien à la compilation avec CONFIG_ENV_FLAGS_LIST_DEFAULT.
  • Soit en définissant CFG_ENV_FLAGS_LIST_STATIC, intégré à la macro ENV_FLAGS_LIST_STATIC.

C'est la fonction env_flags_lookup présente dans le fichier flags.c qui retourne la liste des flags pour une variable donnée :

env/flags.c

/*
 * Look for flags in a provided list and failing that the static list
 */
static inline int env_flags_lookup(const char *flags_list, const char *name,
    char *flags)
{
    int ret = 1;

    if (!flags)
        /* bad parameter */
        return -1;

    /* try the env first */
    if (flags_list)
        ret = env_attr_lookup(flags_list, name, flags);

    if (ret != 0)
        /* if not found in the env, look in the static list */
        ret = env_attr_lookup(ENV_FLAGS_LIST_STATIC, name, flags);

    return ret;
}

La fonction regarde en premier lieu dans la variable flags_list puis dans notre liste compilée ENV_FLAGS_LIST_STATIC.

CONFIG_ENV_WRITEABLE_LIST

L'activation de la writeable list avec CONFIG_ENV_WRITEABLE_LIST implique des modifications à plusieurs endroits.

env/Kconfig

config ENV_WRITEABLE_LIST
    bool "Permit write access only to listed variables"
    select ENV_APPEND
    help
        If defined, only environment variables which explicitly set the 'w'
        writeable flag can be written and modified at runtime. No variables
        can be otherwise created, written or imported into the environment.

Flags par défaut

Les flags viennent de ENV_FLAGS_LIST_STATIC et non plus de la variable .flags comme nous le montre la fonction env_flags_init du fichier flags.c :

env/flags.c

void env_flags_init(struct env_entry *var_entry)
{
    [...]

    if (first_call) {
#ifdef CONFIG_ENV_WRITEABLE_LIST
        flags_list = ENV_FLAGS_LIST_STATIC;
#else
        flags_list = env_get(ENV_FLAGS_VAR);
#endif
        first_call = 0;
    }
    /* look in the ".flags" and static for a reference to this variable */
    ret = env_flags_lookup(flags_list, var_name, flags);

    [...]
}

Environnement par défaut

La fonction env_load présentée précédemment charge en premier lieu l'environnement par défaut :

env/env.c

int env_load(void)
{
    [...]

    if (CONFIG_IS_ENABLED(ENV_WRITEABLE_LIST)) {
        /*
         * When using a list of writeable variables, the baseline comes
         * from the built-in default env. So load this first.
         */
        env_set_default(NULL, 0);
    }

    for (prio = 0; (drv = env_driver_lookup(ENVOP_LOAD, prio)); prio++) {
        [...]
    }
}

CONFIG_ENV_APPEND

L'option CONFIG_ENV_APPEND est activée par dépendance dans le Kconfig. Cette option permet de superposer plusieurs environnements dans le hashtable de l'u-env : l'environnement par défaut et l'environnement de la location.

Comportement de l'u-env

Les changements apportées par l'activation de la writeable list contraint de définir dans la macro CONFIG_ENV_FLAGS_LIST_STATIC les variables, et leur flag, pouvant être écrit dans l'environnement persistant.

En réalité, la vérification est aussi réalisée à la lecture de l'environnement. En effet, bien qu'à partir des flags u-boot est capable d'interdire l'écriture d'une valeur qui n'est pas conforme, cela n'empêche pas un tiers malveillant d'écriture par un autre moyen dans l'environnement (fw_setenv par exemple). Ainsi, la writeable list agit aussi comme un filtre lors de la lecture de l'environnement et refuse les variables qui non conforme à leur flag. Par exemple, une variable toto définie dans l'environnement par défaut avec le flag d ne pourra pas être écrasée par une variable toto="string" venant de l'eMMC, u-boot l'ignora tout simplement.

Exemple avec SWUpdate

Poursuivons avec notre exemple précédent. Nous décidons d'utiliser SWUpdate pour mettre à jour à distance notre distribution Linux avec la méthode A/B. On utilise la fonctionnalité de bootcount d'u-boot avec CONFIG_BOOTCOUNT_LIMIT et CONFIG_BOOTCOUNT_ENV :

drivers/bootcount/Kconfig

menuconfig BOOTCOUNT_LIMIT
    bool "Enable support for checking boot count limit"
    help
      Enable checking for exceeding the boot count limit.
      More information: https://docs.u-boot.org/en/latest/api/bootcount.html

[...]

config BOOTCOUNT_ENV
    bool "Boot counter in environment"
    help
      If no softreset save registers are found on the hardware
      "bootcount" is stored in the environment. To prevent a
      saveenv on all reboots, the environment variable
      "upgrade_available" is used. If "upgrade_available" is
      0, "bootcount" is always 0. If "upgrade_available" is 1,
          "bootcount" is incremented in the environment.
      So the Userspace Application must set the "upgrade_available"
      and "bootcount" variables to 0, if the system booted successfully.

Cela nécessite d'écrire deux variables : upgrade_available et bootcount, ce à quoi on ajoute une variable slot pour définir quel rootfs démarrer. Voici le code à ajouter dans la config de notre board :

#define CFG_ENV_FLAGS_LIST_STATIC
    "slot:dw," \
    "upgrade_available:dw," \
    "bootcount:dw"

#define SWUPDATE_ENV_SETTINGS \
    "slot=0\0" \
    "upgrade_available=0\0" \
    "bootcount=0\0" \
    "altbootcmd=" \
        "echo Rollback to previous rootfs; " \
        "if test \"x${slot}\" = \"x0\"; then " \
            "setenv slot 1; " \
        "else " \
            "setenv slot 0; " \
        "fi; setenv bootcount 0; saveenv; saveenv; run bootcmd\0"

#define CFG_EXTRA_ENV_SETTINGS \
    SWUPDATE_ENV_SETTINGS

Appronfondir
Voici un article détaillant l'intégration de SWUpdate avec u-boot.

Conclusion

L'usage de la writeable list avec la combinaison d'un environnement par défaut et persistant permet de sécuriser l'u-env. Vous noterez cependant qu'il est nécessaire de désactiver l'accès au shell et ainsi que le JTAG pour bloquer toutes modifications à chaud.