A (Delphi) bug’s life – atto II

In un precedente articolo abbiamo visto come aggirare un pericoloso bug della libreria IBxpress, creando un package contenente un componente derivato dall’originale TIBDataSet fornito da Embarcadero.
In questo articolo esploreremo una tecnica ancora più raffinata per risolvere il medesimo problema: useremo il cosiddetto hooking per patchare a runtime il componente originale, senza creare package aggiuntivi.

Cominciamo con un po’ di sana teoria. Con il termine hooking si identifica un gruppo di tecniche di manipolazione, volte a modificare o estendere le funzionalità di un software tramite l’intercettazione e il dirottamento di chiamate a funzione. I metodi per ottenere ciò sono almeno un paio: iniezione di istruzioni JMP e manipolazione delle virtual table.

La prima tecnica, come accennato, prevede l’iniezione di un’istruzione JMP all’inizio della funzione originale. L’istruzione JMP dirotta immediatamente l’esecuzione verso la nostra funzione, ed il gioco è fatto. Le cose si complicano se abbiamo bisogno anche della funzione originale. Ciò può accadere, ad esempio, se la nostra funzione è un wrapper e deve quindi chiamare l’originale. In questo caso, non è sufficiente sovrascrivere i primi 5 byte della funzione originale con il nostro JMP; essi vanno preservati, e quindi devono essere spostati da qualche altra parte in memoria prima della sovrascrittura. Il layout di una chiamata prima del dirottamento assomiglia a questo schema:
Chiamata semplice
Dopo il dirottamento, invece, potrebbe assomigliare a questo schema:
call2
Come vedete, i primi 5 byte della funzione originale sono stati spostati; al loro posto viene inserita un’istruzione JMP che rimanda alla nostra funzione modificata. Se la nostra funzione vuole chiamare l’originale, troverà i primi 5 byte nella porzione che è stata spostata; essi vengono fatti seguire da un ulteriore JMP, che fa saltare l’esecuzione al 6° byte della funzione originale. Il ritorno riporterà l’esecuzione dentro la nostra funzione modificata, che potrà restituire il controllo al chiamante originale.

L’altra tecnica prevede la manipolazione della virtual table. La virtual table è una struttura che ha a che fare con i concetti di polimorfismo ed ereditarietà tipici dei linguaggi di programmazione orientata agli oggetti. Essa contiene semplicemente dei puntatori a funzione, per la precisione i puntatori alle funzioni dichiarate virtuali. Quando si dichiara un tipo, il compilatore crea una virtual table per quel tipo, contenente i puntatori alle funzioni virtuali, che possono essere quelle della classe base (se non è stato fatto un override) oppure quelle della classe derivata. Quando si crea un oggetto di un certo tipo, il compilatore inserisce nell’istanza dell’oggetto un puntatore alla virtual table di quel tipo. In questo modo, l’oggetto avrà un comportamento dettato dal suo tipo di appartenenza. Ecco uno schema di come si presentano in memoria le virtual table di tre oggetti (il primo è l’istanza di una classe base, gli altri due sono istanze di classi derivate con un override delle funzioni virtuali):
vtable

Ma passiamo dalla teoria alla pratica. Il nostro obbiettivo è patchare la funzione TIBCustomDataSet.InternalSetFieldData, che come abbiamo discusso nel precedente articolo contiene un grossolano errore. Se andiamo a vedere la sua dichiarazione, scopriamo che essa è dichiarata virtuale, ma non è stato fatto alcun override nella classe derivata TIBDataSet:

    procedure InternalSetFieldData(Field: TField; Buffer: Pointer); virtual;

Questo significa che le virtual table di TIBCustomDataSet e di TIBDataSet conterranno entrambe un puntatore a questa funzione. Ma allora, è sufficiente che noi modifichiamo quel puntatore nella virtual table della classe TIBDataSet, per far sì che ogni istanza di TIBDataSet chiami la nostra funzione anziché quella originale!
Fortunatamente, il compito ci è anche facilitato dalle splendide funzioni scritte da Andreas Hausladen nel suo straordinario VCL Fix Pack, dal quale partiremo per realizzare la nostra patch.

Scarichiamo quindi il file VCLFixPack.pas, e iniziamo le modifiche.
Cominciamo con il definire una macro per condizionare la compilazione del codice alla versione di Delphi.

