Sicurezza avanzata in Flutter: code obfuscation e certificate pinning

Foto di Maccy su Unsplash

Best practiceAvanzato55 min Flutter 3.x (Dio 5.x)

Sicurezza avanzata in Flutter: code obfuscation e certificate pinning

Quando un'app Flutter esce dalla fase di sviluppo ed entra in produzione, il codice compilato e il traffico di rete diventano superfici d'attacco reali: il binario può essere decompilato per estrarre endpoint, chiavi e logica di business, mentre il traffico HTTPS può essere intercettato da un proxy MITM con una CA installata sul dispositivo (scenario banale su device rooted, emulatori o reti aziendali).

In questo tutorial affrontiamo due contromisure complementari e spesso richieste dagli audit di sicurezza:

  • Code obfuscation dell'AOT snapshot Dart, con gestione corretta dei file di simboli per poter comunque leggere gli stack trace di produzione.
  • Certificate pinning basato su hash SHA-256 della SubjectPublicKeyInfo (SPKI), implementato correttamente con Dio (attenzione: badCertificateCallback non basta) e con strategia di rotazione dei pin.

Prerequisiti: familiarità con build release Android/iOS, Gradle, dio, e nozioni base di TLS/X.509. Il tutorial presuppone Flutter 3.x e Dio 5.x.

