Reverse engineering / software protection

Offuscare codice non significa renderlo inviolabile.

Significa aumentare il costo dell’analisi. A volte basta far perdere tempo. A volte serve proteggere una parte precisa del flusso. In ogni caso, va progettato con un modello di minaccia reale, non come decorazione.

Il punto di partenza

Un obfuscator prende un programma e ne produce uno equivalente dal punto di vista osservabile, ma più difficile da capire, modificare o ricostruire. Questo non crea sicurezza assoluta: se un segreto deve comparire in memoria, un attaccante abbastanza motivato può arrivarci.

Il valore pratico sta nel compromesso: rendere costosa l’analisi statica, disturbare quella dinamica e ridurre la probabilità che un attaccante scelga proprio quel binario come bersaglio facile.

Una buona offuscazione non si misura da quanto “strano” sembra il codice, ma da quanto tempo richiede estrarre ciò che volevi proteggere.

Prima domanda: contro chi?

Solo file binario

L’attaccante analizza il file con strumenti statici. Qui contano nomi, stringhe, grafo di controllo e pattern riconoscibili.

Runtime libero

L’attaccante può eseguire, debuggare, tracciare, usare hook o emulatori. L’offuscamento diventa solo uno strato.

Tempo limitato

È lo scenario più comune: non devi fermare tutti, devi rendere poco conveniente l’analisi rispetto al valore ottenuto.

Tecniche che hanno senso

1. Stringhe e dati immediatamente riconoscibili

URL, nomi di endpoint, messaggi interni e token non dovrebbero stare in chiaro nel binario. Codificarli e decodificarli solo al bisogno è una protezione semplice, ma utile contro analisi superficiali.

fn decrypt_xor(data: &[u8], key: u8) -> String {
    let bytes: Vec<u8> = data.iter().map(|b| b ^ key).collect();
    String::from_utf8_lossy(&bytes).into_owned()
}

fn main() {
    let encoded = [0x2a, 0x27, 0x30, 0x30];
    let value = decrypt_xor(&encoded, 0x42);
    println!("{}", value);
}

Da sola non basta: una chiave statica si recupera. Meglio derivare chiavi a runtime, spezzare dati sensibili e ridurre il tempo in cui restano in chiaro.

2. Control-flow flattening

Il flattening rende meno leggibile il grafo di controllo, trasformando una logica lineare in un dispatcher. È utile contro decompilatori e lettura rapida, ma può peggiorare performance e manutenzione.

3. Virtualizzazione

La virtualizzazione traduce porzioni di programma in bytecode custom, eseguito da un interprete interno. È più pesante, ma spesso più efficace sulle funzioni davvero sensibili.

fn exec(code: &[u8]) -> i64 {
    let mut pc = 0usize;
    let mut stack = Vec::new();

    while pc < code.len() {
        match code[pc] {
            0x01 => { pc += 1; stack.push(code[pc] as i64); }
            0x02 => {
                let b = stack.pop().unwrap_or(0);
                let a = stack.pop().unwrap_or(0);
                stack.push(a + b);
            }
            0xff => break,
            _ => break,
        }
        pc += 1;
    }

    stack.pop().unwrap_or(0)
}

4. Controlli anti-debug e anti-tamper

Controlli su debugger, timing, checksum delle sezioni eseguibili o integrità dei file possono rallentare l’analisi dinamica. Non vanno trattati come muri, ma come attrito.

fn being_traced() -> bool {
    let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
        return false;
    };

    status
        .lines()
        .find(|line| line.starts_with("TracerPid:"))
        .and_then(|line| line.split_whitespace().last())
        .is_some_and(|pid| pid != "0")
}

Come capire se serve davvero

La baseline non offuscata deve restare disponibile per audit, test e manutenzione. L’offuscamento va applicato alla release, non al modo in cui il team ragiona sul codice.

Regole pratiche

In sintesi

L’offuscamento è utile quando è mirato, misurabile e inserito in una strategia più ampia. Non sostituisce sicurezza, design corretto o gestione seria dei segreti. Aggiunge costo. E in molti casi è esattamente quello che serve.