Retour au cours

games / lua

Sandboxing avancé d'un environnement Lua

Leçon 151 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi la bibliothèque debug ne doit jamais être exposée dans un sandbox
  • Limiter la mémoire consommée par un script sandboxé avec collectgarbage("count")
  • Limiter le temps d'exécution d'un script avec un hook d'instructions posé sur une coroutine
  • Construire une liste noire ET une liste blanche pour tester automatiquement un sandbox
  • Identifier pourquoi un sandbox "presque" complet reste souvent totalement contournable

Dans quel contexte ?

Un serveur de jeu permet à sa communauté d'écrire des scripts de mini-jeux personnalisés, chargés dynamiquement pendant que le serveur tourne. Un script buggé (boucle infinie) ou malveillant (tentative d'accès au système de fichiers) ne doit jamais pouvoir affecter les autres joueurs ni le serveur lui-même — un niveau de rigueur bien plus élevé que le sandboxing basique déjà vu en leçon 13.

Prolonger le sandboxing vu en leçon 13 : ce qui manquait

La leçon sur l'intégration moteur hôte a introduit l'idée de restreindre l'environnement d'un script tiers (_ENV). Cette leçon montre que cette seule restriction ne suffit pas : un sandbox "presque" complet reste, en pratique, souvent totalement contournable — un sandboxing sérieux doit couvrir plusieurs dimensions à la fois, pas seulement l'accès aux fonctions.

Le piège le plus dangereux : la bibliothèque debug

Même avec un environnement de fonctions restreint, exposer la bibliothèque debug casse toute la protection : elle permet techniquement de remonter à l'environnement global réel du processus hôte, en contournant complètement la restriction mise en place. C'est une règle absolue à retenir : debug ne doit jamais faire partie d'un environnement sandboxé, quelle que soit la raison qui pousserait à l'y inclure.

Restreindre les accès ne suffit pas : il faut aussi limiter les ressources

Un script sandboxé qui ne peut accéder à rien de dangereux peut quand même bloquer le programme hôte avec une boucle infinie, ou épuiser la mémoire disponible. Deux protections complémentaires s'imposent : un hook d'instructions qui interrompt le script après un nombre d'opérations défini, et une surveillance de la mémoire allouée pendant son exécution — sans ces deux limites, un script mal écrit (ou malveillant) peut geler l'application entière, même sans jamais accéder à un fichier ou au réseau.

Pourquoi tester le sandbox lui-même, pas seulement l'utiliser

Un sandbox n'est fiable que si son absence de fuite est vérifiée activement, pas supposée. Combiner une liste noire explicite (vérifier qu'aucune fonction dangereuse connue n'est accessible) avec une liste blanche stricte (n'autoriser que ce qui est explicitement listé) donne une double garantie : la moindre fonction dangereuse oubliée dans la liste blanche, ou ajoutée par erreur plus tard, doit être détectée par un test automatisé avant de devenir une faille exploitable en production.

Dimension du sandboxOutilProtège contre
Accès aux fonctions_ENV restreintLecture du disque, exécution système
Mémoirecollectgarbage("count")Épuisement mémoire du serveur
Temps CPUdebug.sethook sur coroutineBoucle infinie qui gèle le serveur

Piège dangereux

Exposer la bibliothèque debug, même partiellement, dans un environnement sandboxé permet techniquement de remonter jusqu'à l'environnement global réel du processus hôte, annulant toute la protection mise en place par ailleurs. debug ne doit jamais faire partie d'un sandbox, sans aucune exception.

Commandes & code

Sandboxing avancé d'un environnement Lua

Un sandbox robuste ne se limite pas à restreindre _ENV : il faut aussi contrôler la mémoire, le temps CPU et empêcher l'évasion via la bibliothèque debug.

lua
-- Niveau 1 : environnement restreint (rappel), mais insuffisant seul
local function baseSandbox()
    return {
        print = print, pairs = pairs, ipairs = ipairs, tostring = tostring,
        string = { format = string.format, sub = string.sub, len = string.len },  -- sous-ensemble, pas la lib entière
        math = math, table = { insert = table.insert, remove = table.remove },
    }
end

-- Piège d'évasion classique : la bibliothèque "debug" permet de remonter à l'environnement global réel
-- même depuis un chunk chargé avec un _ENV restreint -> NE JAMAIS l'exposer dans un sandbox
local malicious = [[
local d = debug   -- si debug est accessible, tout le sandboxing est contournable
local realEnv = d.getupvalue(some_upvalue_holder, 1)
]]
lua
-- Niveau 2 : limiter la mémoire allouée par le sandbox (collectgarbage avec un budget)
local function runWithMemoryLimit(fn, maxKB)
    collectgarbage("collect")
    local before = collectgarbage("count")   -- mémoire utilisée par Lua, en Ko

    local co = coroutine.create(fn)
    local ok, err = coroutine.resume(co)

    local after = collectgarbage("count")
    if (after - before) > maxKB then
        error(string.format("Sandbox: limite mémoire dépassée (%.1f Ko utilisés, max %d Ko)", after - before, maxKB))
    end
    return ok, err
end
lua
-- Niveau 3 : limiter le temps d'exécution avec un hook d'instructions (évite une boucle infinie de bloquer le host)
local function runWithTimeout(untrustedCode, env, maxInstructions)
    local fn, loadErr = load(untrustedCode, "sandboxed_chunk", "t", env)
    if not fn then
        return false, "Erreur de compilation: " .. tostring(loadErr)
    end

    local co = coroutine.create(fn)
    -- le hook est posé sur la coroutine, pas globalement : n'affecte pas le reste du programme hôte
    debug.sethook(co, function()
        error("Sandbox: limite d'instructions dépassée (script suspendu, boucle infinie suspectée)")
    end, "", maxInstructions)

    return coroutine.resume(co)
end

local untrusted = "while true do end"  -- boucle infinie volontaire pour test
local ok, err = runWithTimeout(untrusted, baseSandbox(), 500000)
print(ok, err)   -- false   Sandbox: limite d'instructions dépassée...
lua
-- Niveau 4 : liste blanche stricte de fonctions "sûres", refuser tout le reste explicitement
local DANGEROUS_GLOBALS = {
    "load", "loadstring", "dofile", "loadfile",   -- exécution de code arbitraire
    "require", "collectgarbage", "debug",          -- accès système / évasion
    "io", "os", "package",                          -- accès disque/process/modules
}

local function assertNoLeaks(sandboxEnv)
    for _, name in ipairs(DANGEROUS_GLOBALS) do
        assert(sandboxEnv[name] == nil, "Fuite de sécurité: '" .. name .. "' est accessible dans le sandbox !")
    end
end

assertNoLeaks(baseSandbox())   -- à exécuter en test automatisé à chaque modification du sandbox

Résumé

  • La bibliothèque debug ne doit JAMAIS être exposée dans un environnement sandboxé : elle permet l'évasion.
  • Limiter la mémoire via collectgarbage("count") et le temps via debug.sethook sur coroutine protègent le host.
  • Un sandbox doit être testé par liste noire explicite ET liste blanche : les deux approches se complètent.
  • Un sandbox "presque" complet reste exploitable : la moindre fuite (une seule fonction dangereuse oubliée) suffit à le casser.

Exercices pratiques

1 disponible
1

Mission : le mini-jeu communautaire qui s'échappe du sandbox

Objectif : Identifier une fuite critique de sandbox via la bibliothèque debug, et construire une protection à la fois contre l'évasion et contre une boucle infinie.

Contexte

Un serveur de mini-jeux communautaires construit ce sandbox pour exécuter les scripts des joueurs :

lua
local function playerSandbox()
  return {
    print = print, pairs = pairs, ipairs = ipairs,
    math = math, string = string, table = table,
    debug = debug,   -- ajouté "pour permettre le débogage des scripts joueurs"
  }
end

Un joueur malveillant soumet un script de mini-jeu qui utilise debug.getupvalue pour remonter jusqu'à l'environnement global réel du serveur et modifier des variables internes du moteur, complètement en dehors du sandbox prévu. Un autre joueur soumet un script contenant while true do end, qui gèle le mini-jeu pour tout le monde.

Résoudre l’exercice →