GNU/Linux >> Linux Esercitazione >  >> Linux

Come smontare, modificare e poi rimontare un eseguibile Linux?

Non credo che ci sia un modo affidabile per farlo. I formati del codice macchina sono molto complicati, più complicati dei file di assemblaggio. Non è davvero possibile prendere un binario compilato (diciamo, in formato ELF) e produrre un programma di assemblaggio sorgente che verrà compilato nello stesso binario (o abbastanza simile). Per comprendere le differenze, confronta l'output della compilazione di GCC direttamente con l'assemblatore (gcc -S ) rispetto all'output di objdump sull'eseguibile (objdump -D ).

Ci sono due complicazioni principali a cui riesco a pensare. In primo luogo, il codice macchina stesso non è una corrispondenza 1 a 1 con il codice assembly, a causa di cose come gli offset dei puntatori.

Ad esempio, considera il codice C di Hello world:

int main()
{
    printf("Hello, world!\n");
    return 0;
}

Questo viene compilato nel codice assembly x86:

.LC0:
    .string "hello"
    .text
<snip>
    movl    $.LC0, %eax
    movl    %eax, (%esp)
    call    printf

Dove .LCO è una costante denominata e printf è un simbolo in una tabella dei simboli della libreria condivisa. Confronta con l'output di objdump:

80483cd:       b8 b0 84 04 08          mov    $0x80484b0,%eax
80483d2:       89 04 24                mov    %eax,(%esp)
80483d5:       e8 1a ff ff ff          call   80482f4 <[email protected]>

In primo luogo, la costante .LC0 ora è solo un offset casuale in memoria da qualche parte:sarebbe difficile creare un file di origine dell'assembly che contenga questa costante nella posizione corretta, poiché l'assembler e il linker sono liberi di scegliere le posizioni per queste costanti.

In secondo luogo, non ne sono del tutto sicuro (e dipende da cose come il codice indipendente dalla posizione), ma credo che il riferimento a printf non sia effettivamente codificato all'indirizzo del puntatore in quel codice, ma le intestazioni ELF contengono un tabella di ricerca che sostituisce dinamicamente il suo indirizzo in fase di esecuzione. Pertanto, il codice disassemblato non corrisponde esattamente al codice assembly sorgente.

In sintesi, l'assembly di origine ha simboli mentre il codice macchina compilato ha indirizzi che sono difficili da invertire.

La seconda grande complicazione è che un file di origine dell'assembly non può contenere tutte le informazioni che erano presenti nelle intestazioni del file ELF originale, come le librerie a cui collegarsi dinamicamente e altri metadati inseriti lì dal compilatore originale. Sarebbe difficile ricostruirlo.

Come ho detto, è possibile che uno strumento speciale possa manipolare tutte queste informazioni, ma è improbabile che si possa semplicemente produrre codice assembly che possa essere riassemblato nell'eseguibile.

Se sei interessato a modificare solo una piccola sezione dell'eseguibile, ti consiglio un approccio molto più sottile rispetto alla ricompilazione dell'intera applicazione. Usa objdump per ottenere il codice assembly per le funzioni che ti interessano. Convertilo manualmente in "sintassi assembly sorgente" (e qui, vorrei che ci fosse uno strumento che producesse effettivamente il disassemblaggio nella stessa sintassi dell'input) , e modificalo come desideri. Quando hai finito, ricompila solo quelle funzioni e usa objdump per capire il codice macchina per il tuo programma modificato. Quindi, usa un editor esadecimale per incollare manualmente il nuovo codice macchina sopra la parte corrispondente del programma originale, facendo attenzione che il tuo nuovo codice sia esattamente lo stesso numero di byte del vecchio codice (o tutti gli offset sarebbero sbagliati ). Se il nuovo codice è più corto, puoi riempirlo usando le istruzioni NOP. Se è più lungo, potresti essere nei guai e potresti dover creare nuove funzioni e chiamarle invece.


Lo faccio con hexdump e un editor di testo. Devi essere davvero a proprio agio con il codice macchina e il formato del file che lo memorizza e flessibile con ciò che conta come "smontare, modificare e poi rimontare".

Se riesci a farla franca apportando solo "modifiche puntuali" (riscrivendo i byte, ma senza aggiungere né rimuovere byte), sarà facile (relativamente parlando).

Tu davvero non voglio spostare alcuna istruzione esistente, perché in tal caso dovresti regolare manualmente qualsiasi offset relativo effettuato all'interno del codice macchina, per salti/rami/carichi/archivi relativi al contatore del programma, sia in hardcoded immediato valori e quelli calcolati tramite registri .

Dovresti sempre essere in grado di farla franca senza rimuovere i byte. L'aggiunta di byte potrebbe essere necessaria per modifiche più complesse e diventa molto più difficile.

Passaggio 0 (preparazione)

Dopo aver veramente ha disassemblato correttamente il file con objdump -D o qualunque cosa tu usi normalmente prima per capirlo effettivamente e trovare i punti che devi modificare, dovrai prendere nota delle seguenti cose per aiutarti a individuare i byte corretti da modificare:

  1. L'"indirizzo" (offset dall'inizio del file) dei byte che devi modificare.
  2. Il valore grezzo di quei byte come sono attualmente (i --show-raw-insn opzione a objdump è davvero utile qui).

