Voyage en C++ie : un code d’exception

Magazine
Marque
MISC
Numéro
119
Mois de parution
janvier 2022
Spécialité(s)


Résumé

Cet article s'inscrit dans la continuation d'une série d'articles sur les artefacts bas niveau que l'on peut rencontrer dans un binaire compilé à partir de sources en C++. Cet épisode aborde (enfin !) un des morceaux les plus indigestes de la traduction de C++ en assembleur : la gestion d'exceptions sous Linux, suivant la spécification dite Itanium.


Body

Le langage C++ offre plusieurs moyens d'interagir avec le système d'exceptions. On peut lever une exception (via l'instruction throw <value>), attraper une exception en filtrant suivant le type (via l'instruction catch(<type> <identifier>)) ou indépendamment de son type (via l'instruction catch(...)). Il est également possible de relancer l'exception en cours de traitement (via l'instruction throw;).

Depuis C++11, une fonction peut être déclarée noexcept pour indiquer qu'elle ne peut pas lancer d'exception, là où une fonction non décorée pourrait en lancer. Et bien évidemment, le mot clef try permet de délimiter un bloc de code où une exception pourrait être levée.

Le fichier d'en-tête <exception> permet de spécialiser la manière de gérer certaines situations exceptionnelles (huhu) : std::set_terminate(...) modifie la fonction appelée quand une exception n'est pas gérée, std::current_exception() renvoie l'exception en cours de traitement.

Plutôt que de paraphraser le néanmoins passionnant Itanium C++ ABI: Exception Handling [1], le reste de cet article aborde le sujet de manière inductive, à travers l'observation des artefacts produits par la compilation de petits fragments de code et l’analyse du code de la bibliothèque standard C++.

1. Le lancer d'exceptions

Le code suivant :

void foo() {
    throw int(3);
}

génère deux appels de fonctions : 1) __cxa_allocate_exception et 2) __cxa_throw. On peut trouver une implémentation documentée de ces deux appels dans le code source de la libcxxabi [4], l'implémentation de la bibliothèque standard C++ portée par le projet LLVM.

La première de ces fonctions, dont la signature est void *__cxa_allocate_exception(size_t thrown_size) throw(), est décrite comme « allouant un objet de type _cxa_exception, le remplissant de zéro et réservant thrown_size octets également initialisés à zéro à la suite pour pouvoir y allouer l’exception ». C’est donc un allocateur mémoire spécialisé qui alloue (sur le tas) assez de mémoire pour stocker l'exception à lever.

La seconde de ces fonctions, dont la signature est void __cxa_throw(void *thrown_object, std::type_info *tinfo, void (*dest)(void *)), est richement commentée. On y apprend entre autres qu’elle « récupère l’en-tête d’exception à partir d’une adresse d’exception, sauvegarde les gestionnaires unexpected_handler et terminate_handler courants, met à jour l’en-tête de l’exception avec des informations de type et qu’elle appelle _Unwind_RaiseException de la bibliothèque unwind en lui passant thrown_object en paramètre, et enfin elle incrémente le compteur d’exceptions non gérées ».

unexpected_handler et terminate_handler sont liés à std::set_unexpected et std::set_terminate que l’on détaille en fin d’article.

On apprend donc que la gestion d’exceptions met en œuvre des fonctions qui font partie de la bibliothèque standard, et d’autres qui font partie d’une bibliothèque spécialisée dans la gestion des exceptions…

2. L'attrape-exception

Après ce bref aperçu sur le lancer d'exception, regardons comment une exception est attrapée en compilant le code suivant :

#include <cstdio>
void foo();
void pain() {
  try {foo();}
  catch(...) {puts("whoot");}
}

Trois appels de fonction sortent du lot : 1) __cxa_begin_catch 2) __cxa_end_catch et 3)_Unwind_Resume. Un retour aux sources (huhu) se révèle là encore fort instructif pour la fonction de signature void* __cxa_begin_catch(void* unwind_arg) throw() : « Pour les exceptions natives, on incrémente le compteur d’exceptions, on ajoute l’exception à la pile des exceptions en cours de gestion si elle n’y est pas déjà (à cause d’un rethrow), on décrémente le compteur d’exceptions non gérées et on renvoie un pointeur sur l’exception, extrait de __cxa_exception ».

On comprend assez rapidement l'intérêt de la fonction __cxa_end_catch à la lecture des commentaires :

