Perché la sicurezza di un'app Flutter va progettata, non improvvisata

Un binario Flutter compilato in AOT non è "leggibile" come un bundle JavaScript, ma non è nemmeno una cassaforte. Con strumenti open source è possibile estrarre stringhe, individuare gli endpoint delle API, leggere chiavi hardcoded e ispezionare il traffico di rete con un proxy come mitmproxy o Charles.

In questo articolo vediamo le contromisure concrete che ogni app Flutter destinata alla produzione dovrebbe adottare: offuscamento del codice, certificate pinning, gestione dei segreti tramite build-time configuration, hardening della piattaforma nativa e igiene del logging.

Una premessa che vale per tutto ciò che segue: niente di ciò che gira sul dispositivo dell'utente è realmente segreto. L'obiettivo non è rendere l'attacco impossibile, ma renderlo abbastanza costoso da scoraggiarlo, spostando i controlli critici sul backend.

1. Offuscamento del codice Dart

Di default, il codice AOT conserva i nomi di classi, metodi e funzioni. Estrarre i simboli da un APK e ricostruire la logica applicativa è quindi relativamente semplice. Flutter offre l'offuscamento nativo:

# Android
flutter build appbundle --release \
  --obfuscate \
  --split-debug-info=build/symbols/android

# iOS
flutter build ipa --release \
  --obfuscate \
  --split-debug-info=build/symbols/ios

--obfuscate rinomina i simboli, mentre --split-debug-info estrae la mappa dei simboli in file esterni. I due flag vanno usati sempre insieme: senza la mappa, gli stack trace dei crash diventano illeggibili.

Conservare e usare i simboli

I file .symbols prodotti nella cartella indicata sono l'equivalente delle dSYM di iOS: vanno archiviati come artefatto della pipeline CI, versione per versione, e mai committati nel repository dell'app.

Per rendere leggibile uno stack trace offuscato:

flutter symbolize \
  -i crash_stacktrace.txt \
  -d build/symbols/android/app.android-arm64.symbols

Se usi Sentry o Firebase Crashlytics, esistono plugin che caricano automaticamente i simboli durante la build, ottenendo la deoffuscazione trasparente nella dashboard.

Attenzione a reflection e serializzazione

L'offuscamento può rompere codice che si basa sui nomi dei tipi a runtime, ad esempio runtimeType.toString() usato come chiave. Se hai logiche di questo tipo, sostituiscile con enum o costanti esplicite:

// ❌ Fragile con --obfuscate
final key = model.runtimeType.toString();

// ✅ Stabile
abstract interface class Identifiable {
  String get typeKey;
}

class UserModel implements Identifiable {
  @override
  String get typeKey => 'user';
}

2. Gestione dei segreti: --dart-define-from-file

Un errore ricorrente è inserire chiavi e URL direttamente nel codice o, peggio, in un file .dart committato. La soluzione idiomatica è passare la configurazione a build time.

Crea un file env/prod.json (aggiunto a .gitignore):

{
  "API_BASE_URL": "https://api.example.com",
  "SENTRY_DSN": "https://xxx@o0.ingest.sentry.io/0",
  "ENABLE_LOGS": false
}

E lancia la build così:

flutter build appbundle --release \
  --dart-define-from-file=env/prod.json \
  --obfuscate --split-debug-info=build/symbols/android

Nel codice, i valori si leggono con String.fromEnvironment in contesto const:

abstract final class Env {
  static const apiBaseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'https://api.staging.example.com',
  );

  static const sentryDsn = String.fromEnvironment('SENTRY_DSN');

  static const enableLogs = bool.fromEnvironment('ENABLE_LOGS');

  static void assertValid() {
    assert(apiBaseUrl.isNotEmpty, 'API_BASE_URL non configurato');
  }
}

Importante: --dart-define non cripta nulla. I valori finiscono comunque nel binario e sono estraibili. Serve a evitare che i segreti finiscano nel version control e a differenziare gli ambienti, non a proteggerli. Chiavi realmente sensibili (secret di pagamento, credenziali di servizi terzi con costi a consumo) devono stare solo sul backend, esposte tramite un endpoint autenticato.

Per i token utente (access token, refresh token) usa invece lo storage cifrato del sistema operativo, non SharedPreferences.

3. Certificate pinning con Dio

Il pinning impedisce a un proxy con certificato installato dall'utente (o a una CA compromessa) di intercettare il traffico. Con Dio 5.x e l'adapter IO si può implementare in modo compatto validando l'impronta SHA-256 del certificato.

import 'dart:convert';
import 'dart:io';

import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';

/// Impronte SHA-256 dei certificati accettati.
/// Includi sempre almeno un pin di backup per la rotazione.
const _pinnedFingerprints = <String>{
  'YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=',
  'Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys=',
};

Dio buildPinnedDio() {
  final dio = Dio(BaseOptions(baseUrl: Env.apiBaseUrl));

  dio.httpClientAdapter = IOHttpClientAdapter(
    createHttpClient: () {
      final client = HttpClient();
      // Nessuna eccezione sui certificati non validi.
      client.badCertificateCallback = (cert, host, port) => false;
      return client;
    },
    validateCertificate: (cert, host, port) {
      if (cert == null) return false;
      final fingerprint = base64.encode(sha256.convert(cert.der).bytes);
      return _pinnedFingerprints.contains(fingerprint);
    },
  );

  return dio;
}

Per ricavare l'impronta del certificato del tuo dominio:

openssl s_client -connect api.example.com:443 -servername api.example.com \
  < /dev/null 2>/dev/null \
  | openssl x509 -outform DER \
  | openssl dgst -sha256 -binary \
  | base64

