Seguridad en Flutter: ingeniería inversa y análisis
Artículo sobre técnicas de análisis estático y dinámico para la ingeniería inversa de aplicaciones Flutter. Repasa el parcheo en tiempo de ejecución, la interceptación de la ABI personalizada y la interceptación del tráfico.
Introducción
Flutter, desarrollado por Google, es un framework multiplataforma muy utilizado para el desarrollo de aplicaciones móviles que también admite aplicaciones web y de escritorio. Ha experimentado un crecimiento notable, con un aumento del 340% y del 270% en las tiendas de aplicaciones de Android e iOS, respectivamente.
El framework es reconocido por su alto rendimiento, atribuido al motor de renderizado Skia. También ofrece un sistema de interfaz de usuario flexible para diseños de interfaz complejos.
Flutter puede integrarse con aplicaciones existentes y permite diseñar aplicaciones desde cero.
La plataforma cuenta con una comunidad sólida y activa que contribuye a su rápido crecimiento. Debido a su novedad, Flutter está actualmente poco estudiado en términos de seguridad, con un trabajo limitado sobre el análisis de seguridad de aplicaciones móviles.
Aunque Flutter es popular, no está exento de riesgos de seguridad, lo que pone de relieve la necesidad de mejores herramientas y de un mayor conocimiento del framework. Esto incluye aprender a desmontar y estudiar aplicaciones Flutter para encontrar y comprender posibles problemas de seguridad.
Herramientas y técnicas
Para analizar una aplicación Flutter hay disponibles dos categorías de herramientas: las herramientas de análisis estático y las de análisis dinámico.
Las herramientas de análisis estático, como Doldrums, analizan el archivo reimplementando su formato. Doldrums solo admite parcialmente las versiones antiguas de Flutter (2.10 y 2.12; la última versión en el momento de redactar este artículo es la 3.10) y ya no se mantiene debido a los desafíos que plantea la evolución rápida y continua del runtime.
Las herramientas de análisis dinámico, por su parte, como reFlutter, parchean el runtime de Flutter para recopilar información durante la ejecución. Estas herramientas suelen requerir utilidades complementarias como Frida o un depurador como LLDB para instrumentar las funciones identificadas de forma dinámica.
A pesar de su potente enfoque para analizar aplicaciones Flutter, reFlutter ya no admite las últimas versiones del runtime. Los cambios en el runtime de Flutter han dado lugar a parches que no funcionan.
Estructura de una aplicación Flutter
Las aplicaciones Flutter, en todas las plataformas, constan de una aplicación contenedora, el runtime de Flutter y la aplicación Flutter.
A continuación se muestra un ejemplo de la estructura de una aplicación para Linux. libapp.so es el código de la aplicación, counter es el contenedor
y libflutter_linux_gtk.so es el runtime.
├── counter
├── data
│ ├── flutter_assets
│ │ ├── AssetManifest.json
│ │ ├── FontManifest.json
│ │ ├── fonts
│ │ │ └── MaterialIcons-Regular.otf
│ │ ├── NOTICES.Z
│ │ ├── packages
│ │ │ └── cupertino_icons
│ │ │ └── assets
│ │ │ └── CupertinoIcons.ttf
│ │ ├── shaders
│ │ │ └── ink_sparkle.frag
│ │ └── version.json
│ └── icudtl.dat
└── lib
├── libapp.so
└── libflutter_linux_gtk.so
La aplicación contenedora proporciona un punto de entrada que se coordina con el sistema operativo subyacente para acceder a servicios como las superficies de renderizado, la accesibilidad y la entrada, y gestiona el bucle de eventos de mensajes.
El runtime de Flutter se encarga de cargar la aplicación Flutter y ofrece un conjunto de funcionalidades a la aplicación. Incluye la máquina virtual (VM) de Dart y el motor de Flutter, este último escrito principalmente en C++ y que ofrece las primitivas necesarias para todas las aplicaciones Flutter.
A continuación se muestra una lista de funciones expuestas por el runtime de Flutter:
000000000041a860 g DF .text 00000000000001e8 Base fl_binary_messenger_send_response
000000000041f740 g DF .text 0000000000000043 Base fl_json_message_codec_new
000000000042a1f0 g DF .text 000000000000015c Base fl_method_channel_invoke_method
000000000042a390 g DF .text 000000000000012b Base fl_method_channel_invoke_method_finish
000000000042b410 g DF .text 000000000000007c Base fl_method_success_response_get_result
0000000000437a00 g DF .text 0000000000000050 Base fl_view_new
000000000041b890 g DF .text 000000000000007c Base fl_dart_project_get_icu_data_path
0000000000e8f8d8 g D .bss 0000000000000000 Base _end
000000000041b990 g DF .text 00000000000000a6 Base fl_dart_project_set_dart_entrypoint_arguments
00000000004364c0 g DF .text 000000000000003d Base fl_value_get_int
00000000004365f0 g DF .text 000000000000003d Base fl_value_get_int32_list
00000000004133d0 w DF .text 0000000000000005 Base operator delete(void*, std::align_val_t)
00000000004192c0 g DF .text 00000000000001c1 Base fl_basic_message_channel_new
00000000004198d0 g DF .text 000000000000015d Base fl_basic_message_channel_send
0000000000419a70 g DF .text 000000000000012b Base fl_basic_message_channel_send_finish
000000000042d980 g DF .text 0000000000000080 Base fl_plugin_registry_get_type
000000000041be60 g DF .text 0000000000000045 Base fl_engine_new_headless
0000000000436480 g DF .text 000000000000003f Base fl_value_get_bool
00000000004297b0 g DF .text 000000000000007c Base fl_method_call_get_args
000000000042ae40 g DF .text 000000000000003b Base fl_method_success_response_get_type
0000000000434f70 g DF .text 0000000000000160 Base fl_texture_registrar_mark_texture_frame_available
000000000041f790 g DF .text 0000000000000176 Base fl_json_message_codec_encode
000000000042d320 g DF .text 0000000000000154 Base fl_plugin_registrar_get_messenger
00000000004368c0 g DF .text 0000000000000073 Base fl_value_set
00000000004293f0 g DF .text 00000000000000be Base fl_message_codec_decode_message
El motor se encarga de tareas como la rasterización de escenas compuestas, la implementación de bajo nivel de la API principal de Flutter, la gestión de la E/S de archivos y de red, el soporte de accesibilidad, la arquitectura de plugins, y un runtime de Dart y su cadena de herramientas de compilación. El runtime es fijo y no incluye ningún código específico de la aplicación.
La aplicación Flutter es una biblioteca independiente, pero no se carga como una biblioteca estándar. En su lugar, sigue un formato personalizado que contiene la estructura de la aplicación, el código y los objetos.
readelf -Ws libapp.so
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000001ec000 19904 OBJECT GLOBAL DEFAULT 7 _kDartVmSnapshotInstructions
2: 00000000001f0dc0 0x2e0420 OBJECT GLOBAL DEFAULT 7 _kDartIsolateSnapshotInstructions
3: 0000000000000200 33728 OBJECT GLOBAL DEFAULT 2 _kDartVmSnapshotData
4: 00000000000085c0 0x1dfff0 OBJECT GLOBAL DEFAULT 2 _kDartIsolateSnapshotData
5: 00000000000001c8 32 OBJECT GLOBAL DEFAULT 1 _kDartSnapshotBuildId
El archivo de la aplicación contiene dos snapshots: uno para el isolate de la VM y otro para el isolate con la sustancia real. Cada uno de estos isolates se divide en una sección de datos (que contiene el heap del isolate) y una sección de instrucciones (que contiene el código compilado de forma nativa).
Flutter usa el modelo de isolates de Dart, en el que cada fragmento de código Dart se ejecuta dentro de un isolate, una estructura compuesta por un bloque de memoria llamado heap. En Flutter no se aprovechan múltiples isolates: solo se usa un isolate aparte del siempre presente isolate de la VM.
El isolate de la VM es especial: gestiona únicamente objetos inmutables y es accesible para otros isolates.
Un snapshot de Dart, en el contexto de una aplicación Flutter, representa el estado serializado de la VM de Dart en un punto concreto de su ejecución, incluido todo el código compilado de forma nativa. En Flutter, el snapshot del isolate corresponde al estado de la VM de Dart justo antes de que se llame a main.
Configuración y parcheo en tiempo de ejecución
Para analizar una aplicación Flutter, nuestro objetivo es apoyarnos en un enfoque robusto y mantenible. En lugar de reimplementar el formato de archivo, modificaremos el SDK de Dart para extraer la información necesaria de la aplicación Flutter en tiempo de ejecución.
Cuando se compila una aplicación Flutter, se generan dos archivos en el directorio de la aplicación:

libFlutter.so: Este archivo es una biblioteca compartida que contiene el motor de Flutter, responsable de renderizar la interfaz de usuario, gestionar los eventos de entrada y administrar el ciclo de vida de la aplicación.libapp.so: Este archivo contiene el código y la lógica reales de la aplicación que conforman la aplicación Flutter.
Ambos archivos incluyen un snapshot_hash para distinguir las versiones de compilación. El snapshot_hash sirve como identificador único
de las versiones de compilación. Si el snapshot_hash difiere entre los archivos libFlutter.so y libapp.so, se produce un error de
incompatibilidad de versiones que impide que la aplicación se inicie.
Parchear y compilar una versión concreta de Flutter puede hacerse siguiendo estos pasos:
Creación de un parche para una versión específica de Flutter.
Para crear un parche para una versión de Flutter, debemos determinar la versión concreta del SDK de Dart que se utiliza en esa versión. Puede consultar todas las versiones oficiales de Flutter en el siguiente enlace: https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json
Por ejemplo, Flutter v3.10.4 usa el SDK de Dart v3.0.3:

El primer paso es crear un archivo de parche para esa versión concreta del SDK de Dart:
git clone https://github.com/dart-lang/sdk
cd sdk
git checkout 2.13.4
Ahora editamos el código fuente del SDK de Dart para volcar información de la aplicación Flutter durante la ejecución; en la siguiente sección explicaremos esos cambios en detalle. Una vez realizados todos los cambios, cree un archivo de parche con ellos ejecutando:
git diff > patch_2_13_4.patch
Y conserve el archivo de parche para su uso posterior.
2 - Compilar libFlutter.so con nuestro SDK de Dart parcheado.
Clone el repositorio que contiene las herramientas necesarias, como el sistema de compilación Ninja y la herramienta de gestión de
dependencias gclient, y añádalo a su PATH.
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
cd depot_tools
export PATH=$PATH:$(pwd)/depot_tools
Para asegurarnos de que tenemos la versión correcta del motor de Flutter para una versión concreta de Flutter,
localizamos el archivo /bin/internal/engine.version dentro de la base de código de Flutter, en el mismo hash de commit que la versión.
Abra el archivo e identifique el hash de commit que aparece en su interior. Este hash de commit representa la versión concreta del
motor de Flutter utilizada por esa versión de Flutter.
Por ejemplo, consideremos la versión v3.10.4 de Flutter, cuyo hash de commit asociado es 682aa387cfe4fbd71ccd5418b2c2a075729a1c66.
Visite la siguiente URL Engine.version para obtener el motor que se usa con la versión 3.10.4 de Flutter, que es 2a3401c9bbb5a9a9aec74d4f735d18a9dd3ebf2d.

Clone el repositorio del motor de Flutter y haga checkout del mismo hash de commit utilizado en la versión de Flutter deseada.
git clone https://github.com/flutter/engine.git
cd engine
git fetch origin 2a3401c9bbb5a9a9aec74d4f735d18a9dd3ebf2d
git reset --hard
Prepare un directorio para usar gclient y cree un archivo de configuración .gclient.
mkdir costum_engine
cd customEngine
echo 'solutions = [{"managed": False,"name": "src/flutter","url": "PATH_TO_ENGINE'","custom_deps": {},"deps_file": "DEPS","safesync_url": "",},]' > .gclient
Ejecute gclient y espere a que termine:
cd customEngine
gclient sync
gclient clonará todas las dependencias del archivo DEPS dentro del motor de Flutter, incluido el SDK de Dart del repositorio
del SDK de Dart, en nuestra carpeta costum_engine/src/third_party/dart/.
Cualquier cambio en el código fuente del SDK de Dart hará que la compilación produzca un snapshot_hash diferente, lo que puede dar lugar a un
libflutter.so defectuoso que no se pueda usar con ningún libapp.so.
El snapshot_hash se genera en tiempo de compilación mediante el método MakeSnapshotHashString() del
script make_version.py.
Antes de hacer cambios en el código fuente, debemos asegurarnos de que make_version.py siempre genere el
snapshot_hash correcto, aunque cambiemos el código fuente.
Genere el snapshot_hash original con el script make_version.py.
cd costum_engine/src/third_party/dart/tool
ipython
In [1]: import make_version
In [2]: make_version.MakeSnapshotHashString()
Out[2]: 'e4a09dbf2bb120fe4674e0576617a0dc'
Abra el script make_version.py en un editor de texto y modifíquelo para que devuelva el snapshot_hash original.
code make_version.py

