Les failles CSRF

C'est quoi, et comment se protéger ?

a marqué ce sujet comme résolu.

Tout le monde se secoue ! :D

J'ai commencé (lundi 07 novembre 2016 à 18h17) la rédaction d'un tutoriel au doux nom de « Les failles CSRF » et j'ai dans l'objectif de proposer en validation un texte aux petits oignons. Je fais donc appel à votre bonté sans limite pour dénicher le moindre pépin, que ce soit à propos du fond ou de la forme. Vous pourrez consulter la bêta à votre guise à l'adresse suivante :

Merci !

Edit : c'est une version écrite en très peu de temps, c'est limite une POC. Vous en pensez quoi, sur la forme ? Qu'est ce qui manque selon vous ?

Clair et concis, ça se lit en 10 minutes et ça cible vraiment bien le fonctionnement de la faille et les jetons CSRF.

mal-intentionné

https://fr.wiktionary.org/wiki/malintentionn%C3%A9

Mais, comment faire pour qu'il y ai…

ait

C'est vraiment tout ce que je peux redire à ce tuto ! :)

Tu veux dire avec une image ? L'exemple en partie 1 n'est pas clair ?

La distinction n'est pas suffisante ? J'ai fait exprès de citer explicitement la faille XSS et en faire un lien quand je parlais d'exécuter du code dans le navigateur de Clem.

J'ai pas trop compris, c'est le lien du tuto en bêta ça, non ? Quel ressource ?

En tout cas, merci à vous, c'est cool d'avoir du retour aussi rapide. :)

Quelques remarques :

  • Javascript -> JavaScript
  • Zestedesavoir -> Zeste de Savoir (ZesteDeSavoir ou zestedesavoir au pire)

Sinon c'est assez clair ! Je rejoins mes collègues plus haut, une illustration peut se montrer sympa :)

+0 -0

Désolé, j'ai raté mon copier/coller : http://venom630.free.fr/geo/tutz/securite_informatique/csrf.html

Exécuter du code dans la navigateur d'une personne ne veut pas forcément dire "exploiter une faille XSS", en fait.

Un scénario d'attaque avec une image ou un formulaire auto-envoyé. Disons que là c'est plutôt succinct et on ne sait pas vraiment ce qu'il se passe surtout que tu dis "mais c'est protégé sur ZdS" (même si tu expliques plus loin).

Il faut à mon sens mieux sensibiliser le lectorat. À toi de voir comment. :)

Edit : ajout précisions

Edit 2 : en fait je me rends compte, quand je me relis, que je ne suis pas tout à fait clair. Je ne t'en voudrai pas si tu ne tiens pas rigueur de mes remarques, n'hésite pas à en discuter à MP si jamais tu veux plus d'explications… Mais là c'est dur. >_<

+0 -0

Je pense ajouter un code côté serveur pour avoir un exemple un peu plus concret (avec un code qui réalise une action basique avec une requête GET pour la partie 1, un code qui réalise la même action avec une requête POST pour la partie 2 et un code qui utilise un token dans la partie 3). Je pensais partir sur du Python+Flask ou du node+quelconque routeur parce que c'est limite du pseudo-code (tout le monde comprend). Vous en pensez quoi ?

