games / lua
Sandboxing avancé d'un environnement Lua
Explication
Ce que vous allez apprendre
- Comprendre pourquoi la bibliothèque
debugne 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 sandbox | Outil | Protège contre |
|---|---|---|
| Accès aux fonctions | _ENV restreint | Lecture du disque, exécution système |
| Mémoire | collectgarbage("count") | Épuisement mémoire du serveur |
| Temps CPU | debug.sethook sur coroutine | Boucle 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.
-- 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)
]]-- 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-- 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...-- 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 sandboxRésumé
- La bibliothèque
debugne doit JAMAIS être exposée dans un environnement sandboxé : elle permet l'évasion. - Limiter la mémoire via
collectgarbage("count")et le temps viadebug.sethooksur 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
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 :
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"
}
endUn 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.