/* (...)
* This routine can be called for either a native or foreign exception.
* For a native exception:
* * Locates the most recently caught exception and decrements its handler count.
* * Removes the exception from the caught exception stack, if the handler count goes to zero.
* * If the handler count goes down to zero, and the exception was not re-thrown
*    by throw, it locates the primary exception (which may be the same as the one
*    it's handling) and decrements its reference count. If that reference count
*    goes to zero, the function destroys the exception. In any case, if the current
*    exception is a dependent exception, it destroys that.
*
* (...)
*/
void __cxa_end_catch();

On apprend ainsi que la mémoire allouée pour les exceptions stockées dans __cxa_exception est gérée à travers un compteur de références !

On ne trouve aucune référence à _Unwind_Resume dans la libcxx. Normal, le projet LLVM fournit une implémentation de la bibliothèque unwind dans un projet indépendant : libunwind. On y lit que cette fonction fait partie d’un processus complexe en deux phases, une phase de recherche et une phase de nettoyage passe le contrôle de l’exécution à la personality function attachée à chaque frame traversée. À charge de cette fonction de continuer ou non la traversée de la pile d’appel pour continuer le processus de gestion d’exception. Il est peut-être temps de comprendre le rôle de cette dernière.

3. Une personnalité hors du commun

Dans les codes assembleurs précédents, on a omis de mentionner la présence d'une globale nommée __gxx_personality_v0. C'est la clef pour la suite de notre quête. On trouve le code source, heureusement commenté, de cette fonction dans la libcxxabi (commentaires simplifiés pour les besoins de l'article).

/*
The personality function branches on actions like so:
 
_UA_SEARCH_PHASE
 
     Scan for anything that could stop unwinding:
 
        1. A catch clause that will catch this exception
        2. A catch (...)
        3. An exception spec that will catch this exception
     If a handler is found
         Save state in header
         return _URC_HANDLER_FOUND
     Else a handler not found
         return _URC_CONTINUE_UNWIND
 
_UA_CLEANUP_PHASE
 
     If _UA_HANDLER_FRAME
         If _UA_FORCE_UNWIND
             How did this happen? return _URC_FATAL_PHASE2_ERROR
         Recover state from header
         Transfer control to landing pad. return _URC_INSTALL_CONTEXT
 
     Else
 
         This branch handles both normal C++ non-catching handlers (cleanups)
           and forced unwinding.
         Scan for anything that can not stop unwinding:
 
             1. A cleanup.
 
         If a cleanup is found
             transfer control to it. return _URC_INSTALL_CONTEXT
         Else a cleanup is not found: return _URC_CONTINUE_UNWIND
*/
LIBCXXABI_FUNC_VIS _Unwind_Reason_Code __gxx_personality_v0(...)

La personality function se trouve donc bien être au cœur de la gestion du flot de contrôle spécifique aux exceptions. Lors de l'appel à _Unwind_RaiseException déclenché par __cxa_throw, un processus de parcours de la pile d'appel commence (stack unwinding). Ce processus se déroule en deux phases : une phase de recherche (_UA_SEARCH_PHASE) qui parcourt la pile d'appel et met à jour le exception header suivant la présence ou non de gestionnaire d'exceptions ; une phase de nettoyage qui utilise les informations collectées lors de la phase précédente pour transférer le contrôle au landing pad associé à cette fonction.

4. Quelques détails de plus

L'inspection du code de std::set_terminate, std::set_unexpected et std::current_exception permet de confirmer l'intuition que nous sommes en train de développer.

On découvre que std::set_terminate manipule une variable globale __cxa_terminate_handler utilisée pour positionner un des champs du exception header. Ce champ est utilisé dans de nombreux cas d'erreur (p.e. en cas de corruption de l'exception header), mais aussi si aucun gestionnaire d'exceptions n'est trouvé. On appelle alors :

// It is possible that no eh table entry specify how to handle
// this exception. By spec, terminate it immediately.
call_terminate(native_exception, unwind_exception);

std::set_unexpected positionne une fonction appelée quand une exception est levée alors que les spécifications d’une fonction l’empêche, e.g. noexcept .

Quant à std::current_exception, cette fonction appelle à son tour __cxa_current_primary_exception dont l'implémentation ne nous surprend pas :

/*
    Returns a pointer to the thrown object (if any) at the top of the
    caughtExceptions stack. Atomically increment the exception's referenceCount.
    If there is no such thrown object or if the thrown object is foreign,
    returns null. (...)
*/
void *__cxa_current_primary_exception() throw() {
    /* ... */
    void* thrown_object = thrown_object_from_cxa_exception(exception_header);
    __cxa_increment_exception_refcount(thrown_object);
    return thrown_object;
}

