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

Ofuscar readonly structs, parámetros in y retornos ref: los modreqs y los atributos del compilador que Nebula no debe eliminar

Un readonly struct, un parámetro in y un retorno ref readonly no son meras comodidades del código fuente: se codifican en los metadatos como modificadores personalizados y atributos emitidos por el compilador, que el compilador de C# vuelve a leer para decidir si un sitio de llamada hace una copia defensiva oculta, qué sobrecarga gana y si un valor puede pasarse por referencia siquiera. Un modreq(InAttribute) en un parámetro forma parte de su firma, así que eliminarlo cambia la identidad del método y rompe el enlace; quitar IsReadOnlyAttribute de un struct hace que cada llamada con in empiece a copiarlo; eliminar IsByRefLike permite aplicar boxing a un ref struct. Un ofuscador que trate todo esto como metadatos decorativos que renombrar o descartar produce ensamblados que siguen cargando pero se comportan de otra manera, o que no consiguen enlazar con llamadores que no controla. Este artículo lee el IL de cada característica, muestra exactamente qué piezas son firma y cuáles son atributo, recorre las tres formas en que un renombrador descuidado las corrompe y explica cómo Nebula conserva textualmente los modreqs y los atributos de compilador conocidos mientras sigue renombrando todo lo que es genuinamente suyo.

Hay una categoría de metadatos de .NET que parece decoración y en realidad es estructural. Un readonly struct, un parámetro in, un retorno ref readonly: en el código fuente de C# se leen como modificadores que uno esparce por rendimiento. En los metadatos son una mezcla de atributos personalizados y modificadores personalizados requeridos (modreq) que el siguiente compilador vuelve a leer para decidir si un sitio de llamada hace una copia oculta, qué sobrecarga se enlaza y si un valor puede pasarse por referencia siquiera.

Eso los convierte en una trampa para un ofuscador. Un renombrador que trate System.Runtime.CompilerServices.IsReadOnlyAttribute como un tipo más que renombrar, o un limpiador de metadatos que descarte atributos «sin usar», produce un ensamblado que carga sin problemas y luego se comporta de otra manera (copias de más, sobrecargas mal enlazadas) o no consigue enlazar con llamadores que no controla. Este artículo lee el IL de cada característica, separa las piezas de atributo de las piezas de firma y muestra cómo Nebula mantiene el contrato mientras sigue ofuscando su lógica.

readonly struct: un atributo que lee el siguiente compilador

Empecemos por el marcador más simple:

public readonly struct Money
{
    public readonly decimal Amount;
    public Money(decimal amount) => Amount = amount;
    public Money Add(in Money other) => new(Amount + other.Amount);
}

El readonly del struct compila a un atributo personalizado sobre el tipo:

.class public sequential ansi sealed beforefieldinit Money
       extends [System.Runtime]System.ValueType
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
    ...
}

Nada cambia en el token del tipo: sigue siendo el mismo Money. El atributo es una marca que lee el compilador consumidor. Cuando otro ensamblado escribe void Charge(in Money m) y llama a m.Add(...), el compilador comprueba si Money lleva IsReadOnlyAttribute. Si está, el compilador sabe que ningún miembro de Money puede mutar el receptor, así que puede invocar los miembros directamente sobre la referencia in. Si no está, el compilador debe asumir que un miembro podría mutar el valor y, como in significa «no lo permitas», emite una copia defensiva de todo el struct antes de cada acceso a un miembro.

Ese es el modo de fallo silencioso. Elimine IsReadOnlyAttribute de Money y nada se rompe; simplemente, cada llamador en cada otro ensamblado empieza a copiar Money en silencio en cada acceso, que es exactamente la sobrecarga que readonly struct existía para eliminar. La semántica que pidió el autor ha desaparecido, y el único síntoma es un código más lento. Un ofuscador que descarte atributos «sin usar» para reducir los metadatos hará precisamente esto.

in y ref readonly: el modreq en la firma

El marcador del parámetro es un animal distinto, y más peligroso, porque vive dentro del blob de la firma, no junto a él. Observe Add(in Money other):

.method public hidebysig instance valuetype Money
        Add(valuetype Money& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) other) cil managed
{
    .param [1]
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
    ...
}