{$IF CompilerVersion = 21.0} // Delphi 2010
  {$IFDEF VCLFIXPACK_DB_SUPPORT}
    {$DEFINE IBDataSetClear21Fix}
  {$ENDIF VCLFIXPACK_DB_SUPPORT}
{$IFEND}

Bisognerebbe verificare se il problema abbraccia anche altre versioni di Delphi, e nel caso estendere il campo d’azione della patch. Purtroppo non dispongo di altre versioni se non RAD Studio 2010, quindi la mia patch si ferma a questa versione.
Aggiungiamo una manciata di unit alla clausola uses:

uses
  ...
  {$IFDEF VCLFIXPACK_DB_SUPPORT}
  DB, DBClient, DBGrids, DBCtrls,
  IBCustomDataSet, IBDatabase, IBExternals, IBHeader, IBSQL, IBIntf,
  FMTBcd,
  {$ENDIF VCLFIXPACK_DB_SUPPORT}
  ...

Ed ora viene il bello. Gli ingredienti della ricetta sono grossomodo gli stessi già usati nel precedente articolo:

  • un TIBCustomDataSetHelper, per ottenere gli indirizzi delle funzioni private e protette
  • un TIBCustomDataSetHack, per ottenere accesso ai campi privati

In più, aggiungeremo:

  • un TFix21IBCustomDataSet contenente la funzione patchata e gli stub assembler per le funzioni WriteRecordCache, CheckEditState e AdjustRecordOnInsert
  • una funzione di inizializzazione (InitIBDataSetClear21Fix) e una di finalizzazione (FiniIBDataSetClear21Fix) della patch

Ecco il listato della patch:

{ ---------------------------------------------------------------------------- }
{ Workaround TIBCustomDataSet.InternalSetFieldData bug }
{$IFDEF IBDataSetClear21Fix}

type
  TIBCustomDataSetHelper = class helper for TIBCustomDataSet
    function GetWriteRecordCacheAddress: Pointer;
    function GetCheckEditStateAddress: Pointer;
    function GetAdjustRecordOnInsertAddress: Pointer;
    function GetInternalSetFieldDataAddress: Pointer;
  end;

  {$HINTS OFF}
  TIBCustomDataSetHack = class(TWideDataSet)
  private
    FNeedsRefresh: Boolean;
    FForcedRefresh: Boolean;
    FIBLoaded: Boolean;
    FBase: TIBBase;
    FBlobCacheOffset: Integer;
    FBlobStreamList: TList;
    FBufferChunks: Integer;
    FBufferCache,
    FOldBufferCache: TRecordBuffer;
    FBufferChunkSize,
    FCacheSize,
    FOldCacheSize: Integer;
    FFilterBuffer: TRecordBuffer;
    FBPos,
    FOBPos,
    FBEnd,
    FOBEnd: DWord;
    FCachedUpdates: Boolean;
    FCalcFieldsOffset: Integer;
    FCurrentRecord: Long;
    FDeletedRecords: Long;
    FModelBuffer,
    FOldBuffer, FTempBuffer: TRecordBuffer;
    FOpen: Boolean;
    FInternalPrepared: Boolean;
    FQDelete,
    FQInsert,
    FQRefresh,
    FQSelect,
    FQModify: TIBSQL;
    FRecordBufferSize: Integer;
    FRecordCount: Integer;
    FRecordSize: Integer;
    FUniDirectional: Boolean;
    FUpdateMode: TUpdateMode;
    FUpdateObject: TIBDataSetUpdateObject;
    FParamCheck: Boolean;
    FUpdatesPending: Boolean;
    FUpdateRecordTypes: TIBUpdateRecordTypes;
    FMappedFieldPosition: array of Integer;
    FDataLink: TIBDataLink;
    FStreamedActive : Boolean;
    FLiveMode: TLiveModes;
    FGeneratorField: TIBGeneratorField;
    FRowsAffected: Integer;

    FBeforeDatabaseDisconnect,
    FAfterDatabaseDisconnect,
    FDatabaseFree: TNotifyEvent;
    FOnUpdateError: TIBUpdateErrorEvent;
    FOnUpdateRecord: TIBUpdateRecordEvent;
    FBeforeTransactionEnd,
    FAfterTransactionEnd,
    FTransactionFree: TNotifyEvent;
    FGDSLibrary : IGDSLibrary;
  end;
  {$HINTS ON}

  TFix21IBCustomDataSet = class(TIBCustomDataSet)
  private
    procedure AdjustRecordOnInsert(Buffer: Pointer);
    procedure CheckEditState;
    procedure WriteRecordCache(RecordNumber: Integer; Buffer: TRecordBuffer);

  private
    procedure NewInternalSetFieldData(Field: TField; Buffer: Pointer);
  end;

