Note: This article is also available in french 🇫🇷.

Introduction

Kirby is a Nitendo video game character open-source PHP CMS oriented for creators and designers.

The vulnerability presented in this article is an XML External Entity (XXE) in Kirby’s toolbox.

Code review – Identifying vulnerable code

A quick code review on the latest version of Kirby (3.9.5 at the time) quickly revealed that the Xml::parse(string $sml) toolkit function (src/Toolkit/Xml.php) present in Kirby Core seemed vulnerable.

https://github.com/getkirby/kirby/blob/6b5dda600472b59a3a779f7e6b8e9e130cc414a1/src/Toolkit/Xml.php#L272-L281

  public static function parse(string $xml): array|null
  {
    $xml = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT);

    if (is_object($xml) !== true) {
      return null;
    }

    return static::simplify($xml);
  }

Indeed, using the LIBXML_NOENT option activates the substitution of external entities.

The PHP manual warns of the risk of XXE that this represents.

Code review – Usage detection

The Xml::parse() function is part of a toolbox in Kirby Core, i.e. it is available within a library for developers. There are no calls to Xml::parse() in Kirby Core, StarterKit or PlainKit. This means that for a Kirby instance to be vulnerable, the customer must have made a custom development that uses this function, or have installed an extension that uses this function.

In the documentation, you can see an example of virtual page creation using an RSS feed from an external source where the function is used.

On the other hand, it’s difficult to identify all the extensions that can use this function, but here’s at least one: FeedReader.
This extension, too, can be used to display an RSS feed using Xml::parse offering a higher-level interface.

According to the editor, Xml::parse() is used in the Xml data handler, such as Data::decode($string, 'xml'). This is not used directly in Kirby either.

Exploitation – Creation of a demonstration / Proof of concept

The Github repository Acceis/exploit-CVE-2023-38490 contains a vulnerable application in the form of a docker container, a payload and the steps required to reproduce the exploit.

This Github repository offers a ready-to-use exploitation, but below I’ll detail the manual process of creating a vulnerable application with Kirby.

  1. Deploy a basic Kirby instance with the StarterKit as described here https://getkirby.com/docs/guide/quickstart
  2. Reproduce the example page with an RSS feed https://getkirby.com/docs/guide/virtual-pages/content-from-rss-feed
  3. Make the following change to /site/models/rssfeed.php to accept a user-controllable source
    rssfeed model
  4. Take a real RSS feed (such as https://www.acceis.fr/feed/), save the XML file and incorporate an XXE payload (in this case allowing /etc/passwd to be read)
    payload
  5. Serve malicious file xxe.rss via HTTP
    HTTP server
  6. Trigger the vulnerability by delivering the malicious payload to the application, e.g. http://127.0.0.2:8080/rssfeed?feed=http://127.0.0.42:9999/xxe.rss
    access traces
  7. The application displays the local file read from the system
    proof of exploitation

Remediation – Patches content

For branches 3.8 and 3.9 (using PHP 8+), the patch is to remove the LIBXML_NOENT option introducing the vulnerability.

src/Toolkit/Xml.php

-$xml = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT);
+$xml = @simplexml_load_string($xml);

For branches 3.5, 3.6 and 3.7 (using PHP 7 or 8+), the patch also disables the problematic LIBXML_NOENT option, but it is also necessary to enable the libxml_disable_entity_loader directive to prevent external entities from being taken into account when the application is running on a version of PHP prior to version 8.

-$xml = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT);
+$loaderSetting = null;
+if (\PHP_VERSION_ID < 80000) {
+  // prevent loading external entities to protect against XXE attacks;
+  // only needed for PHP versions before 8.0 (the function was deprecated
+  // as the disabled state is the new default in PHP 8.0+)
+  $loaderSetting = libxml_disable_entity_loader(true);
+}
+
+$xml = @simplexml_load_string($xml);
+
+if (\PHP_VERSION_ID < 80000) {
+  // ensure that we don't alter global state by
+  // resetting the original value
+  libxml_disable_entity_loader($loaderSetting);
+}

Chronology of events

See the timeline.

Acknowledgements

Thanks to Bastian ALLGEIER and Lukas BESTLE of the Kirby team for their warm receptivity.

About the author

Article written by Alexandre ZANNI alias noraj, Penetration Test Engineer at ACCEIS.