Skip to content
← Todas las publicaciones
· Delta1 Labs Ofuscación.NETGuía

Leer stack traces ofuscados: mapas de renombrado y desofuscación de fallos

La ofuscación por renombrado convierte tus stack traces de producción en un muro de a, b, c — a menos que conserves el mapa de renombrado. Aquí tienes cómo funciona el mapa, cómo restaurar un trace legible a partir de un fallo ofuscado, y cómo mantener el mapa a salvo sin distribuirlo.

El renombrado es lo primero que hacen la mayoría de los ofuscadores de .NET y la victoria más barata que ofrecen: Billing.InvoiceService.Charge se convierte en a.b.c, y un decompilador deja de entregarle tu arquitectura a un lector gratis. La trampa aparece semanas después, la primera vez que un fallo real vuelve del campo. El stack trace está ahí, la línea es correcta — pero cada frame dice a.b(c), y no puedes saber qué se rompió. El instinto es desactivar el renombrado. La jugada correcta es conservar el mapa de renombrado y aprender a leer a través de él.

Por qué el trace está revuelto pero no perdido

Un stack trace de .NET se construye en el momento del lanzamiento a partir de metadatos que están en el ensamblado distribuido: los tokens de método en la pila de llamadas, resueltos a sus nombres. La ofuscación reescribió esos nombres antes de distribuir, así que el trace reporta fielmente los nombres que realmente están en el binario — los ofuscados. Nada está corrupto. El tipo de excepción, el mensaje, el orden de llamadas y (si conservaste los símbolos) los números de línea son todos genuinos. Lo único que falta es el diccionario que mapea los nombres distribuidos de vuelta a los tuyos.

Ese diccionario es el mapa de renombrado, y un buen ofuscador emite uno por compilación. No se distribuye con la app — eso entregaría a un atacante el inverso exacto de la ofuscación. Es un artefacto de compilación que archivas junto a los PDB de esa versión.

Qué contiene realmente una entrada del mapa

Es tentador imaginar el mapa como una tabla plana de nombreViejo → nombreNuevo, pero eso se desmorona de inmediato, porque los ofuscadores reutilizan nombres cortos de forma agresiva. a podría ser cien métodos sin relación; b, una docena de tipos. El ámbito es lo que los mantiene separados, así que cada entrada del mapa está completamente cualificada — tipo declarante más firma completa — no solo un nombre de hoja. Conceptualmente, unas pocas filas se ven así:

# original (fully qualified)                        => obfuscated
Billing.InvoiceService::Charge(Customer, Money)      => a.b::c(a.d, a.e)
Billing.InvoiceService::.ctor(IClock)                => a.b::.ctor(a.f)
Core.Money::FromMinor(Int64, String)                 => a.e::a(System.Int64, System.String)

El archivo real suele ser XML o un formato específico de la herramienta, pero la forma es la misma: un elemento original identificado por espacio de nombres, tipo, miembro y firma, emparejado con su forma ofuscada. Como la clave es el frame completo, A::a(int) y B::a(string) son filas distintas aunque ambas hojas sean a. Un desofuscador que empareje solo por el nombre corto traducirá mal; uno que empareje por el frame cualificado es exacto.

Desofuscar un trace, frame a frame

Aquí hay un trace ofuscado tal como podría llegar en un informe de fallo:

System.InvalidOperationException: Sequence contains no elements
   at a.e.a(Int64 A_0, String A_1)
   at a.b.c(a.d A_0, a.e A_1)
   at a.g.b(a.d A_0)
   at X.<>c__DisplayClass4_0.a()

Leerlo de vuelta con el mapa de esa compilación convierte cada frame en su identidad original:

System.InvalidOperationException: Sequence contains no elements
   at Core.Money.FromMinor(Int64 minor, String currency)
   at Billing.InvoiceService.Charge(Customer customer, Money amount)
   at Billing.BatchRunner.Run(Customer customer)
   at Billing.BatchRunner.<Run>b__4_0()   // lambda in Run

Dos detalles importan aquí. Primero, los frames generados por el compilador — la clausura <>c__DisplayClass, la lambda b__ — normalmente el renombrado los deja intactos porque los nombró el compilador, no tú; un buen mapa aún resuelve el método contenedor para que sepas que la lambda vivía en Run. (Si esta parte te resulta desconocida, cómo las lambdas se convierten en display classes cubre la forma.) Segundo, los nombres de parámetro como A_0 aparecen porque el ofuscador eliminó los originales; si los necesitas de vuelta, conserva los nombres de parámetro en el mapa o desactiva el renombrado de parámetros para los frames que te importan.

La traducción en sí es mecánica: analiza el trace en frames, y para cada frame busca (tipo declarante, miembro, firma) en el mapa y sustituye el original. La única sutileza real es el emparejamiento — debes normalizar la firma ofuscada igual que el mapa la almacenó (tipos de parámetro completamente cualificados, incluidos los nombres de tipo ya ofuscados), o la búsqueda falla.

Fallo ofuscadoat a.b.c(a.d, a.e)Mapa de renombrado (build N)archivado, no distribuidoDesofuscarTrace legibleInvoiceService.Charge(...)

Mantener el mapa a salvo y localizable

Un mapa solo es útil si, meses después de que una compilación se distribuyó, puedes encontrar el mapa exacto de esa compilación — y solo tú puedes. Tres prácticas lo hacen fiable:

  • Archiva un mapa por compilación, indexado por versión y módulo. Guárdalo junto a los PDB como artefacto de compilación, nombrado por versión de ensamblado y, idealmente, el Mvid del módulo (el GUID grabado en cada módulo compilado). Cuando llega un fallo, el informe te dice la versión; eso selecciona el mapa sin adivinar.
  • Nunca dejes el mapa cerca del cliente. No pertenece al instalador, al directorio de la app, a un paquete NuGet ni a un servidor de símbolos público. Trátalo como una clave de firma: almacenamiento interno, con control de acceso. Un mapa distribuido es una app desofuscada.
  • Desofusca del lado del servidor, en la recepción. Conecta la traducción a donde lleguen los informes de fallo para que los traces ofuscados entrantes se resuelvan automáticamente contra el mapa archivado correspondiente. Tus paneles muestran entonces nombres reales; el usuario nunca ve un mapa y nunca necesita uno.

Cuándo quieres dejar un frame legible

El renombrado completo más un mapa privado es el valor por defecto correcto, pero a veces exoneras deliberadamente a unos pocos miembros del renombrado — una superficie de SDK pública a la que los clientes se enlazan por nombre, un contrato de plugin, o un puñado de puntos de entrada de alto nivel que quieres legibles incluso sin mapa. Esos frames llegan en un trace legibles tal cual, y todo lo que hay debajo sigue ofuscado. Nebula te permite acotar el renombrado con reglas de inclusión/exclusión exactamente para esto: protege las partes internas de forma agresiva, conserva los nombres deliberadamente públicos, y emite un mapa que cubre el resto.

La idea central: un stack trace ofuscado no es un stack trace perdido. El fallo es real y la información está intacta — está escrita en los nombres que realmente se distribuyeron. Conserva el mapa por compilación, resuelve los traces de tu lado, y obtienes toda la protección del renombrado sin el impuesto de depuración. Desactiva el renombrado para que los traces sean legibles y simplemente habrás entregado tu mapa de símbolos al lector gratis; conserva el mapa en su lugar, y solo tú podrás leerlo.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.