
POC para la vulnerabilidad RCE en la librería ParseExcel, y también en ParseXLSX, como librería dependiente.
TL;DR: RCE a partir de la lógica en el análisis de cadenas de formato.
La causa raíz de la explotación proviene de llamar a eval sobre una entrada de usuario no validada en 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;
}
#...
}
Según lo que inspeccioné, la implementación actual de este flujo carece de una validación adecuada, y usar eval para manejar la lógica de comparación es excesivo ("over kill") en este caso. Debido a esto, tanto ParseExcel::parse como ParseXLSX::parse (usados para leer datos de archivos Excel) son vulnerables a RCE.
$format_str?ValFmt es el llamador más probable de ExcelFmt, así que explicaré más a fondo este método.
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} );
}
}
Si $oCell->{Type} es Date o Number, se llamará a ExcelFmt.
El valor $format_str es el que devuelve otro método: 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;
}
Se llama a otra función, así que examinaremos también FmtStringDef.
sub FmtStringDef {
my ( $oThis, $iFmtIdx, $oBook, $rhFmt ) = @_;
my $sFmtStr = $oBook->{FormatStr}->{$iFmtIdx}; # does the mapping
# More with assigning default format string, can ignore
#...
}
Con todas las variables claras, podemos concluir el vector de ataque de la siguiente manera:
$iFmtIdx$oBook->{Format}[$cellFmtIdx] apunte a $iFmtIdx$oCell->{FormatNo} = $cellFmtIdx)![[flow 1.png]]
En las secciones siguientes, explicaré en detalle cómo el payload propagó el código shell hasta el comando eval. Habrá 2 secciones: una para analizar archivos .xls con ParseExcel y otra para analizar archivos .xlsx con ParseXLSX.
Para demostrarlo, abajo está el enlace a nuestros archivos Excel maliciosos (en .xls y .xlsx) que ejecutan whoami y guardan el resultado en el archivo /tmp/inject.txt.
https://gist.github.com/haile01/0f4f19e4441895ef33ff27385080478b
Tomemos un programa Perl simple para analizar el archivo xls como el siguiente, que usa ParseExcel::parse. La RCE ocurrirá mientras se realiza el análisis, incluso antes de que se obtenga cualquier 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");
Los archivos binarios de Excel 97 están estructurados en fragmentos de datos binarios llamados registros BIFF. Cada registro comienza con una cabecera llamada opCode (en little-endian), luego la longitud del registro y sus datos reales.
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;
}
Después de eso, se utiliza un manejador correspondiente al tipo de registro para extraer los datos de ese registro BIFF.
if ( defined $self->{FuncTbl}->{$record} && !$workbook->{_skip_chart} )
{
$self->{FuncTbl}->{$record}
->( $workbook, $record, $record_length, $record_header );
}
La cadena de formato es manejada por _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;
}
No estaba seguro de qué versión de BIFF se usaba en mi archivo .xls, pero según los datos del archivo binario, debería coincidir con el caso else (> verBIFF5).
La estructura del registro de cadena de formato en versiones más nuevas de BIFF debería ser
1E 04 [longitud del registro - 2 bytes] [índice de la cadena de formato - 2 bytes] [longitud de la cadena de formato - 1 byte] [banderas de cadena - 2 bytes] [contenido de la cadena de formato]
Siguiendo la estructura correcta, puedo inyectar cualquier cadena de formato en el archivo .xls.
El registro BIFF real de la cadena de formato que inyecté en el PoC (el índice de la cadena de formato es \x00\xa5)