Alla fine avrai una pipeline di build hardenizzata, un client HTTP che rifiuta qualunque certificato non pinnato e una procedura di verifica con mitmproxy.

  1. 1

    Definire il modello di minaccia e la checklist di hardening

    Prima di scrivere codice, stabilisci cosa stai proteggendo e da chi. L'obfuscation e il pinning non rendono l'app inviolabile: alzano il costo dell'attacco e bloccano gli attacchi opportunistici.

    Cosa NON risolvono:

    • Un segreto hardcoded resta estraibile: --dart-define finisce comunque nello snapshot. Le chiavi che danno accesso a risorse critiche vanno tenute server-side.
    • Il pinning non protegge da un attaccante che controlla il device e può patchare il binario (per quello servono soluzioni RASP).

    Checklist minima per una release:

    Area Azione
    Binario --obfuscate --split-debug-info, simboli archiviati fuori dal repo
    Android minifyEnabled/shrinkResources, debuggable false, no usesCleartextTraffic
    iOS Bitcode/strip simboli, NSAllowsArbitraryLoads assente
    Rete Solo HTTPS + TLS 1.2/1.3, certificate pinning con pin di backup
    Dati Token in flutter_secure_storage, niente PII nei log
    Log debugPrint no-op in release, nessun dump di header Authorization

    Crea un file SECURITY.md nel repo con le decisioni prese e i responsabili della rotazione dei pin: sarà il documento che l'auditor ti chiederà.

    Un accorgimento immediato è azzerare i log in release, così da non lasciare tracce di endpoint e payload nel logcat.

    // lib/core/bootstrap.dart
    import 'package:flutter/foundation.dart';
    
    void configureLogging() {
      if (kReleaseMode) {
        // Disattiva completamente debugPrint nelle build di produzione.
        debugPrint = (String? message, {int? wrapWidth}) {};
      }
    }
    
    // main.dart
    // void main() {
    //   configureLogging();
    //   runApp(const MyApp());
    // }

    Risultato atteso

    Hai una checklist scritta e, in release, nessun output di debugPrint visibile in logcat o nei log di sistema iOS.

  2. 2

    Compilare con obfuscation e gestire i file di simboli

    Flutter offusca i nomi di classi, funzioni e librerie dell'AOT snapshot Dart tramite il flag --obfuscate, che deve essere accompagnato da --split-debug-info: senza quest'ultimo il comando fallisce, perché i simboli originali vanno estratti in un file separato.

    Punti chiave:

    • I simboli sono per architettura e per build: app.android-arm64.symbols, app.ios-arm64.symbols, ecc.
    • Vanno archiviati come artefatto di CI (o su uno storage privato) insieme al numero di build: senza di essi gli stack trace di Crashlytics/Sentry sono illeggibili.
    • Mai committarli nel repository o distribuirli con l'app.
    • L'obfuscation rompe il codice che si basa sui nomi a runtime: MyType.toString(), runtimeType.toString(), switch su nomi di enum ottenuti via reflection-like. Verifica serializzatori e router che usano Type come chiave.

    Dopo il crash, usa flutter symbolize per risalire allo stack trace originale.

    # Android (App Bundle)
    flutter build appbundle --release \
      --obfuscate \
      --split-debug-info=build/symbols/android/1.4.2+142 \
      --dart-define=ENV=prod
    
    # iOS
    flutter build ipa --release \
      --obfuscate \
      --split-debug-info=build/symbols/ios/1.4.2+142
    
    # Verifica che i simboli siano stati generati
    ls -la build/symbols/android/1.4.2+142
    # app.android-arm64.symbols  app.android-arm.symbols  app.android-x64.symbols
    
    # Decodifica di uno stack trace offuscato salvato in crash.txt
    flutter symbolize \
      -i crash.txt \
      -d build/symbols/android/1.4.2+142/app.android-arm64.symbols

    Risultato atteso

    Le build release contengono simboli Dart offuscati; i file .symbols sono archiviati per versione e `flutter symbolize` restituisce stack trace leggibili.

  3. 3

    Hardening del layer nativo: R8/ProGuard su Android e strip su iOS

    L'obfuscation di Dart non tocca il codice Java/Kotlin dei plugin né il wrapper dell'app. Su Android abilita R8 con shrinking delle risorse e regole di keep mirate; ricorda che l'engine Flutter e i plugin che usano reflection (Firebase, Gson, ecc.) richiedono regole esplicite, altrimenti otterrai ClassNotFoundException solo in release.

    Aggiungi in android/app/build.gradle (o .kts) la configurazione del buildType release e crea android/app/proguard-rules.pro.

    Su iOS, in Xcode verifica nel target Runner:

    • Strip Debug Symbols During Copy = Yes (Release)
    • Deployment Postprocessing = Yes
    • dSYM caricati sul crash reporter, mai nell'IPA distribuito

    Infine, su Android, blocca il traffico in chiaro a livello di piattaforma: nel AndroidManifest.xml di release imposta android:usesCleartextTraffic="false" e punta a una network_security_config dedicata (la riprendiamo nello step 6).

    // android/app/build.gradle
    android {
        buildTypes {
            release {
                signingConfig signingConfigs.release
                minifyEnabled true
                shrinkResources true
                debuggable false
                proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                              'proguard-rules.pro'
            }
        }
    }
    
    // android/app/proguard-rules.pro
    // -keep class io.flutter.app.** { *; }
    // -keep class io.flutter.plugin.**  { *; }
    // -keep class io.flutter.embedding.** { *; }
    // -dontwarn io.flutter.embedding.**
    // # rimuove le chiamate a Log.* dal bytecode
    // -assumenosideeffects class android.util.Log {
    //     public static *** d(...);
    //     public static *** v(...);
    //     public static *** i(...);
    // }

    Risultato atteso

    L'APK/AAB di release è minificato e non debuggabile; l'app si avvia correttamente e i plugin nativi continuano a funzionare (da verificare sempre su device reale, non solo in debug).

  4. 4

    Estrarre i pin SPKI SHA-256 dal certificato del server

    Il pinning corretto non si basa sull'hash dell'intero certificato (che cambia a ogni rinnovo) ma sull'hash della SubjectPublicKeyInfo: se il server rinnova il certificato mantenendo la stessa chiave (o la stessa CSR), il pin resta valido.

    Strategia consigliata:

    1. Pin primario: SPKI del certificato leaf attualmente in uso.
    2. Pin di backup: SPKI di una chiave di riserva già generata, oppure dell'intermediate CA del provider. Senza backup, un rinnovo con chiave nuova manda offline tutte le app installate.
    3. Documenta la data di scadenza e imposta un reminder: i pin vanno ruotati con una release prima della scadenza.

    Estrai i valori con OpenSSL. Lo stesso comando applicato alla catena completa ti dà anche il pin dell'intermediate.

    # Pin SPKI del certificato leaf
    openssl s_client -servername api.example.com -connect api.example.com:443 < /dev/null 2>/dev/null \
      | openssl x509 -pubkey -noout \
      | openssl pkey -pubin -outform der \
      | openssl dgst -sha256 -binary \
      | openssl enc -base64
    # => YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=
    
    # Scarica l'intera catena per estrarre anche l'intermediate (pin di backup)
    openssl s_client -showcerts -servername api.example.com \
      -connect api.example.com:443 < /dev/null 2>/dev/null > chain.pem
    
    # Controlla la scadenza del leaf
    openssl x509 -in chain.pem -noout -enddate

    Risultato atteso

    Hai almeno due stringhe base64 (pin primario e pin di backup) e la data di scadenza del certificato annotata nel SECURITY.md.

  5. 5

    Implementare il pinning SPKI con Dio (senza cadere nella trappola di badCertificateCallback)

    L'errore più diffuso: implementare il pinning dentro HttpClient.badCertificateCallback. Quel callback viene invocato solo quando la validazione standard fallisce. Se l'attaccante ha installato la propria CA nello store di sistema (o l'app usa una CA aziendale), il certificato è "valido", il callback non viene mai chiamato e il pinning non entra in gioco.

    La soluzione con Dio 5 è IOHttpClientAdapter.validateCertificate, invocato su ogni connessione dopo l'handshake. Implementiamo:

    • parsing ASN.1 del DER del certificato per estrarre la SPKI (asn1lib),
    • calcolo SHA-256 + base64 (crypto),
    • confronto con un set di pin per host, con deny-by-default per gli host non configurati.

    Dipendenze: dio, asn1lib, crypto.

    Nota architetturale: il pinning va applicato a tutti i client che escono dall'app. Image.network, cached_network_image e le WebView usano client HTTP diversi dal tuo Dio: se scaricano risorse sensibili vanno pinnati separatamente (per Image.network puoi impostare un HttpOverrides globale).

    // lib/core/network/spki_pinner.dart
    import 'dart:convert';
    import 'dart:io';
    import 'dart:typed_data';
    
    import 'package:asn1lib/asn1lib.dart';
    import 'package:crypto/crypto.dart';
    import 'package:dio/dio.dart';
    import 'package:dio/io.dart';
    
    class SpkiPinner {
      const SpkiPinner(this.pinsByHost);
    
      /// host -> insieme di digest SHA-256(SPKI) in base64.
      final Map<String, Set<String>> pinsByHost;
    
      bool validate(X509Certificate? cert, String host, int port) {
        if (cert == null) return false;
        final pins = pinsByHost[host];
        if (pins == null || pins.isEmpty) return false; // deny-by-default
        final fingerprint = spkiSha256Base64(cert.der);
        return fingerprint != null && pins.contains(fingerprint);
      }
    
      /// Estrae la SubjectPublicKeyInfo dal DER X.509 e ne calcola SHA-256/base64.
      static String? spkiSha256Base64(Uint8List der) {
        try {
          final certificate = ASN1Parser(der).nextObject() as ASN1Sequence;
          final tbs = certificate.elements.first as ASN1Sequence;
          // Il campo version [0] EXPLICIT e' opzionale: sposta gli indici.
          final hasVersion = tbs.elements.first.tag == 0xA0;
          final spki = tbs.elements[hasVersion ? 6 : 5];
          return base64.encode(sha256.convert(spki.encodedBytes).bytes);
        } catch (_) {
          return null; // parsing fallito => certificato rifiutato
        }
      }
    }
    
    // lib/core/network/dio_factory.dart
    Dio buildPinnedDio({required bool enablePinning}) {
      const pinner = SpkiPinner({
        'api.example.com': {
          'YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=', // primario (leaf)
          'Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys=', // backup (intermediate)
        },
      });
    
      final dio = Dio(BaseOptions(
        baseUrl: 'https://api.example.com',
        connectTimeout: const Duration(seconds: 10),
      ));
    
      dio.httpClientAdapter = IOHttpClientAdapter(
        createHttpClient: () {
          final client = HttpClient(context: SecurityContext(withTrustedRoots: true));
          // Nessuna deroga: i certificati non validi restano rifiutati.
          client.badCertificateCallback = (cert, host, port) => false;
          return client;
        },
        // Chiamato SEMPRE, anche quando la catena e' considerata valida.
        validateCertificate: enablePinning ? pinner.validate : null,
      );
    
      return dio;
    }

    Risultato atteso

    Le chiamate verso api.example.com funzionano normalmente; se il certificato presentato non corrisponde a nessun pin, Dio lancia una DioException di tipo connectionError/badCertificate e la richiesta non parte.

  6. 6

    Rotazione dei pin, kill-switch e pinning a livello di piattaforma

    Un pinning mal gestito è un incidente di produzione: alla scadenza del certificato tutte le installazioni smettono di funzionare e l'unica via d'uscita è pubblicare una release sugli store (con tempi di review e adozione non controllabili).

    Strategie da adottare insieme:

    1. Sempre almeno due pin (chiave corrente + chiave di backup già emessa).
    2. Configurazione per ambiente: pinning attivo solo in prod; in dev/staging disattivato per poter ispezionare il traffico con Charles/mitmproxy.
    3. Scadenza dei pin lato app: se la build è più vecchia di N mesi, mostra un blocco "aggiorna l'app" invece di fallire silenziosamente.
    4. Pinning nativo su Android tramite network_security_config.xml: copre anche le richieste dei plugin nativi e supporta expiration, dopo la quale Android ignora i pin (fail-open controllato) evitando il brick dell'app.

    Sotto trovi la configurazione Android e il wiring per ambiente lato Dart.

    <!-- android/app/src/main/res/xml/network_security_config.xml -->
    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <base-config cleartextTrafficPermitted="false">
            <trust-anchors>
                <certificates src="system" />
            </trust-anchors>
        </base-config>
    
        <domain-config cleartextTrafficPermitted="false">
            <domain includeSubdomains="true">api.example.com</domain>
            <!-- Dopo expiration i pin vengono ignorati: evita il brick delle vecchie build -->
            <pin-set expiration="2026-06-30">
                <pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
                <pin digest="SHA-256">Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys=</pin>
            </pin-set>
        </domain-config>
    </network-security-config>
    
    <!-- AndroidManifest.xml (application) -->
    <!-- android:networkSecurityConfig="@xml/network_security_config" -->
    <!-- android:usesCleartextTraffic="false" -->

    Risultato atteso

    Su Android il pinning è attivo anche per il traffico dei plugin nativi, con una data di scadenza esplicita; in dev/staging il traffico resta ispezionabile tramite proxy.

  7. 7

    Verificare che tutto funzioni: test automatici e attacco MITM simulato

    L'ultimo passo, quello che quasi tutti saltano: dimostrare che il pinning blocca davvero un MITM e che l'obfuscation è attiva.

    A) Test unitario sul calcolo del pin. Salva un certificato reale come fixture PEM/DER in test/fixtures/ e verifica che SpkiSha256Base64 produca esattamente il valore restituito da OpenSSL: se una futura versione di asn1lib cambia comportamento, il test lo intercetta.

    B) Test d'attacco con mitmproxy.

    1. Avvia mitmproxy in modalità regular sulla tua macchina.
    2. Configura il proxy sul device/emulatore e installa la CA di mitmproxy nello store di sistema (su Android 7+ serve un emulatore con root o una network_security_config di debug).
    3. Esegui la build di produzione dell'app: tutte le chiamate verso l'host pinnato devono fallire, mentre su una build dev (pinning disattivato) devono passare e comparire in mitmproxy.

    Se con la CA installata vedi il traffico in chiaro nella build di produzione, il pinning non è attivo (tipico sintomo dell'implementazione con solo badCertificateCallback).

    C) Verifica dell'obfuscation. Estrai le stringhe dallo snapshot AOT e controlla che i nomi delle tue classi Dart non compaiano.

    // test/spki_pinner_test.dart
    import 'dart:io';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:myapp/core/network/spki_pinner.dart';
    
    void main() {
      test('calcola il pin SPKI atteso dal certificato di produzione', () {
        final der = File('test/fixtures/api_example_com.der').readAsBytesSync();
        expect(
          SpkiPinner.spkiSha256Base64(der),
          'YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=',
        );
      });
    
      test('rifiuta host non configurati (deny-by-default)', () {
        const pinner = SpkiPinner({'api.example.com': {'AAAA'}});
        expect(pinner.validate(null, 'evil.example.com', 443), isFalse);
      });
    }
    
    // --- Verifiche da terminale ---
    // mitmproxy --mode regular --listen-port 8080
    //
    // Obfuscation: i nomi delle classi Dart non devono comparire
    // unzip -o app-release.apk -d out/
    // strings out/lib/arm64-v8a/libapp.so | grep -i "AuthRepositoryImpl" | head
    // (output atteso: vuoto)

    Risultato atteso

    I test unitari passano, la build di produzione non riesce a comunicare attraverso il proxy MITM e i nomi delle classi Dart non sono più presenti in libapp.so.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!