Technical Article

Protección de una vinculación VCL de PDFium: ABI y seguridad de memoria

Una vinculación (binding) de Pascal sobre una biblioteca C se lee como Pascal ordinario. Se llama a un método, se recibe un registro de vuelta y se libera lo asignado. El problema es que PDFium es una biblioteca C y C++ con su propia convención de llamadas, sus propios anchos de enteros y sus propias reglas sobre quién posee la memoria y quién la libera. Nada de eso cruza el límite del lenguaje por sí solo. Cada uno de esos acuerdos debe ser reformulado a mano en las declaraciones de Pascal, y una sola palabra incorrecta convierte una llamada que parece limpia en una corrupción de pila (stack), un desplazamiento truncado o una doble liberación. Una auditoría de la versión v1.61.0 de una vinculación VCL de PDFium reveló un defecto de cada tipo. Vale la pena analizarlos porque no son específicos de esta vinculación. Son los riesgos permanentes de envolver cualquier API C en Delphi o Lazarus.

cdecl es parte del tipo de función, no una decoración

PDFium es C compilado. En Win32, sus exportaciones y, lo que es más importante, las devoluciones de llamada (callbacks) que invoca utilizan la convención de llamadas cdecl. Bajo cdecl, el llamador limpia la pila después de que la llamada retorna. El predeterminado nativo de Delphi es register, y el estándar de C en Win32 para devoluciones de llamada es stdcall en algunas bibliotecas, donde la función llamada se encarga de la limpieza en su lugar. Cuando una estructura entrega a PDFium un puntero de función y usted olvida colocar cdecl en el tipo de ese puntero, las dos partes no se pondrán de acuerdo sobre quién debe ajustar el puntero de la pila. Ambos lo corrigen, o ninguno lo hace, y el puntero de la pila se desvía por el tamaño de los argumentos en cada invocación.

La razón por la que este defecto es difícil de encontrar es que el daño no es local. La llamada dañada retorna y parece estar bien. La desalineación aparece más tarde, en alguna función no relacionada cuyo marco ahora se encuentra en un puntero de pila que está desviado por unos pocos bytes, y se manifiesta como una lectura errática, una dirección de retorno incorrecta o un bloqueo con un rastreo de pila que no apunta en absoluto al callback donde realmente ocurrió el error. Form-fill es el lugar clásico donde esto afecta, porque la interfaz de llenado de formularios es un registro lleno de callbacks que PDFium invoca. Uno de ellos, FFI_OpenFile, entrega a PDFium una función que llamará para abrir un archivo externo, declarada como function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Vale la pena copiar el cdecl final. Si lo omite, el código se seguirá compilando, enlazando y ejecutando perfectamente hasta el momento en que PDFium llame a la función. La convención pertenece al tipo de función en sí. No es un elemento opcional y el compilador no le advertirá de su ausencia porque un tipo de función simple es un tipo Pascal perfectamente legal. La única defensa es tratar la convención de llamadas como un campo obligatorio de cada firma importada y de cada callback que se envíe hacia afuera.

size_t es el ancho del puntero, y en FPC Win64 eso significa 64 bits

El segundo defecto es una discrepancia en el ancho de los enteros que solo aparece en un objetivo. El tipo size_t de C está definido para ser lo suficientemente amplio como para albergar cualquier tamaño de objeto, lo que en una plataforma de 64 bits significa un entero sin signo de 64 bits. Las interfaces de carga progresiva de PDFium se comunican en desplazamientos de bytes de tipo size_t. El registro FX_FILEAVAIL del proveedor de disponibilidad contiene un callback IsDataAvail que PDFium invoca con un desplazamiento y un tamaño, y el callback AddSegment del registro FX_DOWNLOADHINTS recibe lo mismo. Ambos parámetros son de tipo size_t.

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Si declara esos desplazamientos como un tipo de 32 bits, la vinculación funcionará en Win32 y en Delphi Win64, pero fallará silenciosamente en FPC y Lazarus Win64. La causa es sutil. En FPC Win64, NativeUInt es un tipo genuino de 64 bits con el ancho de un puntero, y size_t es un alias de este. La vinculación contiene un comentario en la sección de tipos que advierte precisamente contra la superposición de NativeUInt en FPC, ya que redefinirlo allí a un alias de 32 bits forzaría a size_t a ser de 32 bits y corrompería cada parámetro size_t pasado a la biblioteca o escrito por ella. Un desplazamiento de 64 bits que llega a un parámetro de 32 bits pierde su mitad superior. Para un archivo pequeño, cada desplazamiento cabe en 32 bits y nada falla. Para un archivo grande, en el momento en que un desplazamiento cruza la línea de los cuatro gigabytes, el valor truncado apunta a otro lugar completamente diferente, PDFium pregunta si el rango de bytes incorrecto está disponible, y la carga progresiva se detiene o lee basura. El defecto es invisible hasta que el archivo es lo suficientemente grande y el objetivo es aquel donde size_t realmente se expandió.