Dovrai anche controllare se hexdump -R funziona sul tuo sistema. In caso contrario, per il resto di questi passaggi, utilizza il xxd comando o simile invece di hexdump in tutti i passaggi seguenti (consulta la documentazione per qualunque strumento tu usi, spiego solo hexdump per ora in questa risposta perché è quella che conosco).

Passaggio 1

Scarica la rappresentazione esadecimale grezza del file binario con hexdump -Cv .

Passaggio 2

Apri il hexdump ed e trova i byte all'indirizzo che desideri modificare.

Breve corso intensivo in hexdump -Cv uscita:

  1. La colonna più a sinistra è l'indirizzo dei byte (rispetto all'inizio del file binario stesso, proprio come objdump fornisce).
  2. La colonna più a destra (circondata da | caratteri) è solo una rappresentazione "leggibile dall'uomo" dei byte - il carattere ASCII corrispondente a ciascun byte è scritto lì, con un . sostituisce tutti i byte che non corrispondono a un carattere stampabile ASCII.
  3. Le cose importanti sono nel mezzo:ogni byte come due cifre esadecimali separate da spazi, 16 byte per riga.

Attenzione:a differenza di objdump -D , che fornisce l'indirizzo di ciascuna istruzione e mostra l'esadecimale grezzo dell'istruzione in base a come è documentata come codificata, hexdump -Cv scarica ogni byte esattamente nell'ordine in cui appare nel file. Questo può creare un po' di confusione come prima cosa su macchine in cui i byte di istruzione sono in ordine opposto a causa delle differenze di endianità, che possono anche essere disorientanti quando ti aspetti un byte specifico come indirizzo specifico.

Passaggio 3

