Un archivo xlsx es un archivo ZIP, y ZIP no tiene una sola tabla de contenidos autoritativa. HotXLS Excel Library para Delphi y C++Builder trata esa ambigüedad como una superficie de ataque: su analizador de fin de directorio central acepta un registro candidato solo después de que cuatro verificaciones cruzadas independientes coincidan, así que un directorio falsificado escondido en un comentario ZIP nunca gana
El escenario que hace esto concreto es mundano. Un servidor acepta cargas de hojas de cálculo de clientes. El archivo pasa un escaneo antivirus, se escribe en un directorio de cola, y tu servicio Delphi lo abre para extraer tres columnas. Todo se ve bien, excepto que el escáner y tu analizador no coincidieron en lo que contenía el archivo. El escáner enumeró un conjunto de miembros; tu cargador enumeró un conjunto diferente de los mismos bytes. Ninguno de los dos tiene un error en el sentido ordinario. Simplemente resolvieron una ambigüedad en el formato ZIP en dos direcciones distintas, y un atacante eligió los bytes para que lo hicieran
¿Dónde vive realmente la verdad sobre un archivo ZIP?
Vive al final mismo, en una estructura de 22 bytes llamada registro de fin de directorio central. Un archivo ZIP no se lee de principio a fin: cada miembro lleva un encabezado de archivo local inmediatamente antes de sus datos comprimidos, pero el índice autoritativo es el directorio central, una serie de registros cerca del final que nombra cada entrada y da el desplazamiento de su encabezado local. Para encontrar el directorio central primero debes encontrar el EOCD, porque el EOCD es lo que dice dónde comienza el directorio y cuántos registros contiene. HotXLS lo modela como TEndOfCentralDirectoryRecord, cuyos campos se mapean uno a uno con el diseño en disco: FDiskNumber en el desplazamiento 4, FStartDisk en 6, FThisDiskEntries en 8, FTotalEntries en 10, FSizeOfCD en 12, FOffsetOfStartCD en 16, y FCommentLen en 20. Ese total es FMinSize, calculado en el constructor como 4*3 + 5*2. Después viene el comentario del archivo, hasta 65535 bytes de contenido arbitrario, lo que hace que FMaxSize sea 65557 y significa que el registro no está en una posición fija. Tienes que ir a buscarlo
¿Por qué no basta con escanear hacia atrás en busca de la firma EOCD?
Porque los cuatro bytes que estás buscando, PK\005\006, pueden aparecer legalmente dentro del comentario del archivo, dentro de datos comprimidos, o dentro de un segundo EOCD que un atacante agregó a propósito. Un analizador que se detiene en la primera firma que encuentra al caminar hacia atrás es trivialmente dirigible: coloca un EOCD señuelo cerca del final y el analizador ingenuo lo sigue, mientras que un analizador que escanea en un orden diferente, o que trata la última firma del archivo como canónica, sigue la real. Esta es la familia de ataques de ambigüedad ZIP, y su recompensa es exactamente la división descrita arriba, donde el motor de escaneo y la aplicación consumidora ven conjuntos de entradas diferentes de un archivo
TEndOfCentralDirectoryRecord.Parse sí escanea hacia atrás. Establece startscan en el último byte, limita endscan a lsize - FMaxSize o cero, y recorre la ventana en búferes de 256 bytes que se superponen en tres bytes para que una firma que cruza un límite de búfer nunca se pierda. La diferencia está en lo que sucede al encontrar una coincidencia. Encontrar la firma solo produce un desplazamiento Candidate. HotXLS luego lee los 22 bytes en ese desplazamiento, los analiza con ReadEOCD, y exige que los campos resultantes sean internamente consistentes con el archivo que dicen describir antes de que FOffsetEOCD se asigne siquiera
Candidate := pos + j - 3;
if Candidate + FMinSize <= lsize then
begin
SetLength(RecordBuf, FMinSize);
inputstream.Position := Candidate;
if StreamReadExact(inputstream, RecordBuf[0], FMinSize) then
begin
ReadEOCD(RecordBuf[0], 0);
if (Candidate + FMinSize + FCommentLen = lsize) and
(FDiskNumber = 0) and (FStartDisk = 0) and
(FThisDiskEntries = FTotalEntries) and
(Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate) then
begin
FOffsetEOCD := Candidate;
Result := FOffsetEOCD;
Exit;
end;
end;
end;
Lee el predicado como cuatro afirmaciones separadas que una falsificación debe satisfacer simultáneamente. Candidate + FMinSize + FCommentLen = lsize exige que la longitud de comentario declarada llegue exactamente al final del archivo, que es lo que mata el truco de señuelo en el comentario: un EOCD falso enterrado dentro de un comentario real no puede también dar cuenta de cada byte después de sí mismo. FDiskNumber = 0 y FStartDisk = 0 rechazan los campos de expansión multi-disco que ningún xlsx ha usado legítimamente nunca y que existen en archivos manipulados solo para confundir. FThisDiskEntries = FTotalEntries rechaza el truco de conteo dividido donde un analizador dimensiona su bucle a partir de un campo y otro analizador a partir del otro. Y Int64(FOffsetOfStartCD) + FSizeOfCD = Candidate exige que el directorio central termine precisamente donde comienza el EOCD, así que el directorio no puede apuntarse a algún blob no relacionado en otra parte del archivo. El casteo a Int64 en el último importa: ambos operandos son de 32 bits, y sin ampliación, un par manipulado podría desbordarse y satisfacer la prueba aritméticamente mientras apunta a ningún lugar sensato
Los encabezados locales deben coincidir con el directorio central
Las verificaciones del EOCD fijan qué directorio es autoritativo; todavía no garantizan que el directorio diga la verdad sobre miembros individuales. Cada entrada se describe dos veces en un archivo ZIP, una vez centralmente y otra en su encabezado local, y nada en el formato obliga a que las dos descripciones coincidan, así que un lector que confía en el directorio central y un lector que confía en los encabezados locales pueden extraer contenido diferente de un archivo. TZipEntry.ParseLocalHeader cierra esa brecha analizando el encabezado local en FCdFile.LocalFileHeaderOffset y comparando las dos copias campo por campo, devolviendo un código negativo distinto para cada tipo de desacuerdo: el nombre de entrada canonicalizado, el método de compresión, las banderas de propósito general, y, cuando la bandera del descriptor de datos está apagada, el CRC32 y ambos tamaños. Con esa bandera activa las copias locales pueden ser cero, ya que los valores reales viven en un descriptor final, pero cualquier valor local no cero todavía debe coincidir. Una verificación final rechaza entradas cuyos datos se extenderían más allá del final del archivo, comparando Int64(FLFile.DataOffset) + Int64(FCdFile.FCompressedSize) contra inputstream.Size. Cualquier fallo se propaga fuera de TCentralDirectory.Parse como un resultado distinto de 1 y TZipArchive.OpenArchive lo convierte en Can't open zip archive, en vez de entregarte un objeto de archivo medio confiable. Cuando solo necesitas saber qué hojas contiene un archivo, ejecutar esa validación antes de un análisis completo es barato, y la ruta de inspección ligera de hojas te da exactamente eso sin materializar datos de celda
¿Qué pasa cuando los bytes mismos mienten?
El acuerdo estructural todavía no dice nada sobre la carga útil, así que HotXLS envuelve cada flujo de entrada en TZipVerifiedStream, que impone el tamaño declarado y el CRC32 mientras quien llama lee. Esto deliberadamente no es una verificación posterior: una bomba de descompresión cuyo tamaño no comprimido declarado es 4 KB pero que se infla a gigabytes se detiene en la marca de 4 KB, no después del daño. El envoltorio limita cada lectura a los bytes declarados restantes, lanza ZIP entry ended before its declared size si la fuente se agota temprano, sondea un byte extra al completarse y lanza ZIP entry exceeds its declared size si queda algo, y finalmente compara el CRC32 en ejecución en VerifyComplete, lanzando ZIP entry uncompressed size mismatch o ZIP entry CRC32 mismatch
if Count > 0 then
begin
Result := FSource.Read(Buffer, Count);
if Result <= 0 then
raise Exception.Create('ZIP entry ended before its declared size');
FCRC32 := ZLibCRC32(FCRC32, Buffer, Result);
Inc(FPosition, Result);
end
else
Result := 0;
if FPosition = FExpectedSize then
begin
if FSource.Read(Probe, 1) <> 0 then
raise Exception.Create('ZIP entry exceeds its declared size');
VerifyComplete;
end;
Una consecuencia vale la pena planificarla. El flujo es solo hacia adelante por diseño; un Seek a cualquier lugar distinto de la posición actual lanza ZIP entry stream is forward-only, con una única concesión para soEnd con desplazamiento cero para que las consultas de tamaño sigan funcionando. Ese es el intercambio correcto para entrada no confiable, porque un flujo que puedes rebobinar es un flujo cuya contabilidad CRC puedes derrotar, pero sí significa que el código consumidor que espera un flujo con búsqueda necesita su propio búfer. La misma disciplina de solo-hacia-adelante sostiene al lector directo de streaming, que es la API a la que recurrir cuando el libro cargado es suficientemente grande como para que no quieras tenerlo residente en memoria en absoluto
Límites de recursos antes de asignar, no después
Tres constantes en lxZipArchive acotan lo que un solo archivo puede pedirle hacer al proceso, y TZipEntries.Add las aplica mientras el directorio central todavía se está leyendo, antes de que se toque un byte de datos de entrada. ZipMaxEntryUncompressedSize limita un miembro a 1 GiB, ZipMaxTotalUncompressedSize limita el archivo a 4 GiB, y ZipMaxCompressionRatio de 10000 rechaza cualquier entrada deflactada cuya expansión declarada exceda diez mil veces, junto con el caso degenerado de un tamaño no comprimido distinto de cero emparejado con un tamaño comprimido de cero. Los nombres de entrada pasan por CanonicalZipEntryName en la misma llamada, que rechaza caracteres NUL incrustados, dos puntos, y cualquier segmento de ruta .. con Invalid ZIP entry name, y que pone en minúsculas y normaliza segmentos para que dos miembros que difieren solo en mayúsculas o en separadores redundantes colisionen como Duplicate ZIP entry name en vez de sombrearse silenciosamente el uno al otro
Defensa en profundidad por encima de la capa ZIP
La capa ZIP es un nivel entre varios, y el patrón se repite en cualquier lugar donde HotXLS analiza estructura controlada por el atacante. El ejemplo más claro se encuentra en el analizador de fórmulas BIFF: TXLSFormula.GetTranslated recurre a través de tokens tMemFunc, así que un flujo de tokens rgce manipulado en un .xls heredado puede anidarse arbitrariamente profundo y agotar la pila. La puerta es una constante, MaxTranslateDepth = 256, elegida contra un hecho ascendente conocido en vez de adivinada. Excel limita el anidamiento de fórmulas a 64, así que 256 deja cuádruple margen y nunca puede rechazar una fórmula que produjo una hoja de cálculo real, mientras sigue terminando un flujo malicioso suficientemente antes de que se agote la pila
const
MaxTranslateDepth = 256;
begin
isOuter := FTranslateDepth = 0;
if isOuter then
ResetPendingArrays;
Inc(FTranslateDepth);
try
if FTranslateDepth > MaxTranslateDepth then
begin
Result := nil;
Exit;
end;
Nota que la puerta devuelve nil en vez de lanzar. Una fórmula demasiado profunda para ser genuina no produce ningún árbol de sintaxis, el análisis circundante continúa, y el libro todavía se carga. Esa asimetría es intencional y vale la pena copiarla en tus propios límites: un límite que existe para detener el agotamiento de recursos debería degradar la unidad más pequeña que pueda, no abortar el documento. El mismo razonamiento aplica cuando extiendes la capa de cálculo, así que si registras tus propios manejadores a través de la API de funciones personalizadas del motor de fórmulas, dales sus propios límites de argumentos y recursión en vez de asumir que quien llama ya verificó
Lo que estas verificaciones no te compran
Sé preciso sobre el límite. Las cuatro verificaciones cruzadas del EOCD hacen que el índice del archivo sea inequívoco, así que HotXLS y cualquier otro lector conforme resuelven el mismo archivo al mismo conjunto de entradas; no dicen nada sobre si ese conjunto de entradas es benigno. El acuerdo de encabezado local detiene el truco de las dos vistas, no una carga útil maliciosa que se describe consistentemente. El flujo verificado detiene el truncamiento, el desbordamiento y la corrupción, no una parte XML perfectamente bien formada que codifica algo que no esperabas. Y nada de esto toca las macros: un proyecto VBA dentro de un libro estructuralmente impecable sigue siendo un proyecto VBA, y la decisión de conservarlo, eliminarlo o rechazarlo pertenece a tu capa de política, no al lector ZIP
Lo que obtienes a cambio es un límite de fallo limpio. Un xlsx no confiable o bien se abre como un archivo inequívoco cuyos miembros coinciden con sus tamaños y sumas de verificación declarados, o lanza con un mensaje que nombra el invariante específico que rompió, y tu servicio puede poner en cuarentena a partir de la excepción en vez de adivinar. El lector ZIP y las capas del analizador por encima de él vienen como parte del componente Excel HotXLS para Delphi y C++Builder, que no necesita ni Excel ni automatización OLE en la máquina que hace el análisis, y esa ausencia es en sí misma una reducción significativa de lo que un archivo cargado puede alcanzar