Ahora podemos cambiar el código fuente sin preocuparnos por la discrepancia del snapshot_hash. Copie su archivo de parche dentro de la
carpeta del SDK de Dart y aplíquelo:
git apply patch_2_13_4.patch
Ahora puede compilar Flutter para cualquier sistema operativo o arquitectura compatible:
costum_engine/src/flutter/tools/gn --no-goma --android --android-cpu=arm64 --runtime-mode=release
ninja -C costum_engine/src/out/android_release_arm64
Una vez hecho, encontrará el libFlutter.so parcheado dentro de la carpeta
custom_engine/src/out/android_release_arm64/lib.stripped/libflutter.so.
Extracción de funciones, clases y objetos
Para volcar información sobre la estructura de la aplicación Flutter, la lista de bibliotecas, funciones y su ubicación, reFlutter parchea un objeto de la tabla de clases para listarlas. Este objeto ya no puede utilizarse, ya que la lista de funciones se poda en las versiones más recientes.
Aunque la información se poda en esa clase, sigue presente en la aplicación durante el tiempo de carga. Con un asterisco al respecto, pero volveremos sobre ello en otro artículo.
En los casos normales, todavía podemos listar las funciones de Flutter, las clases y su offset desde el analizador de Flutter. Para ello
tendremos que parchear la función PostLoad de la clase FunctionDeserializationCluster.
A continuación se muestra un ejemplo del parche que hay que aplicar para volcar los offsets de las funciones en formato JSON.
void PostLoad(Deserializer* d, const Array& refs, bool primary) {
+ OS::Print("Patch: Function List START\n");
if (d->kind() == Snapshot::kFullAOT) {
Function& func = Function::Handle(d->zone());
for (intptr_t i = start_index_, n = stop_index_; i < n; i++) {
func ^= refs.At(i);
auto const code = func.ptr()->untag()->code();
ASSERT(code->IsCode());
+
+ auto& rCode = Code::Handle(code);
+ auto& rClass = Class::Handle(func.Owner());
+ auto& rLib = Library::Handle(rClass.library());
+ auto& rlibName = String::Handle(rLib.url());
+
+ JSONWriter js;
+ // Open empty object so output is valid/parsable JSON.
+ js.OpenObject();
+
+ js.PrintProperty("method_name", func.UserVisibleNameCString());
+ js.PrintProperty("offset", offset);
+
+ auto& sig = String::Handle(func.InternalSignature());
+ js.PrintProperty("library_url", rlibName.ToCString());
+ js.PrintProperty("class_name", rClass.UserVisibleNameCString());
+ js.CloseObject();
+
+ char* buffer = nullptr;
+ intptr_t buffer_length = 0;
+ js.Steal(&buffer, &buffer_length);
El parche lee la URL de la biblioteca, el nombre de la clase y el nombre del método. También se puede extraer otra información, como la firma del método, pero no siempre está presente.
El offset devuelto será una dirección absoluta que varía según la dirección en la que se cargue la biblioteca.
Para mostrar solo el offset correspondiente dentro del binario, puede aplicarse el siguiente parche:
code->untag()->monomorphic_entry_point_ = monomorphic_entry_point;
- code->untag()->monomorphic_unchecked_entry_point_ =
- monomorphic_entry_point + unchecked_offset;
+
+ auto& offset =
+ instructions_table_.rodata()
+ ->entries()[instructions_table_.rodata()->first_entry_with_code +
+ instructions_index_ - 1]
+ .pc_offset;
+ code->untag()->monomorphic_unchecked_entry_point_ = offset;
+ // OS::Print("Patch: Offset 0x%016lx\n", monomorphic_entry_point + unchecked_offset);
Una vez extraídos los offsets al inicio, es posible interceptarlos. Los offsets pueden imprimirse en los logs; sin embargo, esto puede resultar problemático porque los logs tienen un límite de tamaño que a menudo provoca que los datos se trunquen.
Escribir los archivos en disco es una solución más estable. Para escribir en un archivo externo, puede añadir el siguiente parche, que intenta volcar los datos en distintas ubicaciones de archivo para adaptarse a las diferentes estructuras del sistema de archivos de cada teléfono.
+ for (const auto& path : PATHS) {
+ OS::Print("Using Path %s\n", path);
+ std::FILE* file;
+ // Write to the file
+ file = std::fopen(path, "a");
+ if (file != NULL) {
+ std::fwrite(buffer, sizeof(char), buffer_length, file);
+ std::fwrite("\n", sizeof(char), 1, file);
+ std::fclose(file);
+ OS::Print("Successfully wrote to the file '%s'.\n", path);
+ } else {
+ OS::Print("Failed to open the file '%s' for writing.\n", path);
+ }
+ }
+
Una vez recopilada la lista de funciones y sus offsets, podemos empezar a interceptarlas.
Análisis dinámico y la ABI personalizada de Flutter
Para interceptar las llamadas a funciones en Flutter, un obstáculo importante es el uso de una implementación de ABI personalizada.
La ABI dicta la compatibilidad binaria y abarca la convención de llamada, el tipo de datos, el tamaño y la alineación, la interfaz de llamadas al sistema, el name mangling, la gestión de excepciones y el formato de archivo.
Dart usa una convención de llamada personalizada. La convención de llamada para arm64, por ejemplo, se define en el
archivo sdk/runtime/vm/constants_arm64.h.
// Register aliases.
const Register TMP = R16; // Used as scratch register by assembler.
const Register TMP2 = R17;
const Register PP = R27; // Caches object pool pointer in generated code.
const Register DISPATCH_TABLE_REG = R21; // Dispatch table register.
const Register CODE_REG = R24;
const Register FPREG = FP; // Frame pointer register.
const Register SPREG = R15; // Stack pointer register.
const Register ARGS_DESC_REG = R4; // Arguments descriptor register.
const Register THR = R26; // Caches current thread in generated code.
const Register CALLEE_SAVED_TEMP = R19;
const Register CALLEE_SAVED_TEMP2 = R20;
const Register BARRIER_MASK = R28;
const Register NULL_REG = R22; // Caches NullObject() value.
const Register HEAP_BASE = R23;
Para interceptar una función y leer sus argumentos con Frida o con un depurador como LLDB, primero debemos obtener el puntero de argumentos y, según el tipo del argumento, que debemos conocer de antemano o que a veces se puede extraer de los metadatos de la firma de la función, determinaríamos la disposición en memoria del objeto argumento que hay que leer.
A continuación se muestra el código JS de Frida que permite extraer los argumentos y leer el valor de una cadena. Bool e Int son valores simples y pueden obtenerse fácilmente de la misma manera:
...
const argPointer = dartGetArguments(this.context, index)
const stringValue = getDartStringData(argPointer)
...
/**
* Get Dart Arguments.
*
* Arguments are passed to the custom function stack pointed at by register X15 on ARM.
*
* @param context Frida context giving access to register values.
* @param argIndex Argument index to determine which offset is the arg pointer.
* @returns {*} Argument pointer.
*/
function dartGetArguments(context, argIndex) {
// RSP on x64, see constants_x64.h at Dart VM repo SPREG value.
const x15 = context.x15;
return x15.add(8 * argIndex).readPointer();
}
/**
* Read SMI (Small Integer) from pointer.
* @param smiPtr SMI Pointer,
* @returns {*|null} Value.
*/
function readSMI(smiPtr) {
let smi_data = smiPtr.readU64();
if (parseInt(smi_data & 0x1, 10) === 0) {
return smi_data >> 1;
}
console.log(
`Invalid SMI pointer ${smiPtr} -> 0x${smi_data.toString(16)}: Smi LSB should be 0`)
return null
}
/**
* Parse String Dart object to extract value.
* @param dartStringPtr Dart String pointer.
* @returns {null|*[]} Tuple of parsed data.
*/
function parseDartString(dartStringPtr) {
if (dartStringPtr.and(0x1).toInt32() === 1) {
dartStringPtr = dartStringPtr.sub(1)
}
const tag = dartStringPtr.readU32();
const classId = (tag >> 16) & 0xffff;
if (classId === 0x5 || classId === 0x55) {
let stringLength = readSMI(dartStringPtr.add(8));
let stringDataStr = dartStringPtr.add(16)
let stringData = stringDataStr.readCString(stringLength);
return [stringDataStr, stringLength, stringData]
}
return null
}
/**
* Read Dart string at pointer.
* @param dartStringPtr Dart string pointer.
* @returns {*|null} String value.
*/
function getDartStringData(dartStringPtr) {
let dartStringInfo = parseDartString(dartStringPtr);
if (dartStringInfo != null) {
return dartStringInfo[2];
}
return null;
}
Ahora que hemos creado un parche, compilado una versión parcheada del runtime y tenemos un script capaz de leer las funciones y sus argumentos, el último paso es volver a empaquetar nuestra aplicación objetivo, ya sea Android o iOS, iniciarla y conectar nuestra herramienta de instrumentación favorita.
Saber qué funciones instrumentar es un tema amplio que se tratará en un artículo posterior. Entonces hablaremos del SDK de Dart, con sus más de 120k métodos, y de algunos de los riesgos observados en sus 1000 paquetes más populares.
Interceptación del tráfico
El tráfico de Flutter es un caso atípico que requiere un tratamiento especial.
Mientras que en las aplicaciones estándar de Android o iOS es posible interceptar el tráfico y eludir el TLS pinning con una
variedad de métodos, como cambiar la política de seguridad de red para aceptar el certificado personalizado, hookear SSL_read
y SSL_write para interceptar el tráfico antes del cifrado o volcar la clave de sesión TLS para descifrar el tráfico.
Flutter no usa la pila TLS nativa y, por tanto, no es posible configurar un proxy ni interceptar el tráfico. La biblioteca TLS está compilada de forma estática en el runtime de Flutter y, por ello, es difícil identificarla y parchearla de forma dinámica.
Para interceptar el tráfico en Flutter, la solución más robusta es volver a parchear el runtime de Flutter para desactivar la validación de certificados TLS y configurar un proxy personalizado.
Este enfoque ya se aplica en la herramienta reFlutter y sigue funcionando en las versiones actuales del runtime de Flutter.
En Socket.cc, forzamos la dirección IP y el puerto a los que se usan para interceptar el tráfico:
void FUNCTION_NAME(Socket_CreateConnect)(Dart_NativeArguments args) {
RawAddr addr;
SocketAddress::GetSockAddr(Dart_GetNativeArgument(args, 1), &addr);
Dart_Handle port_arg = Dart_GetNativeArgument(args, 2);
int64_t port = DartUtils::GetInt64ValueCheckRange(port_arg, 0, 65535);
+ if (port > 50) {
+ port = 8083;
+ addr.addr.sa_family = AF_INET;
+ addr.in.sin_family = AF_INET;
+ inet_aton("192.168.10.5", &addr.in.sin_addr);
+ }
+ ...
Después desactivamos las comprobaciones de certificados en ssl_crypto_x509_session_verify_cert_chain:
static bool ssl_crypto_x509_session_verify_cert_chain(SSL_SESSION *session,
SSL_HANDSHAKE *hs,
uint8_t *out_alert) {
+ return true;
Tras esos cambios, usamos el proxy para interceptar el tráfico sin instalar ningún certificado en el dispositivo. Sin embargo, debemos asegurarnos de que se use el modo de proxy invisible.
Conclusión
En esta primera parte del artículo hemos repasado la estructura de una aplicación Flutter, cómo parchear el runtime, cómo interceptar llamadas a funciones y leer sus argumentos y, por último, cómo parchear el runtime para interceptar el tráfico.
Los próximos 2 artículos abordarán los problemas de seguridad que se deben evaluar en la aplicación Dart; los más destacados son las vulnerabilidades de corrupción de
memoria con dart:ffi, los problemas de interoperabilidad con dart:jni o dart:js, la reflexión y
los problemas de serialización, ya sea en código nativo o en código Dart con Reflectables.
También hablaremos de los problemas de seguridad conocidos en los paquetes de Dart más populares, que deben tenerse en cuenta al desarrollar su aplicación.