Modifica i byte che devono cambiare:ovviamente devi capire la codifica delle istruzioni della macchina non elaborata (non i mnemonici dell'assembly) e scrivere manualmente i byte corretti.

Nota:non è necessario modificare la rappresentazione leggibile dall'uomo nella colonna più a destra. hexdump lo ignorerà quando lo "annullerai".

Passaggio 4

"Un-dump" del file hexdump modificato utilizzando hexdump -R .

Passaggio 5 (controllo di integrità)

objdump il tuo nuovo unhexdump ed e verifica che il disassemblaggio che hai modificato sia corretto. diff contro il objdump dell'originale.

Seriamente, non saltare questo passaggio. Faccio un errore il più delle volte quando modifico manualmente il codice macchina ed è così che ne rilevo la maggior parte.

Esempio

Ecco un esempio di vita reale da quando ho modificato di recente un binario ARMv8 (little endian). (Lo so, la domanda è taggata x86 , ma non ho un esempio x86 a portata di mano e i principi fondamentali sono gli stessi, solo le istruzioni sono diverse.)

Nella mia situazione avevo bisogno di disabilitare uno specifico controllo di tenuta della mano "non dovresti farlo":nel mio binario di esempio, in objdump --show-raw-insn -d output la riga che mi interessava era simile a questa (un'istruzione prima e dopo fornita per il contesto):

     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Come puoi vedere, il nostro programma sta "utilmente" uscendo saltando in un error funzione (che termina il programma). Inaccettabile. Quindi trasformeremo quell'istruzione in un no-op. Quindi stiamo cercando i byte 0x97fffeeb all'indirizzo/offset file 0xf44 .

Ecco il hexdump -Cv riga contenente quell'offset.

00000f40  e3 03 15 aa eb fe ff 97  f7 13 40 f9 e8 02 40 39  |[email protected]@9|

Nota come i byte rilevanti vengono effettivamente capovolti (la codifica little endian nell'architettura si applica alle istruzioni della macchina come a qualsiasi altra cosa) e come questo si colleghi in modo leggermente non intuitivo a quale byte si trova a quale offset di byte:

00000f40  -- -- -- -- eb fe ff 97  -- -- -- -- -- -- -- --  |[email protected]@9|
                      ^
                      This is offset f44, holding the least significant byte
                      So the *instruction as a whole* is at the expected offset,
                      just the bytes are flipped around. Of course, whether the
                      order matches or not will vary with the architecture.

Ad ogni modo, so guardando altri disassemblaggi che 0xd503201f disassembla in nop quindi sembra un buon candidato per la mia istruzione no-op. Ho modificato la riga nel hexdump ed file di conseguenza:

00000f40  e3 03 15 aa 1f 20 03 d5  f7 13 40 f9 e8 02 40 39  |[email protected]@9|

Riconvertito in binario con hexdump -R , ha disassemblato il nuovo binario con objdump --show-raw-insn -d e verificato che la modifica fosse corretta:

     f40:   aa1503e3    mov x3, x21
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

Quindi ho eseguito il binario e ho ottenuto il comportamento che volevo:il controllo pertinente non ha più causato l'interruzione del programma.

Modifica del codice macchina riuscita.

!!! Attenzione!!!

O ho avuto successo? Hai notato cosa mi sono perso in questo esempio?

Sono sicuro che l'hai fatto - dal momento che stai chiedendo come modificare manualmente il codice macchina di un programma, presumibilmente sai cosa stai facendo. Ma a beneficio di tutti i lettori che potrebbero leggere per imparare, elaborerò:

Ho cambiato solo l'ultimo istruzione nel ramo error-case! Il salto nella funzione che esce dal programma. Ma come puoi vedere, registrati x3 era stato modificato dal mov appena sopra! In effetti, un totale di quattro (4) i registri sono stati modificati come parte del preambolo per chiamare error , e un registro era. Ecco il codice macchina completo per quel ramo, a partire dal salto condizionato sul if block e termina dove va il salto se il condizionale if non è preso:

     f2c:   350000e8    cbnz    w8, f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Tutto il codice dopo il ramo è stato generato dal compilatore presupponendo che lo stato del programma fosse come era prima del salto condizionato ! Ma semplicemente facendo il salto finale al error function code a no-op, ho creato un percorso di codice in cui raggiungiamo quel codice con uno stato del programma incoerente/errato !

Nel mio caso, questo in realtà sembrava non causare alcun problema. Quindi sono stato fortunato. Molto lucky:solo dopo che ho già eseguito il mio binario modificato (che, per inciso, era un binario critico per la sicurezza :aveva la capacità di setuid , setgid e cambia il contesto di SELinux !) mi sono reso conto di aver dimenticato di tracciare effettivamente i percorsi del codice se quelle modifiche al registro hanno influito sui percorsi del codice successivi!

Ciò avrebbe potuto essere catastrofico:uno qualsiasi di quei registri potrebbe essere stato utilizzato nel codice successivo con il presupposto che contenesse un valore precedente che ora è stato sovrascritto! E sono il tipo di persona che la gente conosce per la meticolosa riflessione sul codice e per essere un pedante e pignolo per essere sempre attento alla sicurezza informatica.

E se chiamassi una funzione in cui gli argomenti si riversassero dai registri nello stack (come è molto comune, ad esempio, su x86)? E se ci fossero effettivamente più istruzioni condizionali nel set di istruzioni che hanno preceduto il salto condizionale (come è comune, ad esempio, nelle versioni ARM precedenti)? Sarei stato in uno stato ancora più sconsideratamente incoerente dopo aver fatto quel cambiamento apparentemente semplicissimo!

Quindi questo è il mio promemoria: Giocherellare manualmente con i binari significa letteralmente eliminare ogni sicurezza tra te e ciò che la macchina e il sistema operativo consentiranno. Letteralmente tutto i progressi che abbiamo fatto nei nostri strumenti per rilevare automaticamente gli errori dei nostri programmi, spariti .

Quindi, come possiamo risolvere questo problema in modo più corretto? Continua a leggere.

Rimozione codice

Per efficacemente /logicamente "rimuovi" più di un'istruzione, puoi sostituire la prima istruzione che vuoi "cancellare" con un salto incondizionato alla prima istruzione alla fine delle istruzioni "cancellate". Per questo binario ARMv8, sembrava così:

     f2c:   14000007    b   f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Fondamentalmente, "uccidi" il codice (trasformalo in "codice morto"). Nota a margine:puoi fare qualcosa di simile con stringhe letterali incorporate nel binario:fintanto che vuoi sostituirlo con una stringa più piccola, puoi quasi sempre farla franca sovrascrivendo la stringa (incluso il byte nullo di terminazione se è un "C- string") e, se necessario, sovrascrivendo la dimensione codificata della stringa nel codice macchina che la utilizza.

Puoi anche sostituire tutte le istruzioni indesiderate con no-ops. In altre parole, possiamo trasformare il codice indesiderato in quello che viene chiamato "no-op sled":

     f2c:   d503201f    nop
     f30:   d503201f    nop
     f34:   d503201f    nop
     f38:   d503201f    nop
     f3c:   d503201f    nop
     f40:   d503201f    nop
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

Mi aspetterei che questo sia solo uno spreco di cicli della CPU rispetto al saltarli, ma è più semplice e quindi più sicuro contro gli errori , perché non devi capire manualmente come codificare l'istruzione di salto, incluso capire l'offset/l'indirizzo da usare in essa - non devi pensare così tanto per una slitta no-op.

Per essere chiari, l'errore è facile:ho sbagliato due (2) volte durante la codifica manuale dell'istruzione di salto incondizionato. E non è sempre colpa nostra:la prima volta è stato perché la documentazione che avevo era obsoleta/sbagliata e diceva che un bit era stato ignorato nella codifica, quando in realtà non lo era, quindi l'ho impostato su zero al primo tentativo.

Aggiunta di codice

Potresti utilizzare teoricamente questa tecnica per aggiungere anche le istruzioni della macchina, ma è più complesso e non ho mai dovuto farlo, quindi al momento non ho un esempio funzionante.

Dal punto di vista del codice macchina è abbastanza facile:scegli un'istruzione nel punto in cui desideri aggiungere il codice e convertila in un'istruzione di salto al nuovo codice che devi aggiungere (non dimenticare di aggiungere l'istruzione o le istruzioni così sostituito nel nuovo codice, a meno che tu non ne avessi bisogno per la tua logica aggiunta, e per tornare all'istruzione a cui vuoi tornare alla fine dell'aggiunta). Fondamentalmente, stai "congiungendo" il nuovo codice.

Ma devi trovare un punto in cui inserire effettivamente quel nuovo codice, e questa è la parte difficile.

Se sei davvero fortunato, puoi semplicemente aggiungere il nuovo codice macchina alla fine del file e "funzionerà":il nuovo codice verrà caricato insieme al resto nelle stesse istruzioni macchina previste, nello spazio dello spazio degli indirizzi che cade in una pagina di memoria opportunamente contrassegnata come eseguibile.

Nella mia esperienza hexdump -R ignora non solo la colonna più a destra ma anche quella più a sinistra, quindi potresti letteralmente inserire zero indirizzi per tutte le righe aggiunte manualmente e funzionerà.

Se sei meno fortunato, dopo aver aggiunto il codice dovrai effettivamente regolare alcuni valori di intestazione all'interno dello stesso file:se il caricatore per il tuo sistema operativo si aspetta che il binario contenga metadati che descrivono la dimensione della sezione eseguibile (per motivi storici spesso chiamata la sezione "testo") dovrai trovarla e modificarla. In passato i binari erano solo codice macchina grezzo - oggigiorno il codice macchina è racchiuso in una serie di metadati (ad esempio ELF su Linux e alcuni altri).

Se sei ancora un po' fortunato, potresti avere qualche punto "morto" nel file che viene correttamente caricato come parte del binario agli stessi offset relativi del resto del codice che è già nel file (e che il punto morto può adattarsi al tuo codice ed è correttamente allineato se la tua CPU richiede l'allineamento delle parole per le istruzioni della CPU). Quindi puoi sovrascriverlo.

Se sei davvero sfortunato non puoi semplicemente aggiungere il codice e non c'è spazio morto che puoi riempire con il tuo codice macchina. A quel punto, in pratica devi avere una profonda familiarità con il formato eseguibile e sperare di riuscire a capire qualcosa all'interno di quei vincoli che è umanamente fattibile da eseguire manualmente entro un ragionevole lasso di tempo e con una ragionevole possibilità di non rovinare tutto .


@mgiuca ha affrontato correttamente questa risposta dal punto di vista tecnico. In effetti, disassemblare un programma eseguibile in una sorgente di assemblaggio facile da ricompilare non è un compito facile.

Per aggiungere qualcosa alla discussione, ci sono un paio di tecniche/strumenti che potrebbero essere interessanti da esplorare, anche se sono tecnicamente complessi.

  1. Strumentazione statica/dinamica . Questa tecnica comporta l'analisi del formato eseguibile, l'inserimento/cancellazione/sostituzione di specifiche istruzioni di assemblaggio per un determinato scopo, la correzione di tutti i riferimenti a variabili/funzioni nell'eseguibile e l'emissione di un nuovo eseguibile modificato. Alcuni strumenti che conosco sono:PIN, Hijacker, PEBIL, DynamoRIO. Considera che la configurazione di tali strumenti per uno scopo diverso da quello per cui sono stati progettati potrebbe essere complicata e richiede la comprensione sia dei formati eseguibili che dei set di istruzioni.
  2. Decompilazione eseguibile completa . Questa tecnica tenta di ricostruire un'origine completa dell'assembly da un eseguibile. Potresti dare un'occhiata al disassemblatore online, che cerca di fare il lavoro. Perdi comunque informazioni sui diversi moduli sorgente e possibilmente nomi di funzioni/variabili.
  3. Decompilazione retargeting . Questa tecnica cerca di estrarre più informazioni dall'eseguibile, esaminando le impronte digitali del compilatore (ovvero, modelli di codice generati da compilatori noti) e altre cose deterministiche. L'obiettivo principale è ricostruire il codice sorgente di livello superiore, come il sorgente C, da un eseguibile. Questo a volte è in grado di recuperare informazioni sui nomi di funzioni/variabili. Considera che la compilazione dei sorgenti con -g spesso offre risultati migliori. Potresti provare il Retargetable Decompiler.

La maggior parte di questo proviene dai campi di ricerca della valutazione della vulnerabilità e dell'analisi dell'esecuzione. Sono tecniche complesse e spesso gli strumenti non possono essere utilizzati immediatamente fuori dagli schemi. Tuttavia, forniscono un aiuto inestimabile quando si tenta di decodificare alcuni software.


Linux
  1. Come gestire ed elencare i servizi in Linux

  2. Come installare e testare Ansible su Linux

  3. Come installare e utilizzare Flatpak in Linux

  4. Come compilare e installare software dal codice sorgente su Linux

  5. Come mantenere il codice eseguibile in memoria anche sotto pressione della memoria? su Linux

Come installare e utilizzare lo schermo Linux?

Come rinominare file e directory in Linux

Come comprimere file e directory in Linux

Come rendere eseguibile un file in Linux

Come installare e utilizzare PuTTY su Linux

Come installare e utilizzare phpMyAdmin in Linux