Regole d'oro del pinning

  • Sempre almeno due pin: quello attivo e quello del prossimo certificato. Senza pin di backup, alla scadenza del certificato tutte le app installate smettono di funzionare e servirà un aggiornamento dagli store.
  • Meglio pinnare la CA intermedia che il certificato foglia, se il tuo provider ruota spesso (es. Let's Encrypt ogni 90 giorni).
  • Prevedi un kill switch remoto: un flag su Remote Config o un endpoint di configurazione che permetta di disabilitare il pinning in caso di emergenza.
  • Il pinning non si applica al traffico gestito da WebView o da SDK di terze parti: valutalo caso per caso.

4. Hardening lato piattaforma

Android

Blocca il traffico in chiaro e limita le CA attendibili con un network_security_config.xml in android/app/src/main/res/xml/:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <base-config cleartextTrafficPermitted="false">
    <trust-anchors>
      <certificates src="system" />
    </trust-anchors>
  </base-config>
</network-security-config>

Omettere <certificates src="user" /> significa ignorare i certificati installati manualmente dall'utente: già da solo blocca la maggior parte dei proxy di intercettazione. Referenzialo nel manifest:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    android:allowBackup="false"
    ... >

android:allowBackup="false" evita che i dati dell'app finiscano nei backup ADB o cloud, un vettore classico per l'estrazione di token.

Abilita inoltre R8 per la parte nativa nel build.gradle del modulo app:

buildTypes {
    release {
        minifyEnabled true
        shrinkResources true
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                      'proguard-rules.pro'
    }
}

iOS

Verifica che App Transport Security non sia stato allentato durante lo sviluppo: nel file Info.plist non devono comparire NSAllowsArbitraryLoads a true né eccezioni per domini di produzione.

5. Proteggere lo schermo e il multitasking

Per app bancarie, sanitarie o che mostrano documenti, è buona pratica impedire screenshot e nascondere il contenuto nell'app switcher. Pacchetti come screen_protector o no_screenshot incapsulano FLAG_SECURE su Android e l'oscuramento della finestra su iOS:

import 'package:screen_protector/screen_protector.dart';

class SecureScreen extends StatefulWidget {
  const SecureScreen({super.key, required this.child});
  final Widget child;

  @override
  State<SecureScreen> createState() => _SecureScreenState();
}

class _SecureScreenState extends State<SecureScreen> {
  @override
  void initState() {
    super.initState();
    ScreenProtector.preventScreenshotOn();
    ScreenProtector.protectDataLeakageWithBlur();
  }

  @override
  void dispose() {
    ScreenProtector.preventScreenshotOff();
    ScreenProtector.protectDataLeakageWithBlurOff();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

Applicalo solo alle schermate sensibili: disabilitare gli screenshot ovunque è una scelta che penalizza l'esperienza utente.

6. Igiene del logging

I log sono una delle fonti di leak più sottovalutate: su Android sono leggibili via adb logcat anche in release. Centralizza il logging e disattivalo in produzione:

import 'package:flutter/foundation.dart';

abstract final class AppLogger {
  static void debug(String message) {
    if (kReleaseMode) return;
    debugPrint('[DEBUG] $message');
  }

  /// Da usare solo per messaggi già sanificati.
  static void info(String message) => debugPrint('[INFO] $message');
}

E se usi gli interceptor di Dio per il logging, ricordati di filtrare gli header sensibili:

if (!kReleaseMode) {
  dio.interceptors.add(
    LogInterceptor(
      requestHeader: false, // evita di stampare Authorization
      responseBody: true,
    ),
  );
}

Lo stesso vale per i breadcrumb inviati ai servizi di crash reporting: configura sempre una funzione di scrubbing che rimuova token, email e numeri di carta prima dell'invio.

7. Rilevare ambienti compromessi

Su dispositivi con root o jailbreak le protezioni del sistema operativo (keystore, sandbox) sono aggirabili. Pacchetti come flutter_jailbreak_detection permettono di reagire:

Future<bool> isDeviceTrusted() async {
  if (kDebugMode) return true;
  final jailbroken = await FlutterJailbreakDetection.jailbroken;
  final developerMode = await FlutterJailbreakDetection.developerMode;
  return !jailbroken && !developerMode;
}

È un controllo aggirabile, quindi non va usato come unica barriera: ha senso come segnale aggiuntivo da inviare al backend, che può applicare policy più restrittive (es. limiti di importo, richiesta di step-up authentication).

Per un livello superiore esistono le API di attestation (Play Integrity su Android, App Attest su iOS), che permettono al server di verificare crittograficamente che la richiesta provenga da un'app genuina e non modificata.

Checklist finale prima della release

  • [ ] Build con --obfuscate e --split-debug-info, simboli archiviati in CI
  • [ ] Nessuna chiave o URL hardcoded: tutto via --dart-define-from-file
  • [ ] Token e credenziali salvati in storage cifrato, mai in SharedPreferences
  • [ ] Certificate pinning attivo con pin di backup e kill switch
  • [ ] cleartextTrafficPermitted="false" e nessuna eccezione ATS
  • [ ] allowBackup="false" su Android
  • [ ] Logging disattivato o sanificato in release
  • [ ] File .env, keystore e *.symbols esclusi dal repository
  • [ ] Dipendenze aggiornate e verificate (flutter pub outdated)

Conclusione

La sicurezza di un'app Flutter è il risultato di più livelli che si rinforzano a vicenda: l'offuscamento alza il costo del reverse engineering, il pinning protegge il canale, la gestione corretta dei segreti riduce la superficie d'attacco e l'hardening nativo chiude le porte lasciate aperte dai default di sviluppo.

Nessuna di queste misure è sufficiente da sola, e nessuna sostituisce la regola fondamentale: l'autorizzazione si valida sul server. Il client va trattato come un ambiente potenzialmente ostile, e progettato di conseguenza.