Journal Aujourd'hui, je euggubed un programme dans GDB...

Posté par . Licence CC by-sa.
73
7
juil.
2019

Sommaire

Bonjour bonjour !

En ce moment, pour beaucoup de fun, je tente de bidouiller une grammaire générée avec flex et bison, en mode un peu "boite noire" (interdiction de modifier la grammaire d'origine, et à vrai dire je sais même pas quelle est la tronche exacte du fichier source, je joue avec libpg_query pour ceux que ça intéresse).
Mais quand on tombe sur une erreur, la backtrace est fort peu instructive :

#0  base_yyerror (base_yylloc=0x7fffffffc124, msg=0x5555555f5104 "syntax error", yyscanner=0x0) at src/postgres/src_backend_parser_gram.c:44051
#1  base_yyparse (yyscanner=yyscanner@entry=0x55555584b3a8) at src/postgres/src_backend_parser_gram.c:43856
#2  0x00005555555727bc in raw_parser (str=str@entry=0x5555555a6008 "SELECT 1, ") at src/postgres/src_backend_parser_parser.c:61

Toutes les erreurs sont émises depuis la même ligne de base_yyparse, et il n'y a pas de sous-fonctions : cette fonction fait 18000 lignes…
En effet, bison génère du goto en masse, et qui dit goto dit pas de frame dans la stack. Qui dit pas de frame dans la stack dit pas d'entrée dans la backtrace. Qui dit pas d'entrée dans la backtrace dit pas content.
Alors pour plus de fun, on va sortir une petite perle introduite il y a deux ans de cela dans GDB : le reverse-debug.

0) le reverse-debug, mais kesako ?

Comme montré dans le cas d'une grammaire générée par notre ami m. bison, qui n'est pas si méchant que ça, poser des breakpoints et réaliser du debug à base de backtrace a des limites. On aimerait bien disposer de plus dans notre debuggueur que d'un bouton pause et de boutons ligne/function/instruction suivante.
On a donc vu apparaitre ces dernières années le reverse debugging, mis en avant notamment par mozilla et son projet rr.
Le problème, c'est que le matériel ne le permet pas. Il est impossible pour le processeur d'enregistrer toutes ses variations d'état, ainsi que toutes les variations d'état de la RAM. La solution mise en place dans GDB est donc simple : si le processeur ne sait pas faire, faisons-le à sa place. Donc gdb émule un processeur dans un tel cas, ce qui évidemment se ressent sur les performances…

Assez parlé, petit exemple:

#include <stdio.h>

void boo() {
  printf("PERDU\n");
}

int main (int argc, char ** argv) {
  if (argc != 2)
    return -1;
  int v = atoi(argv[1]);

  if (v < 42)
    goto lose;
  if (v > 42)
    goto lose;
  return 0;

lose:
  boo();
  return -1;
}

On compile et on va debugger ça…

gcc -g test-rev.c -o test-rev

gdb ./test-rev

On s'intéresse à la fonction boo et on veut savoir pourquoi elle a été appelée…

> b boo

> r 73

> bt

#0  boo () at test-rev.c:4
#1  0x00005555555551b0 in main (argc=2, argv=0x7fffffffe088) at test-rev.c:19

Comme dans mon cas avec bison, ce n'est pas très très pratique : impossible de savoir quelle branche de if a bien pu déclencher le goto, à moins bien sûr de poser des breakpoints partout (ou de réfléchir, vu la taille du programme c'est pas bien dur, mais c'est pas le but de l'exercice).

(gdb) b main
Breakpoint 1 at 0x1167: file test-rev.c, line 8.
(gdb) b boo
Breakpoint 2 at 0x1149: file test-rev.c, line 4.
(gdb) r 73
Starting program: /tmp/test-rev 73

Breakpoint 1, main (argc=2, argv=0x7fffffffe088) at test-rev.c:8
8         if (argc != 2)
(gdb) target record-full
(gdb) c
Continuing.

Breakpoint 2, boo () at test-rev.c:4
4         printf("PERDU\n");
(gdb) rs
main (argc=2, argv=0x7fffffffe088) at test-rev.c:19
19        boo();
(gdb) 
15          goto lose;

Et voilà ! Nous avons pris le goto de la ligne 15, à savoir donc v > 42.
Nous avons pour cela activé le mode reverse (target record-full) et effecturé un reverse-step pour remonter dans le temps.
Simple, non ?

Du coup, on peut retourner à notre grammaire…

1) La joie du dentiste : l'impact entre le mur de la réalité et tes dents

Alors, faisons simple…

(gdb) b base_yyparse
Breakpoint 1 at 0x27d30: file src/postgres/src_backend_parser_gram.c, line 26257.
(gdb) b base_yyerror
Breakpoint 2 at 0x3f6e0: base_yyerror. (2 locations)
(gdb) r
Starting program: /home/pierre/projects/pglast/libpg_query/examples/simple 

Breakpoint 1, base_yyparse (yyscanner=yyscanner@entry=0x5555556863a8) at src/postgres/src_backend_parser_gram.c:26257
26257     yytype_int16 *yyss = yyssa;
(gdb) target record-full 
(gdb) set variable base_yydebug = 1
(gdb) c
Continuing.
Starting parse
Process record does not support instruction 0xc5 at address 0x7ffff7f26db2.

Ha.
C'est pas vraiment ce à quoi je m'attendais…
Que s'est-il passé ici ? Pourquoi notre exemple simple marchait et que dans la vraie vie on se prend un mur ?
Pour pouvoir implémenter un enregistrement complet des traces d'exécution pour pouvoir remonter dans le temps, il est nécessaire d'avoir au sein de GDB un émulateur pour le processeur. Mais ce dernier n'est pas complet : il ne gère pas notamment les instructions AVX des processeurs modernes.
Et c'est bien ce qu'il a rencontré ici :

   0x00007ffff7f26db2 <+2>:     vmovd  %esi,%xmm0

Il s'agit d'une instruction de l'implémentation en AVX2 de la fonction strhrnul. Comme de nombreuses fonctions de manipulation de chaines de caractères, elles ont été écrites en plusieurs versions, optimisée selon les processeurs.

2) Le choix dynamique d'implémentation

Vous souvenez-vous de l'époque où nous avions des paquets libc optimisés selon le type de processeur ? Ce n'est plus le cas sur nos distributions, comment se fait-il qu'un paquet générique soit optimisé ainsi ?
Au démarrage d'un binaire au format ELF, un interpréteur est lancé qui va récupérer les différents binaires, les mettre en place en RAM, et mettre les bons appels de fonctions aux bons endroits. C'est cet interpréteur qui va appeler la méthode cpuid pour obtenir du processeur les différentes fonctionnalités qu'il supporte et donc choisir les versions les plus optimales des fonctions dont il dispose.
Il faut donc que nous fassions comprendre à ce trublion que non, même si notre processeur dispose des derniers raffinements en vigueur, il ne faut pas les activer. Pour cela… quoi de plus simple que de le patcher, directement ? (ouais, c'est bourrin mais j'ai vraiment pas plus simple)
L'interpréteur ELF par défaut sur amd64 est /lib64/ld-linux-x86-64.so.2:

$ file /bin/bash 
/bin/bash: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=ffe165dc81a64aea2b05beda07aeda8ad71f1e7c, stripped

Il est "préférable" de ne pas toucher directement à ce fichier (sans blague), aussi allons-nous procéder sur une copie du fichier.

Le code à patcher initial est:

__cpuid (0, cpu_features->max_cpuid, ebx, ecx, edx);

(libc, sysdeps/x86/cpu-features.c)

Cela correspond à mettre 0 dans le registre EAX, puis appeler la fonction cpuid.

Nous devons donc trouver dans ld-linux.so l'assembleur suivant:

31 c0       xor eax, eax // Le moyen le plus court pour mettre 0 dans EAX
0f a2       cpuid

Si on cherche dans le binaire, on ne trouve pas cette séquence. Il est en effet possible d'avoir quelques instructions intermédiaires ou de padding, selon le compilateur et le sens du vent…
Du coup, il faut chercher avec éventuellement quelques octets entre ces deux instructions.

Faisons simple : le one-liner Perl suivant devrait résoudre le problème :)

perl -pe 's/\x{31}\x{c0}.{0,32}\K\x{0f}\x{a2}/\x{66}\x{90}/' < /lib64/ld-linux-x86-64.so.2 > ld-linux-x86-64.so.2.nocpu

On génère donc un nouveau fichier ld-linux.so qui ne va pas aller méchamment lire les infos du CPU pour tenter d'être intelligent. non mais…

Il suffit maintenant d'appeler patchelf pour que notre exécutable utilise notre interpréteur patché.

patchelf --set-interpreter `readlink -f ld-linux-x86-64.so.2.nocpu` ./examples/simple

Et voilà !

3) Jouer…

Je peux maintenant faire du reverse-debug dans ma grammaire, et donc retracer "aisément" les variations dans la machine à états.

44051           parser_yyerror(msg);
(gdb) rs
26461     if (yyn == 0)
(gdb) rs
26460     yyn = yydefact[yystate];
(gdb) rs
26424     if (yyn < 0 || YYLAST < yyn || yycheck[yyn] != yytoken)
(gdb) p yyn
$1 = 28679

Le reste sera entre moi et mon psy.

Bonne fin de week-end à tous !

  • # Super

    Posté par . Évalué à 10 (+10/-0).

    Ça c'est du journal fort utile !
    Merci

  • # rr

    Posté par (page perso) . Évalué à 2 (+1/-0).

    Regarde aussi du côté de rr, qui se pilote comme gdb mais qui est plus puissant en termes de reverse debugging.

  • # Excellent !

    Posté par (page perso) . Évalué à 2 (+1/-0).

    Génial ! Je galérais justement sur un debug un peu délicat (des overrides en C++) donc je vais m'empresser de tester ça.

    PS : la commande "target record-full" est supportée depuis GDB 7.6 (2013), mais pas forcément avec tous les raffinements du journal. Je vais voir ça, et recompilerai une version au pire.

  • # Merci !

    Posté par . Évalué à 2 (+2/-0). Dernière modification le 07/07/19 à 21:22.

    Merci pour ce journal fort sympa/instructif !

  • # Avant les grands lézards

    Posté par (page perso) . Évalué à 3 (+1/-0).

    Excellent article, je ne savais pas que gdb savait faire ça.

    Juste une remarque: le reverse debugging existe depuis un certain temps, je me souviens l'avoir pratiqué avec SoftIce, dans le milieu des années 90. Je ne sais pas si SoftIce était précurseur dans le domaine

    • [^] # Re: Avant les grands lézards

      Posté par (page perso) . Évalué à 3 (+1/-0).

      Bon j'y ai pensé trop tard pour modifier mon message, mais:

      Comme tu débuggues un programme dont tu as les sources, ne serait-il pas plus simple de le compiler avec comme cible un proc générique (-march athlon64) ? Afin qu'il soit reconnu sans artifices par gdb.

      • [^] # Re: Avant les grands lézards

        Posté par (page perso) . Évalué à 2 (+0/-0).

        Décidément, c'est pas ma journée.

        /tu débugges un programme/tu débugges sur un système dont tu as les sources/

        Et donc recompiler la lib litigieuse et forcer son utilisation avec un LD_LIBRARY_PATH ?

        • [^] # Re: Avant les grands lézards

          Posté par . Évalué à 4 (+2/-0). Dernière modification le 08/07/19 à 11:24.

          La lib litigieuse est tout de même la libc, c'est pas la lib la plus insignifiante à recompiler et forcer. Je trouve le hack sur l'interpréteur plus léger et au final plus fiable.
          Par ailleurs, à la compilation il me semble qu'elle inclut toutes les versions, qui sont directement optimisées en assembleur, ce n'est pas du code généré par le compilateur (à vérifier, j'ai plus compilé la libc depuis longtemps)

          • [^] # Re: Avant les grands lézards

            Posté par (page perso) . Évalué à 3 (+1/-0). Dernière modification le 08/07/19 à 20:03.

            Oui, recompiler avec un -march=… générique comme je le suggérais au dessus ne fonctionnerait pas. Je n'ai pas vérifié, mais cela n'empêcherait surement pas les codepaths d'être générés.

            J'ai regardé dans la man de ld-linux si on pouvait lui passer un paramètre pour dire de passer en mode «compatibilité» pour le processeur. Je croyais tenir un truc avec LD_HWCAP_MASK mais hélas après vérification, cela s'est avéré être une fausse piste.
            Je suis tombé sur cette intéressante discussion, en cherchant des infos sur ce paramètre:
            https://stackoverflow.com/questions/42451492/disable-avx-optimized-functions-in-glibc-ld-hwcap-mask-etc-ld-so-nohwcap-for

            La discussion débouche sur la même solution que l'auteur de ce journal.

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à ceux qui les ont postés. Nous n'en sommes pas responsables.