Autrement dit, on reconstruit l'exception en cours à partir du exception header, on gère le compteur de référence et aille don [5].

Reste la question de l'attribut noexcept que l'on peut adjoindre à une déclaration de fonction. Quelle différence entre les appels à foo et bar dans le code suivant ?

void impl();
void foo() {
    return impl();
}
void bar() noexcept {
    return impl();
}

La seule différence dans le code généré vient de la présence d'une pseudo-instruction .cfi_personality 0x3,__gxx_personality_v0. La documentation de notre assembleur préféré [2] indique que cette instruction associe une personality fonction à la fonction en cours. On note aussi la présence d'une section nommée gcc_except_table qui contient des labels nommés LSDA (Language-Specific Data Area) et LSDACS (LSDA Call Site). La lecture de la spécification nous apprend que la LSDA contient des informations spécifiques à la fonction à laquelle elle est attachée (landing pad start pointer, types table pointer), tandis que la LSDACS contient des données lues par la personality function et spécifiques à chaque instruction pouvant lever une exception (offset du site d'appel, offset du landing pad, etc.).

On notera également la présence d'une section nommée eh_frame qui contient quant à elle (au format DWARF) les informations dont l' unwinder a besoin pour nettoyer la pile lors du stack unwinding.

5. Assemblage

Tentons de résumer le cycle de vie d’une exception :

  1. Le compilateur génère un appel à __cxa_allocate_exception puis __cxa_throw. lorsqu’une exception est levée. L’une alloue l’exception sur le tas, l’autre transfère le flot de contrôle à _Unwind_RaiseException de libunwind.
  2. _Unwind_RaiseException parcourt chaque frame et regarde la personality function qui y est attachée pour lui transférer le contrôle. Celle-ci, en fonction des informations présentes dans la Language-Specific Data Area, détermine si l’exception peut être gérée localement ou non.
  3. Si aucune frame ne gère l’exception, un gestionnaire spécifique, __cxa_terminate_handler, est appelé.
  4. Si un gestionnaire est trouvé, _Unwind_RaiseException retraverse la pile d’appel et appelle à nouveau la personality fonction en indiquant qu’il faut effectuer le nettoyage de la frame, jusqu’à arriver à la frame déterminée à l’étape 2 où l’exception est proprement gérée.

Conclusion

Après avoir mis bout à bout les différents éléments d'information que l'on a pu collecter, il est bon de confronter l'image mentale que l'on a pu se construire à la froide réalité de la spécification. Pour ceux qui désirent aller dans cette direction, je leur recommande fortement la lecture de Itanium C++ ABI: Exception Handling référencé en fin d'article. C'est peut-être aussi le bon moment pour jeter un œil nouveau à la FAQ de l'ISOC++ sur les exceptions !

Remerciements

L’auteur tient à remercier Béartice Creusillet et Adrien Guinet pour leur relecture. Vous assurez.

Références

[0] FAQ de l'ISOC++ sur les exceptions : https://isocpp.org/wiki/faq/exceptionsdiamond.fr/

[1] Itanium C++ ABI: Exception Handling : https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html

[2] Manuel Binutils, section CFI : https://sourceware.org/binutils/docs/as/CFI-directives.html

[3] Une approche encore plus orientée « mains dans le cambouis » que celle proposée dans cet article : https://monkeywritescode.blogspot.com/p/c-exceptions-under-hood.html

[4] Code source des différents projets rattachés à LLVM : https://github.com/llvm/llvm-project

[5] Vieille expression paysanne bourguignonne signifiant « allons-y »



Article rédigé par

Par le(s) même(s) auteur(s)

Garder ses parties privées

Magazine
Marque
MISC
HS n°
Numéro
30
Mois de parution
octobre 2024
Spécialité(s)
Résumé

Il y a 19 ans, Ulrich Drepper, alors développeur chez Red Hat et un des contributeurs principaux de la glibc, ajoutait au changelog de la glibc la ligne suivante : sysdeps/i386/bsd-_setjmp.S: Use PTR_MANGLE for PC if defined, inaugurant ainsi l’arrivée de la protection des pointeurs de fonction stockés dans des structures internes à la glibc.

Des soucis à la chaîne

Magazine
Marque
MISC
HS n°
Numéro
30
Mois de parution
octobre 2024
Spécialité(s)
Résumé

L’histoire, ou plutôt l’Histoire, est une coquine. Et quand Dennis Ritchie et Ken Thompson inventent le langage C en 1972, ils prennent une décision anodine, une micro-optimisation qui fait gagner quelques octets, mais qui aura un impact important sur la sécurité de nombreux systèmes : en C, les chaînes de caractères sont terminées par un octet positionné à zéro.

Édito

Magazine
Marque
MISC
HS n°
Numéro
30
Mois de parution
octobre 2024
Résumé

En regardant la liste des 25 failles les plus dangereuses éditées par MITRE chaque année, on ne peut qu’être frappé par la présence (ou la persistance) de thèmes bien connus : écriture illégale dans une zone mémoire, utilisation d’une zone mémoire désallouée, lecture illégale d’une zone mémoire, déréférencement de pointeur NULL, dépassement de la capacité d’un entier… Autant de sujets qui sont pourtant abordés dans les premiers chapitres de tout bouquin traitant de la sécurité logicielle. Ce qui n’en fait pas pour autant des sujets faciles dès lors que les considérations de base de code existant et de performances rentrent en compte. C’est compliqué l’optimisation multicritère !

Les derniers articles Premiums

Les derniers articles Premium

La place de l’Intelligence Artificielle dans les entreprises

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

L’intelligence artificielle est en train de redéfinir le paysage professionnel. De l’automatisation des tâches répétitives à la cybersécurité, en passant par l’analyse des données, l’IA s’immisce dans tous les aspects de l’entreprise moderne. Toutefois, cette révolution technologique soulève des questions éthiques et sociétales, notamment sur l’avenir des emplois. Cet article se penche sur l’évolution de l’IA, ses applications variées, et les enjeux qu’elle engendre dans le monde du travail.

Petit guide d’outils open source pour le télétravail

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Ah le Covid ! Si en cette période de nombreux cas resurgissent, ce n’est rien comparé aux vagues que nous avons connues en 2020 et 2021. Ce fléau a contraint une large partie de la population à faire ce que tout le monde connaît sous le nom de télétravail. Nous avons dû changer nos habitudes et avons dû apprendre à utiliser de nombreux outils collaboratifs, de visioconférence, etc., dont tout le monde n’était pas habitué. Dans cet article, nous passons en revue quelques outils open source utiles pour le travail à la maison. En effet, pour les adeptes du costume en haut et du pyjama en bas, la communauté open source s’est démenée pour proposer des alternatives aux outils propriétaires et payants.

Sécurisez vos applications web : comment Symfony vous protège des menaces courantes

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Les frameworks tels que Symfony ont bouleversé le développement web en apportant une structure solide et des outils performants. Malgré ces qualités, nous pouvons découvrir d’innombrables vulnérabilités. Cet article met le doigt sur les failles de sécurité les plus fréquentes qui affectent même les environnements les plus robustes. De l’injection de requêtes à distance à l’exécution de scripts malveillants, découvrez comment ces failles peuvent mettre en péril vos applications et, surtout, comment vous en prémunir.

Bash des temps modernes

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Les scripts Shell, et Bash spécifiquement, demeurent un standard, de facto, de notre industrie. Ils forment un composant primordial de toute distribution Linux, mais c’est aussi un outil de prédilection pour implémenter de nombreuses tâches d’automatisation, en particulier dans le « Cloud », par eux-mêmes ou conjointement à des solutions telles que Ansible. Pour toutes ces raisons et bien d’autres encore, savoir les concevoir de manière robuste et idempotente est crucial.

Les listes de lecture

11 article(s) - ajoutée le 01/07/2020
Clé de voûte d'une infrastructure Windows, Active Directory est l'une des cibles les plus appréciées des attaquants. Les articles regroupés dans cette liste vous permettront de découvrir l'état de la menace, les attaques et, bien sûr, les contre-mesures.
8 article(s) - ajoutée le 13/10/2020
Découvrez les méthodologies d'analyse de la sécurité des terminaux mobiles au travers d'exemples concrets sur Android et iOS.
10 article(s) - ajoutée le 13/10/2020
Vous retrouverez ici un ensemble d'articles sur les usages contemporains de la cryptographie (whitebox, courbes elliptiques, embarqué, post-quantique), qu'il s'agisse de rechercher des vulnérabilités ou simplement comprendre les fondamentaux du domaine.
Voir les 67 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous