# TaBr - tabr_chpw_cgi interface web pour réclamer un autre mot de passe
2023-05-15T14:10:19Z
Les mots de passes sont générés aléatoirement à l'inscription.
J'imagine que la plupart utiliseront la fonction "se souvenir du mot de passe" de leur appareil, mais voudront peut-être changer pour quelque chose de plus facile à retenir, ou bien obtenir un nouveau mot de passe dans le cas où le précédent soit oublié.
Pour ça, chaque utilisateur a reçu une fiche avec un QR code rappelant le code de récupération du mot de passe.
Il leur suffit d'accéder à la page de demande de changement de mot de passe qui est un CGI écrit en C par mes soins.
De toute la suite TaBr, c'est la partie la plus sensible car elle est exposée.
Je tenais dans tous les cas à ne pas connaître les mots de passe des utilisateurs, je ne pouvais donc les modifier "à la main".
C'est aussi la partie dont je suis le plus fier, car j'ai réfléchi à tout ce qui pouvait être à ma portée afin de limiter les risques en terme de sécurité.
Évidemment, je suis très curieux d'avoir votre avis sur la question.
On y trouvera notamment :
* Un délai d'attente avant de répondre pour éviter de multiples attaques bruteforce. Ça n'emêchera pas les appels avec plusieurs IP en simultané, mais ça limite la charge tout en restant acceptable pour l'utilisateur qui de toute façon ne change pas de mot de passe tout le temps.
* Utilisation de pledge afin d'éviter les appels à des fonctions inattendues.
* Utilisation de unveil pour renforcer encore le chroot de httpd et limiter les permissions en lecture et écriture vers un dossier ne particulier.
* Limite des tentatives de bots avec un captcha aléatoire.
* Utilisation de crypt_* pour utiliser des hash afin de cacher la réponse à un captcha.
## L'interface
Voici à quoi ressemble l'interface:
=> /img/log/tabr-oubli.png Aperçu de l'interface
Oui, c'est il n'y a pas de paillettes, d'animations ou de boutons de partages vers les réseaux sociaux. Mais c'est accessible et ça fonctionne dans n'importe quel navigateur, même lynx. Moi, je trouve ça beau :P
On remarque 4 champs :
* le nom d'utilisateur, normal
* son code de récupération. Merci le html5 pour proposer un placeholder, bien que ça ne soit pas forcément utile (c'est facile de mettre un exemple à côté du label du champ.
* le nouveau mot de passe demandé (normal).
* un captcha aléatoire.
À propos du html5, je m'en sers pour valider au maximum ce que les utilisateurs proposent (longueur du mot de passe, validité du nom d'utilisateur...). Cependant, il vaut mieux revérifier ensuite, je ne vais pas confiance à tous les navigateurs pour supporter ces éléments correctement.
## Le code
Le code est du C. C'est quelque chose que je maîtrise et qui me permet de profiter sans difficultés des générateurs de nombres aléatoires avec arc4random, de unveil, de pledge...
Puisque c'est un peu long (~246 lignes uniques), je vais me concentrer ici seulement sur les parties "intéressantes".
### config.h
On configure directement en C.
On y trouve des variables, et surtout les templates html. Ces derniers ne sont pas très propres car il y a beaucoup de guillemets échappés, mais ce n'est pas quelque chose qu'on édite souvent.
Par exemple, on y définit le dossier (à l'intérieur du chroot) où seront stockées les demandes de changement de mot de passe:
```
static const char *chpw_requests_dir = "/tabr_chpw_requests";
```
On y voit aussi les différents types de captchas possibles avec les consignes associées:
```
/* captcha config */
enum { DIGIT, LOWER, UPPER, PUNCT };
/* instructions displayed for humans, same order as above! */
static const char *instructions[] = {
"Recopiez uniquement les chiffres",
"Recopiez uniquement les lettres minuscules",
"Recopiez uniquement les lettres majuscules",
"Recopiez uniquement les symboles et ponctuation",
};
```
Au niveau des templates html, je veux être sûr que la page ne soit pas mise en cache (avec un succès limité):
```
"\n"
"\n"
"\n"
```
j'attire votre attention sur la validation des champs.
Un "title" permet de préciser ce qui est attendu à chaque fois.
```
"
\n"
"\n"
"\n"
```
Ici, le "pattern" me permet de reproduite une regex trouvée dans le code de "adduser", en la modifiant un peu au passage. Qui voudrait d'un nom d'utilisateur terminant pas "$"???
J'impose aussi 15 caractères pour le nouveau mot de passe:
```
"
\n"
"
\n"
"\n"
```
Pour le captcha, il y a les "%s" qui me permettront d'y mettre ce que je veux le moment voulu, puisque c'est une énigme aléatoire à chaque fois:
```
"
\n"
"
%s
\n"
"
%s
\n"
"\n"
"
\n"
"
\n"
"\n"
"
\n"
```
Pour finir, il y a les messages d'erreur affichés selon les cas:
j
```
/* error messages */
static const char *errcaptcha = "Mauvaise réponse! 😱";
static const char *errusername = "Mauvais format. Le nom d'utilisateur doit avoir une longueur entre 1 et 31 caractères, ne peut pas commencer par un \"-\" et ne peut contenir que les symboles suivants: \"abcdefghijklmnopqrstuvwxyz0123456789-_.\"";
static const char *errshortpw = "Mot de passe trop court : au moins 15 caractères sont attendus. Une phrase peut fonctionner.";
```
### main.c
C'est ici que le vrai code commence. Mais avant d'aller plus loin, je vais me préparer un petit café à partager avec vous.
> ☕ Slurp!
Je vous avais promis du unveil et du pledge. Rien de plus simple, on n'autorise l'accès que au dossier contenant les requêtes pour pouvoir y créer des fichiers avec unveil, et on promet seulement des entrée/sorties et écritures dans des fichiers avec pledge:
```
#ifdef __OpenBSD__
/*
if (unveil(chpw_requests_dir, "rwc") == -1)
err(1, "unveil");
unveil(NULL, NULL);
if (pledge("stdio cpath wpath", NULL) == -1)
err(1, "pledge");
*/
#endif
```
Avant d'aller plus loin, on va faire patienter un peu celui ou celle qui fait la requête avec un appel à "sleep". Le nombre de secondes d'attente sera aléatoire grâce à arc4random_uniform:
```
sleep(arc4random_uniform(MAXWAIT));
```
On regarde maintenant si on doit traiter une demande. Cette dernière est passée en entrée, donc on lit stdin avec fgets:
```
if (fgets(rawpost, sizeof(rawpost), stdin) == NULL)
if (ferror(stdin))
http400("Request too long ?");
```
On note au passage la fonction "http400", qui au même titre que "http500" permet de renvoyer le code d'erreur au navigateur si beosin et d'afficher un petit message en passant.
Dans le cas où il n'y a pas de demande à traiter, on affiche un formulaire vide.
```
if (strlen(rawpost) == 0) {
while (strlen(secret) < 6) {
//secret[0] = '\0';
memset(secret, '\0', sizeof(secret));
rdmstr(question, sizeof(question));
secret_class = strtosecret(question, secret);
}
```
Ici, je cherche à créer une chaîne de caractère aléatoire suffisamment longue, d'où la boucle "while" qui se répète tant que la longueur est inférieure à 6.
À chaque tour, memset() s'assure que le chaîne est bien vide, puis un appel à rdmstr() permet d'obtenir une chaîne aléatoire.
```
size_t
rdmstr(char *s, size_t len)
{
/* fill s with random chars */
size_t l=0;
const char charset[] =
"0123456789"
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMOPQRSTUVWXYZ"
"-_+~#()%,?;.:/!=";
for (size_t i = 0; i < len; i++) {
s[i] = charset[arc4random_uniform(strlen(charset))];
l++;
}
s[len] = '\0';
return l;
}
```
Cette fonction pioche dans la liste de caractères définie dans "charset" grâce à arc4random_uniform().
Notez que je n'ai choisi que des caractères spéciaux assez faciles d'accès sur un clavier (de smartphone notamment). On pourrait y ajouter des espaces et d'autres symboles plus étranges encore. On peut aussi prciser plusieurs fois un caractère si on veut augmenter sa probabilité d'apparaître. Je n'ai pas mis d'espace, j'ai pensé que ça pourrait prêter à confusion.
S'ensuit la fonction strtosecret qui va choisir une classe de caractères à recopier et qui constituera la réponse au captcha:
```
int
strtosecret(const char *s, char *secret)
{
/* choose a random class of char
* secret is all char of the selected class
* from string s
*/
int char_classes[] = { DIGIT, LOWER, UPPER, PUNCT };
int char_class = char_classes[arc4random_uniform(LEN(char_classes))];
for (size_t i=0; i < strlen(s); i++) {
switch (char_class) {
case DIGIT:
if (isdigit(s[i]))
secret[strlen(secret)] = s[i];
break;
case LOWER:
if (islower(s[i]))
secret[strlen(secret)] = s[i];
break;
case UPPER:
if (isupper(s[i]))
secret[strlen(secret)] = s[i];
break;
case PUNCT:
if (ispunct(s[i]))
secret[strlen(secret)] = s[i];
break;
}
}
return char_class;
}
```
Vous l'aurez compris, cette fonction lit la chaîne caractère par caractère et le recopie s'il correspond à la condition choisie aléatoirement.
La classe de caractère choisie est retournée pour savoir quelle consigne afficher à l'utilisateur.
On transforme la réponse en hash avec crypt_newhash(), pour permettre la vérification du captcha ensuite:
```
if (crypt_newhash(secret, "bcrypt,a", anshash,
sizeof(anshash)) != 0)
```
Reste à afficher le formulaire:
```
http200("text/html; charset=utf-8");
printf(form,
instructions[secret_class],
question,
anshash
);
```
Si au contraire on a reçu des données, il faut traiter la demande.
Pour cela, on va découper le texte d'entrée:
```
get_post_field("user=", rawpost, user);
get_post_field("recovery=", rawpost, recovery);
get_post_field("newpass=", rawpost, newpass);
get_post_field("answer=", rawpost, answer);
get_post_field("anshash=", rawpost, anshash);
```
La fonction get_post_field recherche dans "rawpost" la chaîne passée en premier argument et l'enregistre dans la dernière variable.
Regardons-là de plus près:
```
char *
get_post_field(const char *str, const char *post, char *field)
{
char *pos = NULL;
char buf[BUFSIZ] = {'\0'};
char *r = strdup(post);
char *tofree = r;
char *tok = NULL;
while ((tok = strsep(&r, "&")) != NULL) {
if ((pos = strstr(tok, str)) == NULL) {
continue;
} else {
if (strlcpy(buf, tok + strlen(str),
sizeof(buf)) >= sizeof(buf))
http500("strlcpy");
break;
}
}
if (urldecode(buf, field) < 0)
http400("Bad request");
free(tofree);
return field;
}
```
On remarque qu'elle fait appel à strsep(). Cette fonction modifie la chaîne, c'est pourquoi on a utilisé strdup() au préalable, et enregistré dans tofree la référence au pointeur qu'il faudra libérer avec free() ensuite.
Une fois ces éléments rassemblés, on va vérifier que le nom d'utilisateur correspond à une regex:
```
if (regcomp(®_username, valid_username_regex,
REG_EXTENDED) != 0) {
regfree(®_username);
http500("can't compile regex");
}
if (regexec(®_username, user, 0, NULL, 0) != 0) {
regfree(®_username);
http400(errusername);
}
regfree(®_username);
```
Ici, regex.h n'est pas trop compliqué à utiliser puisqu'il s'agit de seulement vérifier si "ça match".
Plus simple, on vérifie juste si le nouveau mot de passe demandé est assez long:
```
if (strlen(newpass) < 15)
http400(errshortpw);
```
Oui, 15 seulement... On va me détester si j'impose +.
Reste à enregistrer la demande dans un fichier portant le nom d'utilisateur.
La suite est donc un peu barbante, il s'agit juste de créer un chemin puis d'écrire dedans:
```
/* build the path to file with request */
if ((strlcpy(request_file, chpw_requests_dir, sizeof(request_file))
> sizeof(request_file)) ||
(strlcat(request_file, "/", sizeof(request_file))
> sizeof(request_file)) ||
(strlcat(request_file, user, sizeof(request_file))
> sizeof(request_file)))
http500("strlcpy & strlcat");
/* open the file and save data */
f = fopen(request_file, "w");
if (f == NULL)
http500("can't open file to write");
fprintf(f, "%s\n", recovery);
fprintf(f, "%s\n", newpass);
fclose(f);
```
Je termine en faisant un petit chmod afin de limiter qui aura accès à ce fichier.
```
chmod(request_file,
S_IRUSR | S_IWUSR |
S_IRGRP | S_IWGRP |
S_IROTH | S_IWOTH
);
```
C'est le point dont je suis le moins satisfait, mais je n'ai pas trouvé mieux car:
* httpd (slowcgi) doit pouvoir écrire dans les fichiers. Le propriétaire est donc "www".
* _tabr_admin doit pouvoir lire ces fichiers. Cependant, ce dernier n'appartient pas au groupe "www".
Par conséquent, ces fichiers sont lisibles par tous. TOUTEFOIS, le dossier dans lequel ils sont stockés n'est accessible en lecture QUE par _tabr_admin. L'utilisateur www ne peut que écrire dedans, et les autres n'ont aucune permission :
```
drwx-wx--- 2 _tabr_admin www 512 May 13 13:39 tabr_chpw_requests
```
C'est LA partie de l'installation à ne pas rater.
Heureusement, un petit Makefile est prévu pour ça. On en parle dans le prochain article :)
## Une réaction?
=> mailto:bla@bla.si3t.ch?subject=TaBr-tabr_chpw_cgi-part4 Envoyez votre commentaire par mail (anonyme).
=> /log/commentaires/ Mode d'emploi de la liste de diffusion pour recevoir les réponses.