
POC per la vulnerabilità RCE nella libreria ParseExcel, e anche in ParseXLSX, come libreria dipendente.
TL;DR: RCE dalla logica nel parsing delle stringhe di formato.
La causa principale dell'exploit deriva dalla chiamata di eval su un input utente non validato 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;
}
#...
}
Secondo quanto ho ispezionato, l'implementazione attuale di questo flusso manca di una corretta validazione, mentre usare eval per gestire la logica di confronto è un "overkill" in questo caso. Per questo motivo, sia ParseExcel::parse che ParseXLSX::parse (usati per leggere i dati dai file Excel) sono vulnerabili a RCE.
$format_str?ValFmt è il chiamante più probabile di ExcelFmt, quindi approfondirò questo metodo.
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} );
}
}
Se $oCell->{Type} è Date o Number, verrà chiamato ExcelFmt.
Il valore $format_str è quello restituito da un altro metodo: FmtString.
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;
}
Viene chiamata un'altra funzione, quindi esamineremo anche FmtStringDef.
sub FmtStringDef {
my ( $oThis, $iFmtIdx, $oBook, $rhFmt ) = @_;
my $sFmtStr = $oBook->{FormatStr}->{$iFmtIdx}; # does the mapping
# More with assigning default format string, can ignore
#...
}
Ora che tutte le variabili sono chiare, possiamo concludere il vettore d'attacco come segue:
$iFmtIdx$oBook->{Format}[$cellFmtIdx] punti a $iFmtIdx$oCell->{FormatNo} = $cellFmtIdx)
![[flow 1.png]]Nelle sezioni seguenti, illustrerò in dettaglio come il payload ha propagato la shell code fino al comando eval. Ci saranno 2 sezioni: una per il parsing dei file .xls tramite ParseExcel e una per il parsing dei file .xlsx tramite ParseXLSX.
Per dimostrarlo, di seguito è riportato il link ai nostri file Excel malevoli creati ad hoc (in .xls e .xlsx) che eseguono whoami e salvano il risultato nel file /tmp/inject.txt.
https://gist.github.com/haile01/0f4f19e4441895ef33ff27385080478b
Prendiamo un semplice programma Perl per analizzare un file xls come quello sotto, che usa ParseExcel::parse. L'RCE si verificherà durante il parsing, anche prima che venga recuperato qualsiasi dato.
use strict;
use Spreadsheet::ParseExcel;
my $parser = Spreadsheet::ParseExcel->new();
# file.xls is malicious file from end user
my $workbook = $parser->parse("test.xls");
I file binari di Excel 97 sono strutturati in blocchi di dati binari chiamati record BIFF. Ogni record inizia con un'intestazione chiamata opCode (in little-endian), seguita dalla lunghezza del record e dai suoi dati effettivi.
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;
}
Dopodiché, un gestore corrispondente al tipo di record viene usato per estrarre i dati del record BIFF.
if ( defined $self->{FuncTbl}->{$record} && !$workbook->{_skip_chart} )
{
$self->{FuncTbl}->{$record}
->( $workbook, $record, $record_length, $record_header );
}
La stringa di formato è gestita da _subFormat, con opCode = 0x41E.
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;
}
Non ero sicuro di quale versione BIFF fosse usata nel mio file .xls, ma secondo i dati nel file binario, dovrebbe corrispondere al caso else (> verBIFF5).
La struttura del record della stringa di formato nelle versioni BIFF più recenti dovrebbe essere: 1E 04 [lunghezza record - 2 byte] [indice stringa di formato - 2 byte] [lunghezza stringa di formato - 1 byte] [flag stringa - 2 byte] [contenuto stringa di formato]
Seguendo la struttura corretta, posso iniettare qualsiasi stringa di formato nel file .xls.