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)

Cheval de Troie : retour aux sources

Magazine
Marque
MISC
Numéro
121
Mois de parution
mai 2022
Spécialité(s)
Résumé

Le premier novembre 2021, les CVE-2021-42574 et CVE-2021-42694 étaient rendues publiques. Ces deux CVE rendent comptent des limitations de l’utilisation de caractères Unicode dans les identifiants, commentaires et/ou chaînes de caractères littérales de codes sources. Elles sont intéressantes par deux aspects : elles sont relativement agnostiques au langage de programmation sous-jacent, et elles utilisent comme cheval de Troie le rendu fait par certains outils, notamment les forges logicielles en ligne, de certains caractères Unicode.

Les options de compilation : Fun with Flags

Magazine
Marque
MISC
Numéro
121
Mois de parution
mai 2022
Spécialité(s)
Résumé

Machines et développeurs ont parfois du mal à communiquer. D’un côté l’esprit humain, avec sa volonté d’abstraire et de penser fonctionnel. De l’autre côté, la machine et sa froide rigueur électronique. Entre les deux, il y a un pont : le compilateur. Mais comment prend-il en compte les aspects sécurité ?

La compilation statique démythifiée - Une plongée dans les entrailles de mon compilo

Magazine
Marque
MISC
Numéro
120
Mois de parution
mars 2022
Spécialité(s)
Résumé

Dans le hors-série 24 de MISC [0], nous avions montré que les outils et techniques de la compilation sont aujourd'hui des éléments incontournables dans le domaine de la sécurité des applications, que ce soit pour les protéger, les obfusquer, et même de manière plus surprenante les reverser.Nous plongeons ici plus en détail dans les entrailles d'un compilateur, Clang/LLVM [LLVM] : ses différentes étapes, son architecture, sa représentation interne, ses optimisations. Nous illustrerons les transformations les plus communes par un exemple concret, et nous verrons que si certaines structures de code observées dans les binaires sont facilement reconnaissables, d'autres peuvent surprendre...

Les derniers articles Premiums

Les derniers articles Premium

Stubby : protection de votre vie privée via le chiffrement des requêtes DNS

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

Depuis les révélations d’Edward Snowden sur l’espionnage de masse des communications sur Internet par la NSA, un effort massif a été fait pour protéger la vie en ligne des internautes. Cet effort s’est principalement concentré sur les outils de communication avec la généralisation de l’usage du chiffrement sur le web (désormais, plus de 90 % des échanges se font en HTTPS) et l’adoption en masse des messageries utilisant des protocoles de chiffrement de bout en bout. Cependant, toutes ces communications, bien que chiffrées, utilisent un protocole qui, lui, n’est pas chiffré par défaut, loin de là : le DNS. Voyons ensemble quels sont les risques que cela induit pour les internautes et comment nous pouvons améliorer la situation.

Surveillez la consommation énergétique de votre code

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

Être en mesure de surveiller la consommation énergétique de nos applications est une idée attrayante, qui n'est que trop souvent mise à la marge aujourd'hui. C'est d'ailleurs paradoxal, quand on pense que de plus en plus de voitures permettent de connaître la consommation instantanée et la consommation moyenne du véhicule, mais que nos chers ordinateurs, fleurons de la technologie, ne le permettent pas pour nos applications... Mais c'est aussi une tendance qui s'affirme petit à petit et à laquelle à terme, il devrait être difficile d'échapper. Car même si ce n'est qu'un effet de bord, elle nous amène à créer des programmes plus efficaces, qui sont également moins chers à exécuter.

Donnez une autre dimension à vos logs avec Vector

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

Avoir des informations précises et détaillées sur ce qu’il se passe dans une infrastructure, et sur les applications qu'elle héberge est un enjeu critique pour votre business. Cependant, ça demande du temps, temps qu'on préfère parfois se réserver pour d'autres tâches jugées plus prioritaires. Mais qu'un système plante, qu'une application perde les pédales ou qu'une faille de sécurité soit découverte et c'est la panique à bord ! Alors je vous le demande, qui voudrait rester aveugle quand l'observabilité a tout à vous offrir ?

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 62 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous