Artículo técnico

Validación del EOCD ZIP para XLSX no confiables en Delphi

Un archivo xlsx es un archivo ZIP, y ZIP no tiene una única tabla de contenidos con autoridad. 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 comprobaciones cruzadas independientes coincidan, así que un directorio falsificado oculto en un comentario ZIP nunca gana

El escenario que hace esto concreto es mundano. Un servidor acepta subidas 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 parece correcto, salvo 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 distinto a partir de los mismos bytes. Ninguno de los dos tiene un fallo en el sentido habitual. Simplemente resolvieron una ambigüedad del formato ZIP en dos direcciones distintas, y un atacante eligió los bytes para que así fuera

¿Dónde vive realmente la verdad sobre un archivo ZIP?

Vive justo al final, 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 una cabecera de archivo local justo antes de sus datos comprimidos, pero el índice con autoridad es el directorio central, una serie de registros cerca del final que nombra cada entrada y da el desplazamiento de su cabecera local. Para encontrar el directorio central primero hay que encontrar el EOCD, porque el EOCD es lo que dice dónde empieza el directorio y cuántos registros contiene. HotXLS lo modela como TEndOfCentralDirectoryRecord, cuyos campos se corresponden uno a uno con el diseño en disco: FDiskNumber en el desplazamiento 4, FStartDisk en el 6, FThisDiskEntries en el 8, FTotalEntries en el 10, FSizeOfCD en el 12, FOffsetOfStartCD en el 16, y FCommentLen en el 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. Hay que ir a buscarlo

¿Por qué no basta con explorar hacia atrás en busca de la firma del 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 añadió a propósito. Un analizador que se detiene en la primera firma que encuentra mientras recorre hacia atrás es trivialmente manipulable: coloca un EOCD señuelo cerca del final y el analizador ingenuo lo sigue, mientras que un analizador que explora en un orden distinto, 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 distintos a partir de un mismo archivo

TEndOfCentralDirectoryRecord.Parse sí explora hacia atrás. Fija startscan en el último byte, limita endscan a lsize - FMaxSize o cero, y recorre la ventana en búferes de 256 bytes que se solapan en tres bytes para que una firma que quede a caballo entre dos búferes nunca se pierda. La diferencia está en lo que ocurre en un acierto. Encontrar la firma solo produce un desplazamiento Candidate. HotXLS lee entonces los 22 bytes en ese desplazamiento, los analiza con ReadEOCD, y exige que los campos resultantes sean internamente coherentes con el archivo que dicen describir antes de que se asigne siquiera FOffsetEOCD

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 tiene que satisfacer simultáneamente. Candidate + FMinSize + FCommentLen = lsize exige que la longitud de comentario declarada llegue exactamente hasta el final del archivo, que es lo que mata el truco del señuelo dentro del comentario: un EOCD falso enterrado dentro de un comentario real no puede también dar cuenta de cada byte posterior a sí mismo. FDiskNumber = 0 y FStartDisk = 0 rechazan los campos de expansión multidisco que ningún xlsx ha usado nunca legítimamente y que existen en archivos manipulados solo para confundir. FThisDiskEntries = FTotalEntries rechaza el truco del recuento 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 empieza el EOCD, así que el directorio no puede apuntar a algún blob sin relación en otro lugar del archivo. La conversión Int64 en ese último punto importa: ambos operandos son de 32 bits, y sin la ampliación, un par manipulado podría desbordarse y satisfacer la prueba aritméticamente mientras apunta a ningún sitio sensato

Las cabeceras locales deben coincidir con el directorio central

Las comprobaciones del EOCD fijan qué directorio tiene autoridad; todavía no garantizan que el directorio diga la verdad sobre los miembros individuales. Cada entrada se describe dos veces en un archivo ZIP, una vez de forma central y otra en su cabecera 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 las cabeceras locales pueden extraer contenido distinto de un mismo archivo. TZipEntry.ParseLocalHeader cierra ese hueco analizando la cabecera local en FCdFile.LocalFileHeaderOffset y comparando las dos copias campo por campo, devolviendo un código negativo distinto para cada tipo de discrepancia: el nombre de entrada canonicalizado, el método de compresión, los indicadores de propósito general, y, cuando el indicador de descriptor de datos está desactivado, el CRC32 y ambos tamaños. Con ese indicador activado, las copias locales pueden ser cero, ya que los valores reales viven en un descriptor final, pero cualquier valor local distinto de cero sigue teniendo que coincidir. Una comprobació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 lugar 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 lo da exactamente sin materializar datos de celda

¿Qué pasa cuando los propios bytes mienten?

La coherencia estructural todavía no dice nada sobre la carga útil, así que HotXLS envuelve cada flujo de entrada en TZipVerifiedStream, que hace cumplir el tamaño declarado y el CRC32 a medida que el llamador lee. Esto deliberadamente no es una comprobación a posteriori: una bomba de descompresión cuyo tamaño sin comprimir declarado es de 4 KB pero que se expande 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 antes de tiempo, sondea un byte extra al terminar y lanza ZIP entry exceeds its declared size si queda algo, y finalmente compara el CRC32 acumulado 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;

Vale la pena prever una consecuencia. El flujo es solo hacia delante por diseño; un Seek a cualquier posición que no sea la 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. Esa es la compensación correcta para una entrada no confiable, porque un flujo que puedes rebobinar es un flujo cuya contabilidad de CRC puedes vencer, pero sí significa que el código consumidor que espera un flujo posicionable necesita su propio búfer. La misma disciplina de solo-hacia-delante sustenta el lector directo en streaming, que es la API a la que recurrir cuando el libro subido es lo bastante grande como para que no quieras tenerlo residente en memoria en absoluto

Límites de recursos antes de la asignación, no después

Tres constantes en lxZipArchive acotan lo que un único archivo puede pedirle que haga al proceso, y TZipEntries.Add las aplica mientras todavía se está leyendo el directorio central, antes de tocar un solo 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 deflada cuya expansión declarada supere las diez mil veces, junto con el caso degenerado de un tamaño sin comprimir 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 convierte a minúsculas y normaliza los segmentos para que dos miembros que difieran solo en mayúsculas o en separadores redundantes colisionen como Duplicate ZIP entry name en lugar de eclipsarse silenciosamente entre sí

Defensa en profundidad por encima de la capa ZIP

La capa ZIP es un nivel entre varios, y el patrón se repite dondequiera que HotXLS analice una estructura controlada por el atacante. El ejemplo más claro está 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 barrera es una constante, MaxTranslateDepth = 256, elegida a partir de un hecho conocido y no adivinada. Excel limita el anidamiento de fórmulas a 64, así que 256 deja un margen cuádruple y nunca puede rechazar una fórmula que produjo una hoja de cálculo real, mientras sigue terminando un flujo malicioso con suficiente antelación 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;

Observa que la barrera devuelve nil en lugar de lanzar una excepción. Una fórmula demasiado profunda para ser genuina no produce ningún árbol de sintaxis, el análisis circundante continúa, y el libro sigue cargándose. Esa asimetría es intencionada y merece 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 se 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 lugar de asumir que el llamador ya lo comprobó

Lo que estas comprobaciones no te compran

Sé preciso sobre el límite. Las cuatro comprobaciones 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. La coincidencia de cabeceras locales detiene el truco de las dos vistas, no una carga maliciosa que se describe de forma coherente. 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 único archivo inequívoco cuyos miembros coinciden con sus tamaños y sumas de comprobación declarados, o lanza una excepción 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 lugar de adivinar. El lector ZIP y las capas de análisis por encima de él se incluyen 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 puede alcanzar un archivo subido