J'aurai besoin de vos avis : j'ai écrit le code dont je parlais au dessus (en utilisant node+express). D'un côté, le code est un peu beaucoup pour un simple exemple annexe, d'un autre côté il montre bien comment tout fonctionne (l'avantage de express, c'est qu'on voit bien tous ce qui se passe, rien de magique). Ceux qui ont jamais fait de node/express, vous pourriez me donner votre avis : à la première lecture, est-ce que c'est clair ? Les commentaires ne sont pas ceux destinés au tuto, juste pour vous éclaircir (quoi que, si ils sont assez clairs (?)).

p1.js :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
var express = require('express')
var app = express()
var session = require('express-session')
var uid = require('uid-safe')
var messages = []

// configuration du système de vue
require('nunjucks').configure('views', { express: app })

// configuration du système de session
app.use(session({ secret: uid.sync(37), resave: false, saveUninitialized: false }))

// page d'accueil
app.get('/', (req, res) => {
    res.render('p1.njk', {
        messages: messages,
        isClem: req.session.isClem ? true : false
    })
})

// page pour poster un message
app.get('/msg', (req, res) => {
    // si c'est clem et que il y a du contenu
    if (req.session.isClem && req.query.content !== undefined) {
        // on ajoute un message
        messages.push(req.query.content)
    }

    // puis on redirige vers la page d'accueil
    res.redirect('/')
})

// page pour se connecter
app.get('/login', (req, res) => {
    // si le mot de passe est bon
    if (req.query.password === 'ilovezds') {
        // on sauvegarde en session que c'est clem
        req.session.isClem = true
    }

    // puis on redirige vers la page d'accueil
    res.redirect('/')
})

// page pour se déconnecter
app.get('/logout', (req, res) => {
    // on supprime l'info en session
    delete req.session.isClem

    // puis on redirige vers la page d'accueil
    res.redirect('/')
})

app.listen(5000)

p1.njk :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{% if isClem %}

    <p>Bienvenue Clem !</p>

    <form method="GET" action="/logout">
        <input type="submit" value="Se déconnecter">
    </form>

    <form method="GET" action="/msg">
        <input type="text" name="content" placeholder="Message">
        <input type="submit" value="Publier un message">
    </form>

{% else %}

    <p>Qui es-tu, étranger ?</p>

    <form method="GET" action="/login">
        <input type="password" name="password" placeholder="Mot de passe">
        <input type="submit" value="Se connecter">
    </form>

{% endif %}

<hr>

<h2>Liste des messages</h2>

<ul>
    {% for msg in messages %}
        <li>{{ msg }}</li>
    {% endfor %}
</ul>

p2.js :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
var express = require('express')
var app = express()
var session = require('express-session')
var uid = require('uid-safe')
var messages = []

// toujours le même système de vue
require('nunjucks').configure('views', { express: app })

// et le même système de session
app.use(session({ secret: uid.sync(37), resave: false, saveUninitialized: false }))

// mais on ajoute le parser du contenu des requêtes POST venant des formulaires
app.use(require('body-parser').urlencoded({ extended: true }))

// rien ne change ici
app.get('/', (req, res) => {
    res.render('p2.njk', {
        messages: messages,
        isClem: req.session.isClem ? true : false
    })
})

// ici, c'est `app.post()` et non plus `app.get()`
app.post('/msg', (req, res) => {
    if (req.session.isClem && req.body.content !== undefined) {
        messages.push(req.body.content)
    }

    res.redirect('/')
})

// pareil ici
app.post('/login', (req, res) => {
    if (req.body.password === 'ilovezds') {
        req.session.isClem = true
    }

    res.redirect('/')
})

// pareil ici
app.post('/logout', (req, res) => {
    delete req.session.isClem
    res.redirect('/')
})

app.listen(5000)

p2.njk :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{% if isClem %}

    <p>Bienvenue Clem !</p>

    <form method="POST" action="/logout">
        <input type="submit" value="Se déconnecter">
    </form>

    <form method="POST" action="/msg">
        <input type="text" name="content" placeholder="Message">
        <input type="submit" value="Publier un message">
    </form>

{% else %}

    <p>Qui es-tu, étranger ?</p>

    <form method="POST" action="/login">
        <input type="password" name="password" placeholder="Mot de passe">
        <input type="submit" value="Se connecter">
    </form>

{% endif %}

<hr>

<h2>Liste des messages</h2>

<ul>
    {% for msg in messages %}
        <li>{{ msg }}</li>
    {% endfor %}
</ul>

p3.js :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
var express = require('express')
var app = express()
var session = require('express-session')
var uid = require('uid-safe')
var messages = []

// fonction que l'on appelle quand une page contient un formulaire
var needCsrf = (req, res, next) => {
    // si on n'a jamais généré de jeton pour la session
    if (req.session.csrf === undefined) {
        // on en génère un
        req.session.csrf = uid.sync(22)
    }

    // et on continue l'exécution
    next()
}

// fonction que l'on appelle quand une page reçoit les données d'un formulaire
var checkCsrf = (req, res, next) => {
    // si le jeton en session et celui envoyé par le formulaire est le même
    if (req.body.csrfToken === req.session.csrf) {
        // on continue
        next()
    } else {
        // sinon, on affiche une erreur
        res.status(403).end('Wrong CSRF token!')
    }
}

// même chose qu'avant pour ces 3 lignes
require('nunjucks').configure('views', { express: app })
app.use(session({ secret: uid.sync(37), resave: false, saveUninitialized: false }))
app.use(require('body-parser').urlencoded({ extended: true }))

// même chose qu'avant, sauf qu'on génère le jeton csrf
app.get('/', needCsrf, (req, res) => {
    res.render('p3.njk', {
        messages: messages,
        isClem: req.session.isClem ? true : false,
        // et on passe le jeton à la vue
        csrfToken: req.session.csrf
    })
})

// même chose qu'avant, sauf qu'on vérifie que le jeton qui est dans les données
// du formulaire est le même que celui en session
app.post('/msg', checkCsrf, (req, res) => {
    if (req.session.isClem && req.body.content !== undefined) {
        messages.push(req.body.content)
    }

    res.redirect('/')
})

// même chose qu'au dessus
app.post('/login', checkCsrf, (req, res) => {
    if (req.body.password === 'ilovezds') {
        req.session.isClem = true
    }

    res.redirect('/')
})

// et encore pareil
app.post('/logout', checkCsrf, (req, res) => {
    delete req.session.isClem
    res.redirect('/')
})

app.listen(5000)

p3.njk :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{% if isClem %}

    <p>
        Bienvenue Clem !
    </p>

    <form method="POST" action="/logout">
        {# ce qui change ici est l'ajout des champs cachés contenant le jeton
           dans les trois formulaires #}
        <input type="hidden" name="csrfToken" value="{{ csrfToken }}">
        <input type="submit" value="Se déconnecter">
    </form>

    <form method="POST" action="/msg">
        <input type="text" name="content" placeholder="Message">
        <input type="hidden" name="csrfToken" value="{{ csrfToken }}">
        <input type="submit" value="Publier un message">
    </form>

{% else %}

    <p>Qui es-tu, étranger ?</p>

    <form method="POST" action="/login">
        <input type="password" name="password" placeholder="Mot de passe">
        <input type="hidden" name="csrfToken" value="{{ csrfToken }}">
        <input type="submit" value="Se connecter">
    </form>

{% endif %}

<hr>

<h2>Liste des messages</h2>

<ul>
    {% for msg in messages %}
        <li>{{ msg }}</li>
    {% endfor %}
</ul>

Je vous déconseille de citer ce message ! :D

Salut, je partage l'avis de Ge0.

L'exemple de la première partie est bien mais me semble mal adapté, il adoucit la gravité de la faille. Je verrais plutôt une fonction de changement d'email/password/d'adresse, ou pour un modérateur avec la fonction de bannissement, etc… Il faut quelques choses qui sert d’électrochoc.

Et appuyer sur le fait qu'on puisse envoyer plusieurs formulaires en POST avec une boucle (ou avec setInterval), pour affecter beaucoup de chose. Il est nécessaire d'avoir une prise de conscience. :)

Pour l'image qui envoie un formulaire GET, c'est entre une faille CSRF et XSS il me semble.

+0 -0

L'exemple de la première partie est bien mais me semble mal adapté, il adoucit la gravité de la faille. Je verrais plutôt une fonction de changement d'email/password/d'adresse, ou pour un modérateur avec la fonction de bannissement, etc… Il faut quelques choses qui sert d’électrochoc.

C'est vrai que j'ai déjà abusé de CSRF de la manière suivante : dans un profil utilisateur, quand on veut changer un mot de passe, on demande le nouveau mot de passe et sa confirmation. Parfois on demande l'ancien mot de passe juste pour changer le nouveau mot de passe.

Mais en ce qui concerne le changement d'adresse mail, celui-ci n'est pas tout le temps contrôlé ! Il suffit donc de poster une @mail à soit pour lier un profil utilisateur à une adresse qui nous appartient, ensuite utiliser la fonction "mot de passe oublié", et le tour est joué.

C'est un petit détail qui peut faire toute la différence et s'avérer gravissime compte tenu du site / utilisateur ciblé.

Merci A-312 pour m'avoir rafraîchi la mémoire.

tleb, tu pourrais peut-être t'en tenir à un tel exemple ?

D'accord, merci pour vos retours.

Pour l'exemple, quel système me conseillerait-vous ? Je pensais à un système de compte (minimaliste, bien entendu) avec un chat global. Ça permettrait de montrer le premier (avec l'image posté sur le chat, qui modifierait l'email de tous ceux qui charge la page), avec une requête qui change l'email de l'utilisateur.

Pour ce qui est de l'image avec la requête GET, c'est l'exemple de CSRF le plus pur que l'on puisse avoir : on charge une page dans le navigateur de l'autre. Il n'y a pas besoin d'afficher de l'HTML custom ou d'exécuter du JS. Si je savais que tu utilisais souvent un service en ligne qui utilise des requêtes GET qui ont des side effects sur ton compte (e.g: modifier ton email), il me suffirait de poster une image ici pour que je modifie ton email (quand tu chargeras cette page, ton navigateur ira chercher une image à l'URL que j'ai donné, et cette requête arrivera sur le service victime, qui modifiera ton email). Je n'ai pas besoin de faille XSS pour ça.

c'est l'exemple de CSRF le plus pur que l'on puisse avoir

tleb

Il y a quasiment aucun formulaire important en GET avec des paramètres de type texte (saisi par l'utilisateur), les formulaires GET sont surtout présent lors d'action direct (comme voter, toggle (inverser) une option, la suppression du compte dans la modération ?, …).

Tout à fait. Pour être safe, il ne faudrait qu'aucune requête GET ait de side-effect (leurs fonctions est de retourner du contenu, non de le modifier). Même les exemples simples comme voter ou toggle ne devraient pas utiliser de requête GET.

C'est un peu le but du tuto, de montrer qu'est ce que n'est pas bien dans les 2 premières méthodes pour aborder la 3ème, qui résout les problèmes des 2 premières.

Qu'est-ce que tu penses du fil conducteur que je propose au dessus (système de comptes avec un chat) ?

Il y a quasiment aucun formulaire important en GET avec des paramètres de type texte

D'accord avec ça, mais il ne faut pas oublier que la sécurité informatique ; l'art du hacking consiste vraiment à n'épargner aucun détail et il se peut qu'un jour on ait affaire à, disons, une application qui tourne sous PHP avec register_globals à On, ce qui fait que les variables GET et POST sont globalisées et confondues.

Il ne faut RIEN laisser au hasard et aborder le plus de thématiques possibles si l'on veut vraiment faire de la sécurité informatique. En plus d'avoir un esprit bidouilleur, curieux, créatif, mais ça c'est un autre débat et ça n'est pas incompatible avec le développement. :)

Voici à quoi pourrait ressembler le code pour la partie 1 :

p1.js :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
var express = require('express')
var app = express()
var session = require('express-session')
var uid = require('uid-safe')

var users = {
    'tleb': {
        password: 'foo',
        admin: true,
    }
}
var messages = []

require('nunjucks').configure('views', { express: app })
app.use(session({ secret: uid.sync(37), resave: false, saveUninitialized: false }))

function userExists(username) {
    return users.hasOwnProperty(username)
}

function isAdmin(username) {
    return userExists(username) && users[username].admin === true
}


app.get('/', (req, res) => {
    res.render('p1.njk', {
        messages: messages,
        username: req.session.username,
        admin: isAdmin(req.session.username),  // si l'utilisateur connecté est admin
        users: users,  // liste des utilisateurs (pour admin)
    })
})

app.get('/msg', (req, res) => {
    // si il est connecté et si y'a du content qui est passé
    if (req.session.username && req.query.content) {
        // on ajoute un message
        messages.push({
            author: req.session.username,
            content: req.query.content,
        })
    }

    res.redirect('/')
})

app.get('/toggle-admin', (req, res) => {
    let username = req.query.username
    // si celui connecté est admin et l'utilisateur en paramètre existe
    if (isAdmin(req.session.username) && userExists(username)) {
        // on toggle le statut de l'utilisateur passé en paramètre
        users[username].admin = isAdmin(username) ? false : true
    }

    res.redirect('/')
})

app.get('/login', (req, res) => {
    let username = req.query.username
    let password = req.query.password

    // si l'utisateur existe et son mdp est bon
    if (userExists(username) && users[username].password === password) {
        // on le connecte
        req.session.username = username
    }

    res.redirect('/')
})

app.get('/logout', (req, res) => {
    // on supprime la session qui stoque son nom d'utilisateur
    delete req.session.username

    res.redirect('/')
})

app.get('/register', (req, res) => {
    let username = req.query.username
    let password = req.query.password

    // si un nom d'utilisateur et un mdp sont passés
    if (username && password) {
        // on ajoute un utilisateur
        users[username] = {
            password: password,
            admin: false,
        }
        // et on le login
        req.session.username = username
    }

    res.redirect('/')
})

app.listen(5000)

p1.njk :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
{% if username %}

    <p>Bonjour <b>{{ username }}</b> !</p>

    <p><a href="/logout">Se déconnecter</a></p>

    <hr><h2>Liste des messages</h2>

    {% if messages|length %}
        <ul>
            {% for msg in messages %}
                <li><b>{{ msg.author}} : </b> {{ msg.content }}</li>
            {% endfor %}
        </ul>
    {% else %}
        <p>Aucun message publié</p>
    {% endif %}


    <form method="GET" action="/msg">
        <input type="text" name="content" placeholder="message">
        <input type="submit" value="Publier un message">
    </form>


    {% if admin %}

        <hr>


        <ul>
            {% for username, user in users %}
                <li>{{ username }} : <a href="/toggle-admin?username={{ username|urlencode }}">{% if user.admin %}supprimer son rang d'admin{% else %}rendre admin{% endif %}</a></li>
            {% endfor %}
        </ul>

    {% endif %}

{% else %}

    <p>Qui es-tu, étranger ?</p>

    <form method="GET" action="/login">
        <input type="text" name="username" placeholder="Nom d'utilisateur">
        <input type="password" name="password" placeholder="Mot de passe">
        <input type="submit" value="Se connecter">
    </form>

    <form method="GET" action="/register">
        <input type="text" name="username" placeholder="Nom d'utilisateur">
        <input type="password" name="password" placeholder="Mot de passe">
        <input type="submit" value="Créer un compte">
    </form>

{% endif %}

Un admin peut modifier le statut des autres comptes. Est-ce que vous pensez qu'il faudrait pouvoir delete aussi ou juste changer le statut ça suffit comme électrochoc ? Ça commence à faire beaucoup pour un tuto, j'ai peur que ça repousse de voir tous ça, surtout si le lecteur ne connait pas express et pense que c'est trop compliqué.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte