Skip to content
← Todas las publicaciones
· Delta1 Labs Glass.NETDecompilación.NETAnálisis a fondo

Decompilar tipos de referencia anulables: dónde vive realmente string? en la metadata

string y string? son el mismo tipo para el runtime —System.String, una sola TypeRef, ninguna diferencia en ninguna firma—. El signo de interrogación existe solo en tiempo de compilación, y el compilador tiene que colarlo en el ensamblado en algún sitio para que los consumidores de tu biblioteca reciban las advertencias correctas. Lo hace con dos atributos embebidos, NullableAttribute y NullableContextAttribute, un vocabulario de tres valores en bytes, un aplanamiento en preorden de árboles de tipos genéricos a arrays de bytes, y un esquema de compresión agresivo que omite lo que coincide con el valor por defecto del ámbito. Esta entrada lee esos bytes directamente del IL, muestra la codificación exacta del blob para las formas de un byte y de array, recorre cómo un decompilador reconstruye ?, notnull y class? a partir de ellos, y explica por qué saltarse este paso cambia silenciosamente el contrato público de una biblioteca.

Pregúntale al runtime cuál es el tipo de una propiedad string? y te dirá System.String. Pregúntale por una propiedad string y dirá lo mismo. Hay una sola TypeRef para System.String en el ensamblado, ambas propiedades apuntan a ella, y los blobs de firma de sus getters son byte a byte idénticos. Los tipos de referencia anulables son la característica puramente de tiempo de compilación más exhaustiva de C#: cambian advertencias, no código, y no dejan rastro alguno en el sistema de tipos.

Sí dejan rastro en algún sitio, porque tienen que hacerlo. Si publicas una biblioteca compilada con #nullable enable, un consumidor que compile contra ella recibe advertencias según cuáles de tus parámetros aceptan null y cuáles de tus retornos pueden producirlo. Esa información cruza la frontera del ensamblado, así que debe estar en la metadata. El compilador la almacena en atributos personalizados —dos de ellos, embebidos de forma privada en cada ensamblado que usa la característica, con una codificación compacta en bytes y un esquema de compresión diseñado para mantener la metadata pequeña—. Esta entrada lee esos bytes directamente, y luego muestra cómo un decompilador como Glass.NET los convierte de nuevo en el ?, notnull y class? que el autor escribió.

Un tipo, tres anotaciones, dos atributos

Aquí tienes una clase pequeña que ejercita cada caso de codificación:

#nullable enable
using System.Diagnostics.CodeAnalysis;

public sealed class Catalog<T> where T : notnull
{
    public string Name { get; }                                   // non-nullable
    public string? Description { get; set; }                      // nullable
    public Dictionary<string, List<string?>?> Aliases { get; }    // mixed, nested
    public int? LastIndex { get; set; }                           // value type: Nullable<int>

    public Catalog(string name) { Name = name; Aliases = new(); }

    public bool TryFind(string key, [MaybeNullWhen(false)] out T value) { ... }
}

El compilador codifica cada posición de tipo de referencia con un byte:

bytesignificadocómo se escribe en C#
0oblivious — sin información de anotacióncódigo compilado sin #nullable enable
1no anotado — no anulablestring
2anotado — puede ser nullstring?

Lleva esos bytes en dos atributos que define dentro de tu ensamblado —no referenciados desde la BCL— como tipos internal sealed en System.Runtime.CompilerServices, marcados con [Microsoft.CodeAnalysis.Embedded] y [CompilerGenerated]:

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8[] NullableFlags
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8)   cil managed { ... }
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8[]) cil managed { ... }
}

.class private auto ansi sealed beforefieldinit System.Runtime.CompilerServices.NullableContextAttribute
       extends [System.Runtime]System.Attribute
{
  .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
  .field public initonly uint8 Flag
  .method public hidebysig specialname rtspecialname instance void .ctor(uint8) cil managed { ... }
}

NullableAttribute va sobre una cosa con un tipo —un campo, una propiedad, un parámetro, un valor de retorno, un parámetro de tipo genérico, un tipo base— y describe ese tipo. NullableContextAttribute va sobre un ámbito —un tipo o un método— y fija el byte por defecto de todo lo que hay dentro que no tenga su propio NullableAttribute. Los dos trabajan juntos para mantener la metadata pequeña: el compilador elige el byte más común de un ámbito como su contexto y solo emite NullableAttribute donde un miembro difiere.

Leer la forma de un byte

Vuelca Catalog<T> y los atributos aparecen sobre la propia clase y sobre Description:

.class public auto ansi sealed beforefieldinit Catalog`1<T>
       extends [System.Runtime]System.Object
{
  .param type T
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
  .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 00 00 00 )

  .property instance string Name()
  {
    .get instance string Catalog`1::get_Name()
  }

  .property instance string Description()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 02 00 00 )
    .get instance string Catalog`1::get_Description()
    .set instance void Catalog`1::set_Description(string)
  }
  ...

El formato del blob del atributo personalizado es: un prólogo de dos bytes 01 00, los argumentos fijos del constructor en orden, luego un recuento de dos bytes de argumentos con nombre (00 00 aquí). Así que ( 01 00 02 00 00 ) es prólogo, el byte 02, sin argumentos con nombre: [Nullable(2)], la forma anotada.

Lee la clase de arriba abajo:

  • [NullableContext(1)] sobre el tipo: el valor por defecto de cada miembro de esta clase es 1, no anulable. Por eso Name no lleva atributo alguno; su string hereda 1 del contexto. El compilador eligió 1 porque la mayoría de las referencias de esta clase son no anulables; una clase llena de ? obtendría [NullableContext(2)] y los miembros no anulables serían los que llevan atributos explícitos.
  • [Nullable(0)] sobre el tipo: esto describe los tipos propios de la declaración de la clase —su tipo base e interfaces—. System.Object aquí es oblivious, así que 0. Este confunde a la gente; no dice “esta clase es oblivious”, dice “la referencia de tipo base de esta clase no tiene anotación”.
  • [Nullable(1)] sobre el parámetro genérico T vía .param type T: eso es where T : notnull. Una restricción notnull no tiene representación en runtime —no hay fila de restricción para ella en la tabla GenericParamConstraint— así que existe solo como este atributo. Quita el atributo y la restricción desaparece.
  • [Nullable(2)] sobre Description: el único miembro que difiere del contexto. string?.
  • LastIndex no tiene nada, y no necesita nada: int? es Nullable<int>, que es un tipo distinto en el blob de firma. Los atributos de anulabilidad de referencias no tienen nada que decir sobre él.

Los accesores de propiedad reciben su propio NullableContext cuando difieren del del tipo: get_Description y set_Description llevan [NullableContext(2)] para que su valor de retorno y su parámetro, que no tienen atributos propios, se resuelvan a 2. El atributo de la propiedad y los contextos de los accesores siempre coinciden; un decompilador puede leer cualquiera.

Leer la forma de array: aplanar un tipo genérico

Aliases es donde el byte único deja de bastar. Dictionary<string, List<string?>?> tiene cuatro posiciones de tipo de referencia con anotaciones distintas, y el compilador necesita describirlas todas. Lo hace recorriendo el árbol de tipos en preorden —el tipo en sí, luego cada argumento de tipo recursivamente, de izquierda a derecha— y emitiendo un byte por nodo a un array:

