17. Funzioni senza frame

La gestione delle funzioni in frame rende facile per un decompilatore identificare variabili e parametri locali. Sfortunatamente, l’ottimizzazione del compilatore non genera un frame pointer per la maggior parte delle funzioni. Dopotutto, il motivo principale per usare un frame pointer è aiutare un debugger a scorrere l’elenco delle funzioni attive per dire all’utente come è stata raggiunta la funzione corrente e come individuare facilmente le variabili e i parametri locali (così come i registri che sono salvato tra le chiamate di funzione).

Il motivo per cui un compilatore può dimenticare il puntatore al frame è che il compilatore sa esattamente come accedere a ciascuna variabile o parametro locale in ogni punto della funzione. Quindi, vediamo la differenza nel codice generato tra funzioni con frame e funzioni senza frame:

PUSH FP                      SP -= #local_size        {
FP = SP
SP -= #local_size                                        int var1;
...                          ...
// access local var1
R1 = FP[-8]                  R1 = SP[#local_size-8]
PUSH R1                      PUSH R1
// access local var1 again
R2 = FP[-8]                  R2 = SP[#local_size-8+4]
PUSH R2                      PUSH R2
func()                       func()                     func(var1,var1);
// deallocate parameters
SP += 8                      SP += 8
...                          ...
SP = FP                      SP += #local_size        }
POP FP
RET                          RET

Rilevare la dimensione dei locali è abbastanza facile anche per le funzioni senza cornice. Basta cercare l’allocazione dell’area delle variabili locali tramite l’istruzione “SP -= #local_size” nel prologo della funzione, ed una corrispondente deallocazione delle variabili locali nell’epilogo della funzione.

Ma rilevare le variabili locali è più difficile. Quando si utilizza un frame pointer, l’espressione utilizzata per accedere a una variabile locale o a un parametro è sempre la stessa, indipendentemente dal numero di parametri passati a una funzione chiamata, poiché il registro del frame pointer non è influenzato dalle istruzioni PUSH utilizzate per passare i parametri.

Questo non è vero per le funzioni senza frame: l’espressione utilizzata per accedere allo stesso local dipende da come è cambiato il puntatore dello stack dall’inizio della funzione. Questo perché il puntatore dello stack viene utilizzato sia per accedere alle variabili sia anche per passare parametri ad altre funzioni. Pertanto, per le funzioni senza frame, un decompilatore deve calcolare un fattore di regolazione del puntatore dello stack per ciascuna istruzione, da aggiungere alla posizione iniziale di ciascuna variabile locale.

Si noti che questo deve accadere anche quando si calcola la posizione delle variabili stesse !

Quindi non possiamo usare lo stesso algoritmo che abbiamo usato per creare variabili e parametri locali per funzioni con frame in presenza di funzioni senza frame. Per esempio:

Instruction stream      Aggiunta allo SP   variable      locazione della variabile
SP -= #local_size            0                           SP's value = &CFA
R1 = SP[#local_size-8]       0             var1          CFA - 8 
PUSH R1
R1 = SP[#local_size-4]       4             var1          CFA - 8
PUSH R1
R1 = SP[#local_size-4]       8             var2          CFA - 12
...
SP += 8
R1 = SP[#local_size-8]       0             var1          CFA - 8

Usiamo qui il concetto di CFA, come definito nel formato di debug DWARF, per indicare il valore del puntatore dello stack nel punto di ingresso della funzione. Questo è il valore che usiamo come riferimento in tutta la funzione e il valore che usiamo per identificare le posizioni delle variabili. Quindi memorizziamo var1 con posizione “CFA - 8” e var2 con posizione “CFA - 12”. Con questo approccio, possiamo facilmente determinare se una variabile è una variabile locale o un parametro in entrata, poiché il valore dell’offset di CFA è negativo per le variabili locali e positivo per i parametri, proprio come avevamo per il caso della funzione inquadrata.

La conversione delle posizioni nel flusso di istruzioni viene eseguita facilmente con il seguente algoritmo:

   sp_adjust = 0
   for each instruction i in block
       if i.opcode == Push
          sp_adjust += i.operand_size
       if i.opcode == Pop
          sp_adjust -= i.operand_size
       if isSubFromSP(i)
          if block == entry_block
              local_size = i.op2.value
          else
              sp_adjust -= i.op2.value
       if isAddToSP(i)
          sp_adjust += i.op2.value

       i.result = replaceSPreferences(i.result)
       i.op1 = replaceSPreferences(i.op1)
       i.op2 = replaceSPreferences(i.op2)
   end for

Node replaceSPreferences(Node n){
   n.op1 = replaceSPreferences(n.op1)
   n.op2 = replaceSPreferences(n.op2)
   if n.isAddToSP()
      n = new AddNode(CFA, local_size - sp_adjust)
   if n.isSubToSP()
      n = new SubNode(CFA, local_size - sp_adjust)
   return n
}

Questo algoritmo non è perfetto, in quanto fa alcune ipotesi:

  • si assume che il valore di SP all’ingresso di ogni blocco base sia lo stesso, cioè che tutti i byte allocati all’interno di un blocco base vengano deallocati prima di uscire dal blocco base. Questo potrebbe non essere sempre vero, quindi i valori di aggiustamento calcolati per un blocco di base dovrebbero essere propagati ai successori del blocco.
  • presuppone che una chiamata di funzione non modifichi il valore del puntatore allo stack dopo che la funzione è stata restituita. Questo non è sempre vero, specialmente in x86, dove alcune convenzioni di chiamata consentono alla funzione chiamata di deallocare i propri parametri. Per gestire questo caso, dobbiamo conoscere la convenzione di chiamata per la funzione chiamata e modificare di conseguenza il valore di regolazione sp.
  • presuppone che tutti i calcoli avvengano sul registro SP. Se il registro SP viene copiato in un altro registro, ad esempio R1, quindi R1 viene modificato e quindi copiato nuovamente in SP, non saremmo riusciti a tenere traccia del nuovo valore di SP. Ciò può accadere nel caso della funzione originale che utilizza la pseudo-funzione “alloc(size)”.

A causa di queste limitazioni, non è sempre possibile ricostruire variabili e parametri locali per funzioni frameless. Fortunatamente, questo tipo di funzione non è comune (tranne forse in gcc, che fa molto uso delle chiamate “alloc(size)”), quindi è possibile contrassegnare queste funzioni e chiedere all’utente di fornire informazioni aggiuntive in modo interattivo.

Biografia

Sono uno specialista in materia di sicurezza informatica, scrittore, contributore per il progetto Monero, una criptovaluta che è focalizzata nel proteggere le informazioni sulle transazioni. Il libro che ho pubblicato Mastering Monero è diventato una delle migliori risorse per padroneggiare Monero. Più informazioni su di me

Seguimi su Twitter o scrivimi una e-mail. Le donazioni sono molto apprezzate, mi permettono di continuare a lavorare e a scrivere.

Mastering Monero Book