var
  fnWriteRecordCache: Pointer;
  fnCheckEditState: Pointer;
  fnAdjustRecordOnInsert: Pointer;

procedure TFix21IBCustomDataSet.AdjustRecordOnInsert(Buffer: Pointer);
asm
  push  EAX
  push  EDX
  mov   EAX, Self
  mov   EDX, Buffer
  call  fnAdjustRecordOnInsert
  pop   EDX
  pop   EAX
end;

procedure TFix21IBCustomDataSet.CheckEditState;
asm
  push  EAX
  mov   EAX, Self
  call  fnCheckEditState
  pop   EAX
end;

procedure TFix21IBCustomDataSet.WriteRecordCache(RecordNumber: Integer;
  Buffer: TRecordBuffer);
asm
  push  EAX
  push  EDX
  push  ECX
  mov   EAX, Self
  mov   EDX, RecordNumber
  mov   ECX, Buffer
  call  fnWriteRecordCache
  pop   ECX
  pop   EDX
  pop   EAX
end;

procedure TFix21IBCustomDataSet.NewInternalSetFieldData(Field: TField; Buffer: Pointer);
var
  Buff, TmpBuff: TRecordBuffer;
  hack: TIBCustomDataSetHack;
  isString: Boolean;
begin
  hack := TIBCustomDataSetHack(Self);

  Buff := GetActiveBuf;
  if Field.FieldNo < 0 then
  begin
    TmpBuff := Buff + hack.FRecordSize + Field.Offset;
    Boolean(TmpBuff[0]) := LongBool(Buffer);
    if Boolean(TmpBuff[0]) then
      Move(Buffer^, TmpBuff[1], Field.DataSize);
    WriteRecordCache(PRecordData(Buff)^.rdRecordNumber, Buff);
  end
  else
  begin
    CheckEditState;
    with PRecordData(Buff)^ do
    begin
      { If inserting, Adjust record position }
      AdjustRecordOnInsert(Buff);
      if (hack.FMappedFieldPosition[Field.FieldNo - 1] > 0) and
         (hack.FMappedFieldPosition[Field.FieldNo - 1] <= rdFieldCount) then
      begin
        Field.Validate(Buffer);
        isString := Field is TIBStringField;
        if (Buffer = nil) or
           isString and (PChar(Buffer)[0] = #0) then
          if not isString or TIBStringField(Field).EmptyAsNull then
            rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdIsNull := True
          else
          begin
            rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdDataLength := 0;
            rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdIsNull := False;
          end
        else
        begin
          Move(Buffer^, Buff[rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdDataOfs],
                 rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdDataSize);
          if (rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdDataType = SQL_TEXT) or
             (rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdDataType = SQL_VARYING) then
            rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdDataLength := StrLen(PChar(Buffer)) * 2;
          rdFields[hack.FMappedFieldPosition[Field.FieldNo - 1]].fdIsNull := False;
          if rdUpdateStatus = usUnmodified then
          begin
            if CachedUpdates then
            begin
              hack.FUpdatesPending := True;
              if State = dsInsert then
                rdCachedUpdateStatus := cusInserted
              else if State = dsEdit then
                rdCachedUpdateStatus := cusModified;
            end;

            if State = dsInsert then
              rdUpdateStatus := usInserted
            else
              rdUpdateStatus := usModified;
          end;
          WriteRecordCache(rdRecordNumber, Buff);
          SetModified(True);
        end;
      end;
    end;
  end;
  if not (State in [dsCalcFields, dsFilter, dsNewValue]) then
      DataEvent(deFieldChange, Longint(Field));
end;

function TIBCustomDataSetHelper.GetAdjustRecordOnInsertAddress: Pointer;
begin
  Result := @TIBCustomDataSet.AdjustRecordOnInsert;
end;

function TIBCustomDataSetHelper.GetCheckEditStateAddress: Pointer;
begin
  Result := @TIBCustomDataSet.CheckEditState;
end;

function TIBCustomDataSetHelper.GetWriteRecordCacheAddress: Pointer;
begin
  Result := @TIBCustomDataSet.WriteRecordCache;
end;

function TIBCustomDataSetHelper.GetInternalSetFieldDataAddress: Pointer;
begin
  Result := @TIBCustomDataSet.InternalSetFieldData;
end;

procedure InitIBDataSetClear21Fix;
begin
  fnWriteRecordCache := TIBCustomDataSet(nil).GetWriteRecordCacheAddress;
  fnCheckEditState := TIBCustomDataSet(nil).GetCheckEditStateAddress;
  fnAdjustRecordOnInsert := TIBCustomDataSet(nil).GetAdjustRecordOnInsertAddress;

  ReplaceVmtField(TIBCustomDataSet,
    TIBCustomDataSet(nil).GetInternalSetFieldDataAddress,
    @TFix21IBCustomDataSet.NewInternalSetFieldData
  );
  ReplaceVmtField(TIBDataSet,
    TIBCustomDataSet(nil).GetInternalSetFieldDataAddress,
    @TFix21IBCustomDataSet.NewInternalSetFieldData
  );
end;

procedure FiniIBDataSetClear21Fix;
begin
  ReplaceVmtField(TIBCustomDataSet,
    @TFix21IBCustomDataSet.NewInternalSetFieldData,
    TIBCustomDataSet(nil).GetInternalSetFieldDataAddress
  );
  ReplaceVmtField(TIBDataSet,
    @TFix21IBCustomDataSet.NewInternalSetFieldData,
    TIBCustomDataSet(nil).GetInternalSetFieldDataAddress
  );
end;

{$ENDIF IBDataSetClear21Fix}
{ ---------------------------------------------------------------------------- }

Per i dettagli, si rimanda al precedente articolo dove tutto il funzionamento è spiegato per bene. Qui mi soffermo solo sulle aggiunte, ovvero le due funzioni InitIBDataSetClear21Fix e FiniIBDataSetClear21Fix.
Come vedete, viene preso l'indirizzo delle tre funzioni protette WriteRecordCache, CheckEditState e AdjustRecordOnInsert attraverso l'helper. Quindi viene chiamata la funzione ReplaceVmtField, che sostituirà, nella virtual table, l'indirizzo della funzione originale (individuata da TIBCustomDataSet(nil).GetInternalSetFieldDataAddress) con quello della nostra funzione sostitutiva (identificata da @TFix21IBCustomDataSet.NewInternalSetFieldData). La funzione FiniIBDataSetClear21Fix non fa altro che eseguire l'operazione inversa. La sostituzione avviene sia sulla virtual table di TIBCustomDataSet, sia su quella di TIBDataSet, anche se tecnicamente ci serve solo quest'ultima (infatti un TIBCustomDataSet non verrà mai istanziato direttamente, quindi della sua virtual table potremmo fregarcene, ma a me piace essere preciso).
Non ci rimane che chiamare le nostre funzioni nelle sezioni initialization e finalization:

initialization
  ...
  {$IFDEF IBDataSetClear21Fix}
  InitIBDataSetClear21Fix;
  {$ENDIF IBDataSetClear21Fix}
finalization
  ...
  {$IFDEF IBDataSetClear21Fix}
  FiniIBDataSetClear21Fix;
  {$ENDIF IBDataSetClear21Fix}

Voilà! Possiamo riaprire il nostro vecchio progetto di test, ma questa volta dobbiamo tornare indietro: dobbiamo rimettere al suo posto un TIBDataSet laddove avevamo messo un TWrapIBDataSet (ricordate?).
Nel file dfm:

...
  object IBDataSet1: TIBDataSet
...

Nel file pas:

...
type
  TForm2 = class(TForm)
    IBDataSet1: TIBDataSet;
...

E infine, dobbiamo includere il VCLFixPack nel progetto. Mi raccomando: deve essere in cima all'elenco degli uses, secondo solo a FastMM, eventualmente:

uses
  FastMM4,
  VCLFixPack,
  Forms,
  ...

Vogliamo provare se funziona? Vai con F9!
Abbiamo domato...
...l'animale!
Due a zero per noi! Come avrete intuito, queste tecniche possono aprire la strada a tantissime possibilità di intervento. Laddove individuiamo un bug, non saremo più costretti ad attendere che Embarcadero ponga rimedio, ma potremo arrangiarci. Certo, tutto questo se paragonato con la libertà offerta dall'Open Source, è un po' grottesco...
Buon hacking a tutti.