recorrido en preorden: nodo, luego argumentos de tipo de izquierda a derechaDictionary<,>1string1List<>?2string?2bytes aplanados[ 1, 1, 2, 2 ]blob del atributo, ctor uint8[]01 00 prólogo04 00 00 00 longitud = 401 01 02 02 los flags00 00 sin args con nombre
  .property instance class [System.Collections]System.Collections.Generic.Dictionary`2<string, class [System.Collections]System.Collections.Generic.List`1<string>> Aliases()
  {
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8[]) = ( 01 00 04 00 00 00 01 01 02 02 00 00 )
    .get instance class ... Catalog`1::get_Aliases()
  }

Mira el tipo declarado de la propiedad en el IL: Dictionary2<string, List1<string>>, sin signos de interrogación en ninguna parte, porque no hay dónde ponerlos en un blob de firma. Las cuatro anotaciones viven en el atributo: [1, 1, 2, 2] leídas en el mismo preorden que la figura —Dictionary es 1, string es 1, List<...>? es 2, string? es 2.

El blob del constructor uint8[] es el prólogo, un recuento de elementos int32 en little-endian (04 00 00 00), los elementos, y el recuento de argumentos con nombre. Si cada byte del array aplanado fuera idéntico, el compilador lo colapsa al constructor de un byte —List<string> bajo un contexto de 1 no necesita atributo alguno, y List<string?>? se convierte en [Nullable(2)], no en [Nullable(new byte[] { 2, 2 })]—. Un decompilador tiene que manejar ambos constructores y tratar el byte único como “este valor para cada posición”.

Los tipos de valor ocupan una ranura pero siempre llevan 0. Dictionary<int, string?> se aplana a [1, 0, 2]; Dictionary<int, int?> sería [1, 0, 0, 0] —Dictionary 1, int 0, Nullable<> 0, el int interior 0— que se colapsa, dado que los cuatro no son idénticos, a la forma de array. Los arrays son un nodo con un hijo: string?[] es [1, 2] (array no nulo de strings anulables) y string[]? es [2, 1]. Las tuplas se aplanan a través de ValueTuple<...> igual, por lo que (string, string?) produce [0, 1, 2].

Parámetros out y los atributos de análisis

TryFind muestra la otra mitad de la historia:

  .method public hidebysig instance bool TryFind(string key, [out] !T& 'value') cil managed
  {
    .param [2]
      .custom instance void [System.Runtime]System.Diagnostics.CodeAnalysis.MaybeNullWhenAttribute::.ctor(bool) = ( 01 00 00 00 00 )
    ...
  }

Nada aquí está embebido. MaybeNullWhenAttribute es un tipo público en la BCL; describe flujo —“cuando el método devuelve false, no confíes en value”— no la anulabilidad declarada del tipo. El decompilador lo muestra tal cual, igual que muestra cualquier otro atributo. key no lleva NullableAttribute porque el método hereda el contexto de 1 de la clase, y !T& no lleva nada porque T está restringido notnull a nivel de clase. La división es limpia: la anulabilidad declarada (el ? y las restricciones) está en los atributos embebidos y debe reconstruirse; los atributos de flujo son metadata normal y simplemente no deben ocultarse.

Qué tiene que hacer el decompilador

Renderizar los bytes fielmente es un algoritmo pequeño con varios puntos donde equivocarse:

  1. Encuentra los atributos embebidos por nombre, no por identidad. Cada ensamblado define su propio System.Runtime.CompilerServices.NullableAttribute; no hay un tipo compartido con el que comparar. Compara por nombre completo, confirma el marcador [Embedded], y trata las propias definiciones como fontanería del compilador que ocultar de la salida.
  2. Resuelve el contexto efectivo de cada miembro. El NullableContext propio de un método vence al de su tipo declarante; el de un tipo anidado vence al de su tipo externo; el de un accesor de propiedad vence al del tipo de la propiedad. Sin contexto en ningún punto de la cadena, el valor por defecto es 0 —oblivious— que es como se ve cada ensamblado anterior a C# 8.
  3. Recorre cada tipo de firma en preorden, consumiendo bytes. Toma el NullableAttribute del miembro si está presente —un byte único aplica a cada posición; un array da un byte por nodo—. Si no, cada posición toma el byte del contexto. Los tipos de valor consumen una ranura y se ignoran. Arrays, punteros, byrefs e instanciaciones genéricas contribuyen cada uno su nodo y luego recurren.
  4. Deletrea el resultado. Un 2 sobre un tipo de referencia o parámetro de tipo añade ?; un 1 no añade nada; un 0 significa que el miembro debe renderizarse dentro de una región #nullable disable, o el fichero entero dejarse sin #nullable enable si todo es 0. Un 1 sobre un parámetro genérico sin otra restricción de referencia se convierte en where T : notnull; un 2 sobre una restricción class se convierte en class?; un 2 sobre el uso de un parámetro sin restringir se convierte en T?.
  5. Respeta [NullablePublicOnly]. Cuando el módulo lleva ese marcador, el compilador quitó las anotaciones de los miembros no públicos; un decompilador debe renderizar esos como oblivious en lugar de inferir 1 de un contexto que, por construcción, nunca estuvo pensado para aplicarse a ellos.
  6. Emite la directiva. Un fichero con cualquier anotación 1 o 2 necesita #nullable enable arriba, o la salida recompilada avisa en todos los lugares equivocados.

Glass hace las seis. Abre Catalog<T> y la salida es la clase con la que empezaste: string? Description, Dictionary<string, List<string?>?> Aliases, where T : notnull, [MaybeNullWhen(false)] out T value, y un #nullable enable arriba del fichero. Los dos tipos de atributo embebidos no aparecen en el árbol de tipos —aún puedes alcanzarlos cambiando a la vista de IL, donde el blob ( 01 00 04 00 00 00 01 01 02 02 00 00 ) es exactamente lo que muestra la figura de arriba— y el [Nullable(0)] sobre la declaración de la clase, que describe un object base oblivious, correctamente no produce nada.

Por qué importa más de lo que parece

Para la mayoría de la metadata generada por el compilador, un decompilador que la ignora produce una salida que es meramente más fea. La anulabilidad es distinta, porque ignorarla produce una salida que es errónea de una forma que compila. Quita los atributos y string? Description se convierte en string Description; where T : notnull se evapora; #nullable enable está ausente, así que la biblioteca reconstruida es oblivious por completo. Un consumidor que recompile contra esa biblioteca reconstruida pierde cada advertencia que el autor original puso ahí, y en la otra dirección, un TryFind cuyo parámetro out es honestamente [MaybeNullWhen(false)] junto a un tipo de retorno que ahora afirma no-null es un contrato que nunca se publicó.

Esa es la verdadera prueba de un decompilador sobre esta característica: no si la salida parece plausible, sino si una biblioteca reconstruida a partir de ella presenta el mismo contrato de anulabilidad a sus consumidores que la original. La información está toda ahí en la metadata —tres valores de byte, un aplanamiento en preorden, un contexto por defecto, y un par de tipos de atributo embebidos— y leerla de vuelta es lo que convierte System.String en el string? que realmente se escribió.

Prueba Nebula.NET

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