Un bac à sable pour Ruby

Un bac à sable pour Ruby

Je ne sais plus trop comment cette histoire a commencé. Hum… une discussion engagée par un développeur de codepen.io sur la possibilité d’utiliser des Dockers jetables pour “rendre” des fichiers HAML.

HAML est un format de template pour Ruby très peu verbeux, basé sur l’indentation. Il permet, entre autres, de faire des choses dangereuses, comme insérer du ruby dans le gabarit. Cette option est débrayable, heureusement, mais cet outil n’a jamais été conçu dans une optique de sécurité, mais de rendre le développeur heureux. Comme tous les produits Ruby, en fait.

Proposer de rendre des fichiers HAML comme service web est donc dangereux, il ne doit pas être bien difficile de mettre le bazar en soumettant un HAML mal intentionné.

Dans l’absolu, je ne me sens pas super concerné par HAML, ni par les entrailles de Ruby. Mais le challenge est intéressant : proposer un bac à sable avec les outils modernes servant de base à la conteneurisation.

Depuis l’intérieur de ruby

Ruby est un langage interprété capable d’aller très loin dans les abstractions, les bidouilles et les astuces. Tout est permis, il n’y a aucune limite pour atteindre l’objectif sacré : “à la fin, le code est beau”, le but ultime du développeur Ruby.

Vouloir border l’exécution de code ruby est donc complètement utopiste, voire même insultant.

Il existe des outils liés à la sécurité dans Ruby, comme la variable $SAFE, mais ça semble être peu pratique, et remis en cause dans les prochaines versions de Ruby.

Il existe la gem shikashi qui permet de faire des eval avec des listes blanches, mais pour l’utiliser, il faudrait patcher le code de la gem `haml.

Il y a visiblement des choses à faire dans Ruby même, mais bon, je n’ai pas super envie d’aller farfouiller dans ses entrailles, ni même beaucoup d’espoir de pouvoir y trouver quelque chose de déterministe ou systématique.

Depuis l’extérieur de Ruby

Docker a réussi à démocratiser la conteneurisation sur Linux, et à amener de la visibilité sur le sujet, beaucoup de visibilité.

Seul bémol, les conteneurs de Docker ne sont pas pour l’instant prévus pour isoler du code potentiellement agressif. Une équipe a été montée pour travailler sur ce sujet, et RedHat s’y intéresse aussi.

L’isolation des process est confiée aux namespaces, une innovation apparue dans le kernel 3.8, il y a maintenant 2 ans, mais qui surtout est officiellement troués jusqu’à la version 3.14 du kernel. Pour rappel, Trusty, la version courante d’Ubuntu utilise un kernel 3.13.

Bien conscient du problème, Docker propose d’utiliser l’une des deux implémentations vedettes des LSM : Apparmor ou SELinux. Je n’ai pas eu l’occasion de me documenter sur SELinux, et Apparmor a le bon gout d’être installé par défaut sur Ubuntu depuis des années (et Debian le gère sans soucis). Pour l’instant, la documentation de Docker sur la sécurité est spartiate (les sources de libcontainer sont lisibles), et rien ne semble prévu pour demander à restreindre des accés à l’intérieur même du conteneur.

Github a réalisé un prototype pour emballer du Ruby générique dans un Docker : Hoosegow. Ils donnent tous les liens vers les informations disponibles, mais font le pari de confier la sécurité à Docker.

Découper en tranches

Docker, c’est beaucoup de Kernel, un peu d’API, et pas mal de choix d’organisation (type de réseaux, système de fichiers en oignons, chemin des Cgroups …). Pour la partie isolation/coercition, le rôle est confié aux Namespaces et aux Cgroups, le tout emballé par Apparmor. Les Namespaces sont officiellement troués. Deuxième soucis, Apparmor travaille avec des chemins complets, et Docker utilise un UUID au dernier moment, pour nommer le dossier racine du containers. Il y a donc un souci pour préparer le apparmor avant de pouvoir l’utiliser.

Docker fourni nsinit, un outil en ligne de commande pour coudre à la main des namespaces pour tailler sur mesure son propre conteneur. On ne peut pas tout à fait qualifier cet outil de grand public. Il m’a résisté. Je suis donc parti sur une solution simple, basée sur Apparmor et Cgroup, pour les Namespaces, on verra plus tard.

Isoler un service

La tactique est simple : isoler le code gérant le haml dans un serveur, et le mettre dans une boite qui interdit un maximum d’actions possibles :

  • La boite est en lecture seule, sans réseau IP, sans capabilites.
  • Seuls les fichiers nécessaires pour lancer le code ruby sont lisibles.
  • Le serveur va communiquer via une socket UNIX, seul élément accessible en écriture.
  • Le protocole n’utilise aucune sérialisation, juste des Pascal strings : une taille sur 4 octets, suivi du message.
  • Le client gère un timeout pour ne pas attendre un serveur bloqué.
  • Cgroup permet de limiter la mémoire, et la ration de CPU disponibles.

Bundler

Bundler est utilisé pour installer les bibliothèques nécessaires de manière propre et déterministe. Le serveur sera lancé avec un -I pour ne pas devoir embarquer tout bundler dans Apparmor : pas de bundle exec. L’application a un shebang et un chemin en dur : /opt/box/box pour permettre l’interception par le démon de Apparmor.

Apparmor

La règle Apparmor est créée en ajoutant les fichiers nécessaires un par un, jusqu’à ce que le serveur se lance, puis que le client puisse lui parler. C’est un peu laborieux, mais je suis moyennement convaincu par l’outillage actuel, avec l’activation d’un audit et surveillance automatique des logs. On parle de 50 lignes de serveurs utilisant 1 bibliothèque et de 40 lignes de clients, rien de comparable avec la moindre application Rails.

Eye

L’application bénéficie d’un superviseur, Eye,qui se charge de lancer et relancer le service en cas d’incident. Pour l’instant, seul le service est isolé par Apparmor.

Eye.config do
  logger '/var/log/sandbox/eye.log'
end

Eye.application 'sandbox' do
  working_dir '/var/run/box'
  trigger :flapping, times: 10, within: 1.minute, retry_in: 10.minutes
  stdall 'trash.log' # stdout,err logs for processes by default

  # eye daemonized process
  process :box do
    pid_file 'box.pid' # pid_path will be expanded with the working_dir
    start_command '/usr/bin/ruby -I /opt/box/vendor/bundle/ruby/1.9.1/gems/ /opt/box/box'
    daemonize true
  end
end

Cgroups

Un utilisateur est créé, pour profiter des droits UNIX classiques, et surtout pour pouvoir y accrocher des Cgroups. Il n’est pas nécessaire de dégainer des outils de haut niveau pour ça, Cgroups se configure avec de simples fichiers montés dans un point de montage de type cgroup.

Init.d

Le script init.d est laid comme tous les scripts init.d mais comme il est tombé en marche au bout de 10 minutes alors que la version upstart m’a résisté pendant tout le weekend, je ne critiquerai pas. L’abandon d’Upstart dans la prochaine version LTS d’Ubuntu est par contre une très bonne nouvelle. J’ai rarement vu un produit aussi pénible et incapable de dire où il a mal, puis qui se bloque au bout d’un moment, avec le reboot comme seule issue. La doc est pleine de promesses d’améliorations pour la prochaine release que personne ne verra jamais.

Le superviseur et le service tournent avec un utilisateur non privilégié lancé par un start-stop-daemon, init.d se charge de préparer le terrain puis d’attacher le service dans un cgroup.

do_pre_start()
{
    mkdir -p /run/box
    chown box /run/box

    mkdir -p /var/log/sandbox
    chown box /var/log/sandbox

    cgcreate -a box -t box -g memory,cpu:box
    # 25%
    echo 256 > /sys/fs/cgroup/cpu/box/cpu.shares
    # 32Mo
    echo 32000000 > /sys/fs/cgroup/memory/box/memory.limit_in_bytes

    mkdir -p /opt/box/.eye
    chown box /opt/box/.eye
}

do_post_start()
{
    sleep 3 # Yes, this is ugly, it should be a loop
    cgclassify -g memory,cpu:box `cat /run/box/box.pid`
}

On retrouve la logique de poupées gigognes (les matriochkas), avec une séparation claire des responsabilités. L’init crée le contexte puis lance un superviseur, qui lance un service dans un contexte rigoriste. Ruby n’est jamais lancé avec l’utilisateur root.

Je n’ai aucune idée du niveau de sécurité qu’amène cette suite de bonne pratique, mais on est clairement au-dessus de pas mal de sites web.

Améliorer

Par principe, il faudrait mettre la boite dans une boite de virtualisation, utiliser un kernel patché avec GRSec, ajouté du log (auditd et tcpspy ) vers une machine distante, et attendre un drame pour ensuite corriger.

Le code

Le code est disponible sur github, avec une jolie licence LGPL : Sandbox

blogroll

social