Una excepción Pascal nunca debe propagarse a través de un marco de C

Cuando PDFium invoca uno de sus callbacks, su código Pascal se ejecuta dentro de una pila de marcos de C y C++ que no saben nada sobre el mecanismo de excepciones de Delphi. Si su callback genera una excepción y permite que esta se propague, se deshará el camino de la pila a través de marcos que nunca fueron diseñados para tal fin. La propia limpieza de PDFium no se ejecutará, sus invariantes internas quedarán actualizadas a medias y el proceso entrará en un estado que la biblioteca nunca previó. El acuerdo para estos callbacks es un código de retorno, no una excepción.

Dos callbacks hacen que esto sea concreto. FPDF_FILEWRITE es el destino en el que PDFium escribe un documento guardado, y FPDF_FILEACCESS es el origen del que lee un documento de entrada. Ambos están implementados aquí sobre un TStream de Delphi, y ambos pueden fallar de la misma manera que falla cualquier flujo: el disco se llena, el flujo se cierra de forma inesperada o una lectura sobrepasa el final. El callback de escritura envuelve su escritura en el flujo y convierte cualquier falla en un código de falla de PDFium en lugar de permitir que se propague.

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium treats any non-1 return as a write failure. A Pascal exception
  // must not unwind through this cdecl/C++ frame, so trap it and report
  // failure instead.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

El lado de lectura hace lo mismo: una lectura fallida devuelve cero para cumplir con el contrato de FPDF_FILEACCESS en lugar de generar una excepción a través del límite. Un bloque except vacío sin volver a lanzar la excepción parece incorrecto para un programador de Pascal entrenado para nunca silenciar excepciones, y en Pascal ordinario lo es. En un límite de ABI es la forma correcta, porque el único valor seguro para devolver al llamador en C es un código de estado que este sepa interpretar. La falla aún se propaga, pero a través del valor de retorno, y el código de llamada por encima de la biblioteca la expone como EPdfError una vez que el control regresa al lado de Pascal.

La doble liberación se oculta en la ruta de error

El cuarto defecto es la propiedad. Un identificador (handle) de documento de PDFium es abierto por la biblioteca y debe cerrarse exactamente una vez mediante FPDF_CloseDocument. El peligro radica en una ruta de error que libera un identificador que otro proceso de limpieza también posee. Imagine una rutina que crea un objeto contenedor, le asigna un identificador de documento recién abierto y luego realiza configuraciones adicionales que podrían fallar. Si esa configuración genera un error, un controlador de retorno anticipado que llama a FPDF_CloseDocument sobre el identificador directo lo cerrará, y luego el propio destructor del objeto contenedor lo volverá a cerrar cuando el objeto sea liberado. El identificador se libera dos veces, lo que representa un comportamiento indefinido y un bloqueo probable de la aplicación.