Dos cosas distintas codifican in aquí. El tipo del parámetro es Money& (una referencia administrada) que lleva modreq(InAttribute), y además hay un IsReadOnlyAttribute sobre el parámetro. El modreq es la parte estructural: un modificador personalizado requerido forma parte del tipo en la firma, de modo que Add(in Money) y un hipotético Add(ref Money) tienen firmas genuinamente distintas. Los retornos ref readonly usan el mismo modreq(InAttribute) sobre el tipo de retorno, y out usa modreq(OutAttribute).

Por eso un modreq no puede renombrarse ni eliminarse como un atributo:

  • Elimine el modreq y la firma pasa de Money& modreq(InAttribute) a un simple Money&: ahora se lee como ref, no como in. Un llamador compilado contra el original esperaba in; la identidad del método se ha desplazado bajo sus pies y, según qué más esté presente, obtiene una MissingMethodException en la llamada o un enlace a la sobrecarga equivocada.
  • Renombre el tipo InAttribute referenciado a a.b.C y el modreq apunta ahora a un modificador que el compilador consumidor no reconoce como ref readonly/in. Sigue siendo un modreq válido en lo que al runtime respecta, así que el ensamblado carga, pero C# ya no ve el parámetro como in.
Dos codificaciones, dos reglas de preservaciónatributos — junto a la entidad, token intactoIsReadOnlyAttribute → en readonly structIsReadOnlyAttribute → parám. in / ref readonlyIsByRefLikeAttribute → en ref structRequiresLocationAttribute → parám. ref readonlyIsUnmanagedAttribute → restricción unmanagedmodreqs — dentro del blob de la firmaT& modreq(InAttribute) → parám. inT& modreq(InAttribute) → retorno ref readonlyT& modreq(OutAttribute) → parám. outparte del TIPO en la firma →cambia la identidad binaria del métodoConserve los atributos por NOMBRE COMPLETO (ni renombrar, ni eliminar).Deje intactos los bytes del modreq Y no renombre el InAttribute / OutAttribute al que apuntan.

ref struct y [IsByRefLike]: una propiedad de seguridad, no una sugerencia

Un ref struct lleva IsByRefLikeAttribute, y este sí es un contrato de seguridad que el runtime y el compilador aplican conjuntamente:

public ref struct BufferCursor
{
    private ref readonly byte _current;
    public ref readonly byte Current => ref _current;
}
.class public sequential ansi sealed beforefieldinit BufferCursor
       extends [System.Runtime]System.ValueType
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.IsByRefLikeAttribute::.ctor() = ( 01 00 00 00 )
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
            = ( 01 00 09 'RefStructs' 00 00 )
    ...
}

IsByRefLikeAttribute es lo que le dice a un compilador consumidor que a BufferCursor no se le puede aplicar boxing, ni almacenarlo en el montón, ni capturarlo en una lambda, ni usarlo como argumento de tipo genérico donde pudiera escapar: todo el conjunto de reglas que impide que las referencias interiores de un ref struct sobrevivan al marco de pila al que apuntan. Elimínelo y el compilador que consuma su ensamblado dejará tranquilamente que alguien aplique boxing al cursor, anulando la garantía y abriendo un use-after-free en código de aspecto seguro. El CompilerFeatureRequiredAttribute que lo acompaña es la puerta del propio compilador: a un consumidor que no entienda la característica RefStructs se le indica que rechace el tipo en lugar de manejarlo mal. Ambos son contratos; ninguno de los dos es suyo para renombrarlo.

La propiedad Current muestra el modreq del lado del retorno en acción: ref readonly byte Current compila a un getter que devuelve uint8& modreq(InAttribute), de modo que la historia del retorno por referencia y la del parámetro in comparten exactamente el mismo tipo de modificador. Una sola entrada de preservación cubre ambas.

El caso autoincrustado, donde renombrar hace el daño de verdad

Este es el detalle que atrapa a los ofuscadores que sí protegen los atributos del framework, pero por token o por ensamblado. Cuando su ensamblado es el primero de su clausura en necesitar uno de estos marcadores (porque apunta a un framework demasiado antiguo para definir IsReadOnlyAttribute, por ejemplo), el compilador sintetiza el tipo del atributo en su propio ensamblado:

.class private auto ansi sealed beforefieldinit
       System.Runtime.CompilerServices.IsReadOnlyAttribute
       extends [System.Runtime]System.Attribute
{
    .custom instance void Microsoft.CodeAnalysis.EmbeddedAttribute::.ctor() = ( 01 00 00 00 )
    ...
}

Es un tipo real en sus metadatos, en un espacio de nombres que usted no escribió, y su identidad se compara por nombre completo. El siguiente compilador (incluido el suyo, la próxima vez que compile contra este ensamblado como referencia) encuentra los readonly structs buscando un tipo llamado literalmente System.Runtime.CompilerServices.IsReadOnlyAttribute y leyendo sus aplicaciones. Renombre ese tipo incrustado a A.B y cada readonly y cada in del ensamblado dejan de reconocerse en silencio, porque el marcador que el compilador busca ha desaparecido. El Microsoft.CodeAnalysis.EmbeddedAttribute que lo acompaña marca estos tipos como internos del compilador y también debe sobrevivir por nombre. Un renombrador ingenuo ve una clase de atributo private con un nombre raro y la renombra como cualquier otro tipo interno, y corrompe la ABI sin tocar una sola línea de su código.

Dónde traza Nebula la línea

La regla de Nebula es separar el contrato del compilador de su lógica, y para ello mantiene una lista de preservación de la familia conocida de modificadores y marcadores, comparada por nombre completo para que cubra tanto las copias del framework como las autoincrustadas:

Conservado por nombre completoPor qué — qué se rompe si se renombra o eliminaIsReadOnlyAttributeaparecen copias defensivas en cada sitio de llamada con inIsByRefLikeAttributeel ref struct admite boxing / escape — use-after-freeIn/OutAttribute (modreq target)cambia la identidad de la firma; se pierden in/out/ref readonlyRequiresLocationAttributese pierde la semántica de parámetro ref readonly (C# 12)Embedded / CompilerFeatureRequiredel compilador consumidor deja de reconocer los marcadoresTodo lo demás (nombre del tipo, miembros, cuerpos, cadenas, constantes, flujo de control) sigue siendo ofuscable.

Concretamente, la canalización hace tres cosas:

  1. Nunca renombrar por nombre la familia de modificadores y marcadores. IsReadOnlyAttribute, IsByRefLikeAttribute, IsUnmanagedAttribute, RequiresLocationAttribute, InAttribute, OutAttribute, EmbeddedAttribute, CompilerFeatureRequiredAttribute, definidos por el framework o autoincrustados, permanecen con sus nombres completos. Las copias incrustadas se detectan precisamente porque llevan EmbeddedAttribute, que a su vez está en la lista.
  2. Tratar los modreq de una firma como bytes inmutables. Cuando Nebula reescribe una firma (porque un tipo de usuario presente en ella fue renombrado), copia los tokens modreq/modopt sin cambios y nunca redirige el InAttribute/OutAttribute al que hacen referencia. El modificador se queda exactamente donde lo puso el compilador.
  3. Mantener los atributos marcadores en sus declaraciones. El IsReadOnlyAttribute de su struct Money y de su parámetro in, el IsByRefLikeAttribute de su BufferCursor: estos viajan con la entidad (posiblemente renombrada) en lugar de barrerse como atributos personalizados sin usar.

Lo que queda es justo el objetivo: su Money se convierte en un struct renombrado con miembros ofuscados y constantes cifradas, su BufferCursor en un ref struct renombrado con el cuerpo revuelto, y un consumidor sigue viendo un readonly struct con un método que recibe in y un ref struct con una propiedad ref readonly, porque cada byte que el compilador lee para saberlo sigue ahí. La protección recae sobre su lógica. La ABI de la que depende el siguiente compilador queda intacta. Esa línea (ofuscar la implementación, conservar el contrato) es la que un ofuscador tiene que sostener, y readonly/in/ref readonly es donde sostenerla exige conocimiento real de los metadatos y no una simple tabla de renombrado.

Prueba Nebula.NET

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