La auditoría detectó esto en una ruta de importación de tipo imposición que crea un TPdf alrededor de un identificador ya abierto. La solución consiste en hacer que la transferencia de propiedad sea la única fuente de verdad. Una vez que el identificador se asigna al campo del contenedor, este lo posee y la única limpieza en la ruta de error es liberar el contenedor. El destructor del contenedor llama a FPDF_CloseDocument por usted, de modo que un segundo cierre explícito liberaría dos veces el mismo documento. El controlador de errores corregido libera el objeto y vuelve a lanzar el error, existiendo exactamente una sola ruta para el cierre.

Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result now owns the handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
  // here would double-free the same PDFium document.
  Result.Free;
  raise;
end;

Tanto los registros gestionados como una biblioteca llena de exportaciones necesitan un desmontaje explícito

La última clase se refiere a la memoria que el compilador gestiona en su nombre, la cual un hábito derivado de C corromperá silenciosamente. Muchas de las funciones auxiliares de esta vinculación devuelven un registro que contiene un WideString o un arreglo dinámico. Esos son campos con recuento de referencias, y el compilador genera un control interno oculto para mantener sus recuentos. El instinto heredado de C es limpiar un registro nuevo con FillChar(Result, SizeOf(Result), 0). Eso estampa ceros sobre la referencia gestionada dentro del registro sin decrementarla primero. El compilador reutiliza una variable temporal oculta para el resultado de una función a través de las iteraciones del bucle, por lo que en la segunda iteración, FillChar sobrescribe un puntero de cadena activo que nunca se liberó, y la cadena a la que apuntaba se filtra (leak). Llame a la función en un bucle sobre mil anotaciones y filtrará mil cadenas.

La solución consiste en permitir que el lenguaje limpie el registro de la manera que sabe hacerlo, mediante Default(T), lo que libera cualquier campo gestionado antes de ponerlo a cero.

// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);

Un problema de propiedad relacionado reside en el límite de carga de la biblioteca. Esta vinculación resuelve varios cientos de punteros de función desde la DLL de PDFium con GetProcAddress después de un LoadLibrary. Si falta una exportación requerida, el estado parcialmente vinculado es peligroso: docenas de punteros serán válidos, el resto serán nulos o no válidos, y cualquier llamada posterior a través de uno de ellos saltará a un módulo que ya podría estar descargado de la memoria. La vinculación maneja esto descargando la biblioteca y ejecutando un proceso completo ClearAllBindings que restablece cada puntero importado de vuelta a nil cada vez que una exportación requerida no se resuelve. Después de eso, ningún puntero de función queda suspendido apuntando a un módulo descargado, y una llamada posterior fallará limpiamente con una comprobación de puntero nulo en lugar de desviarse hacia código liberado.

El contenedor es donde cuatro acuerdos se reformulan a mano

Ninguno de estos cinco defectos es exótico. Son los modos de falla predecibles de una capa delgada de Pascal sobre una API de C, y se agrupan porque esa capa es exactamente donde se deben volver a declarar cuatro acuerdos independientes. La convención de llamadas debe declararse como cdecl en cada callback. El ancho de los enteros debe coincidir con size_t en el único objetivo donde realmente se expande. El modelo de excepciones debe convertirse en códigos de retorno en cada callback que salga de Pascal. La propiedad de cada identificador y de cada campo gestionado debe declararse una vez y respetarse en cada ruta, incluidas las rutas de error que nadie prueba hasta la producción. Si omite cualquiera de ellas, obtendrá un defecto cuyo síntoma aparecerá lejos de su causa, que es lo que hace que esta categoría sea costosa. El valor de la auditoría residió menos en una solución única que en tratar a cada una de estas como su propia disciplina para verificar a lo largo de toda la vinculación.

Si desea ver la vinculación realizando trabajo real en lugar de proteger sus bordes, las técnicas de caché de renderizado y zoom en nuestra nota sobre rendimiento de caché de renderizado y zoom muestran la ruta de renderizado, y la guía de compilación cruzada en creación de un visor Lazarus y FPC es el lugar donde el comportamiento de size_t en Win64 descrito aquí realmente importa. Ambos se basan en el mismo trabajo de seguridad de memoria y ABI que se incluye en PDFium Component para Delphi, Lazarus y C++Builder, junto con las API de renderizado, extracción de texto y formularios cubiertas en otras partes de este blog.