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

Ofuscar un módulo binario de PowerShell: el nombre del cmdlet vive en un atributo, pero los nombres de los parámetros son los nombres de tus propiedades

Un módulo binario de PowerShell es un ensamblado .NET corriente que PowerShell carga por reflexión, y es un objeto peculiar de ofuscar porque su contrato público no está donde los desarrolladores de C# esperan encontrarlo. El nombre del cmdlet —Get-Widget— procede del atributo [Cmdlet], así que la clase que lo implementa puede renombrarse a cualquier cosa. Pero los nombres de los parámetros —-Name, -Force, -Path— son literalmente los nombres de tus propiedades públicas: renómbralos y se rompen todos los scripts, todo el autocompletado con tabulador y todas las páginas de Get-Help. Lo mismo ocurre con los objetos que el cmdlet escribe en la canalización, cuyos nombres de propiedad son los encabezados de columna de Format-Table y los objetivos de Select-Object. Esta entrada lee exactamente sobre qué reflexiona el runtime de PowerShell cuando importa un módulo y enlaza los parámetros, traza la línea entre lo que Nebula puede renombrar (la clase del cmdlet, el motor que hay detrás) y lo que queda fijado (las propiedades de parámetro, las propiedades de salida, los atributos), muestra la disposición de «cmdlet fino, motor grueso» que mantiene pequeña la superficie fijada, da la configuración y termina con la comprobación de importar e invocar que de verdad demuestra que el módulo protegido funciona.

Un módulo binario de PowerShell es un ensamblado .NET sin más. Escribes una clase, la decoras con [Cmdlet], compilas una DLL, e Import-Module la convierte en un comando que un usuario puede escribir. Eso lo hace atractivo de proteger —el motor que hay detrás de un módulo comercial es código de producto real— y algo traicionero de ofuscar, porque su contrato público no está en el lugar donde los desarrolladores de C# lo buscan. El nombre del cmdlet es un argumento de atributo y se renombra sin ningún efecto. Los nombres de los parámetros del cmdlet son los nombres de tus propiedades, y las columnas de tu salida también son los nombres de tus propiedades. Renómbralos y el módulo sigue cargándose, sigue «funcionando», y todos los scripts que lo llaman se rompen.

Esta entrada lee sobre qué reflexiona realmente el runtime de PowerShell, traza la línea para Nebula y muestra la disposición que mantiene minúscula la superficie fijada.

El módulo tal como lo ve PowerShell

Este es un cmdlet representativo, la forma que distribuye la mayoría de los módulos:

using System.Management.Automation;
using Acme.Widgets.Engine;   // the logic — renames wholesale
using Acme.Widgets.Model;    // WidgetInfo — pipeline output, kept

namespace Acme.Widgets.Cmdlets;

[Cmdlet(VerbsCommon.Get, "Widget", DefaultParameterSetName = "ByName")]
[OutputType(typeof(WidgetInfo))]
public sealed class GetWidgetCommand : PSCmdlet
{
    [Parameter(Mandatory = true, Position = 0, ParameterSetName = "ByName",
               ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
    [Alias("n")]
    public string Name { get; set; } = "";

    [Parameter(ParameterSetName = "ById")]
    [ValidateRange(1, int.MaxValue)]
    public int Id { get; set; }

    [Parameter] public SwitchParameter IncludeHidden { get; set; }

    private WidgetCatalog? _catalog;                          // private: renames freely

    protected override void BeginProcessing() => _catalog = WidgetCatalog.Open();

    protected override void ProcessRecord()
    {
        var hits = ParameterSetName == "ById"
            ? _catalog!.FindById(Id)
            : _catalog!.FindByName(Name, IncludeHidden.IsPresent);
        foreach (var w in hits) WriteObject(new WidgetInfo(w.Name, w.Size, w.Modified));
    }
}

// In Acme.Widgets.Model: the pipeline output shape. Its property names are contract.
public sealed record WidgetInfo(string Name, long Size, DateTime Modified);

Y el manifiesto que lo acompaña:

@{
    RootModule        = 'Acme.Widgets.dll'       # the file, not a type
    ModuleVersion     = '2.3.0'
    CmdletsToExport   = @('Get-Widget', 'Set-Widget', 'Remove-Widget')   # Verb-Noun names
    FormatsToProcess  = @('Acme.Widgets.Format.ps1xml')
}

Cuando se ejecuta Import-Module Acme.Widgets, el motor carga Acme.Widgets.dll, enumera sus tipos públicos, conserva los que derivan de Cmdlet/PSCmdlet y llevan [Cmdlet], y registra cada uno bajo el Verb-Noun almacenado en el atributo. Fíjate en lo que falta en esa secuencia: el nombre de la clase. A GetWidgetCommand nadie lo busca nunca: ni el manifiesto, ni el motor, ni un script. Se encuentra por atributo y se nombra por atributo, que es la misma razón por la que un generador de código fuente de Roslyn se renombra con libertad.

Dónde vive de verdad el contrato: el enlazador de parámetros

Ahora el usuario escribe Get-Widget -Name foo, o 'foo' | Get-Widget, o Get-Widget foo. El enlazador de parámetros resuelve los tres contra la instancia del cmdlet mediante reflexión sobre los nombres de propiedad:

  • -Name foo encuentra una propiedad pública llamada Name decorada con [Parameter] y la establece.
  • Get-Widget foo encuentra el [Parameter] con Position = 0: sigue siendo la propiedad Name.
  • $obj | Get-Widget con ValueFromPipelineByPropertyName hace coincidir $obj.Name con la propiedad por nombre.

No hay una ranura de metadatos aparte para «el nombre del parámetro»; el nombre de propiedad del CLR es el nombre del parámetro. Renombra Name a a y el cmdlet enlaza ahora -a, rechaza -Name con A parameter cannot be found that matches parameter name ‘Name’, autocompleta -a y Get-Help Get-Widget documenta -a. [Alias("n")] sobrevive —es una constante de cadena de atributo— pero un alias es un nombre extra; el nombre primario sigue siendo la propiedad, y todos los scripts que hay en circulación usan el nombre primario.

Sobre qué reflexiona el runtime al escribir Get-Widget -Name fooImport-Modulebusca el tipo por el atributo [Cmdlet]enlazador de parámetrospropiedad "Name" por reflexiónProcessRecord()sustitución de virtual baseWriteObject(WidgetInfo)Format-Table / Select-Objectleen .Name .Size por nombreSeguro de renombrar• nombre de clase GetWidgetCommand• su espacio de nombres, campos privados• cuerpos de BeginProcessing/ProcessRecord• todo Acme.Widgets.EngineFijado• cada nombre de propiedad [Parameter]• tipo de salida + nombres de propiedad• [Cmdlet] [Parameter] [Alias] atributos [OutputType]• nombres usados en formatos .ps1xmlPor quéVerb-Noun es un argumentode atributo: una constante,nunca renombrada; la clase es libre.Un parámetro no tiene nombrepropio: el nombre de propiedaddel CLR ES el -Parameter escrito.Los objetos de canalización seleen por nombre de propiedad:columnas, filtros, $w.Name.Las sustituciones mantienen su ranura.

El lado de la salida también queda fijado

WriteObject(new WidgetInfo(...)) envía un objeto por la canalización, y cada consumidor lo lee por nombre de propiedad mediante reflexión en tiempo de ejecución. Format-Table usa los nombres como encabezados de columna; Select-Object Name, Size, Where-Object { $_.Size -gt 1MB }, Sort-Object Modified y $w.Name resuelven todos contra el objeto vivo. Un WidgetInfo renombrado sigue funcionando mecánicamente y produce columnas llamadas a, b, c, y todo script posterior que seleccione o filtre por los nombres documentados no obtiene nada, sin avisar, lo cual es peor que un error.

El archivo de formato hace explícita la dependencia. Una vista .ps1xml nombra el tipo por su nombre completo y sus propiedades como cadenas:

<View>
  <Name>WidgetTable</Name>
  <ViewSelectedBy><TypeName>Acme.Widgets.Model.WidgetInfo</TypeName></ViewSelectedBy>
  <TableControl>
    <TableRowEntries><TableRowEntry><TableColumnItems>
      <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
      <TableColumnItem><PropertyName>Size</PropertyName></TableColumnItem>
      <TableColumnItem><PropertyName>Modified</PropertyName></TableColumnItem>
    </TableColumnItems></TableRowEntry></TableRowEntries>
  </TableControl>
</View>

Renombra el tipo y la vista no coincide nunca; renombra una propiedad y la columna queda en blanco. Así que los tipos de modelo de salida son contrato público —el nombre del tipo y los nombres de propiedad— exactamente igual que las propiedades de parámetro. [OutputType(typeof(WidgetInfo))] es un token de metadatos y sigue automáticamente un renombrado, pero entonces Get-Command Get-Widget | Select OutputType mostraría el nombre renombrado, lo cual es una razón más para dejar el tipo en paz.

Qué se renombra con libertad, y la disposición que hace que sea la mayor parte del módulo

Todo lo demás es tuyo. El nombre de la clase del cmdlet, su espacio de nombres, los campos privados como _catalog, los cuerpos de las sustituciones y —lo más importante— el motor al que llama el cmdlet. ProcessRecord, BeginProcessing y EndProcessing son sustituciones de virtuales de la clase base, así que conservan sus ranuras de v-table bajo el renombrado sin ningún tratamiento especial; el motor las invoca a través de la base.

La consecuencia práctica es una regla de disposición: cmdlet fino, motor grueso. No pongas nada en la clase del cmdlet salvo las propiedades de parámetro, las tres sustituciones y una llamada a un espacio de nombres aparte que contenga la lógica real. Entonces la superficie conservada es un puñado de nombres de propiedad por cmdlet más los registros de salida, y el motor —la parte que merece la pena proteger— se renombra, se le cifran las cadenas y se le aplana el flujo de control como a cualquier otra biblioteca:

// Acme.Widgets.Engine — no PowerShell types, no public contract, renames wholesale.
namespace Acme.Widgets.Engine;

internal sealed class WidgetCatalog
{
    internal static WidgetCatalog Open() { /* licensing gate, index load, cache */ }
    internal IEnumerable<Widget> FindByName(string pattern, bool includeHidden) { /* ... */ }
    internal IEnumerable<Widget> FindById(int id) { /* ... */ }
}

Si hoy tienes lógica dentro de la clase del cmdlet, muévela. Un cmdlet cuyo ProcessRecord es el algoritmo te obliga a conservar un tipo que preferirías renombrar, y te tienta a conservar con él todos sus auxiliares privados.

La configuración

Con esa disposición, el perfil de Nebula es breve. Conserva por nombre los espacios de nombres del cmdlet y del modelo (para que sobrevivan sus propiedades públicas), conserva los atributos de PowerShell, y renombra todo lo demás:

{
  "inputs": [ "bin/Release/netstandard2.0/Acme.Widgets.dll" ],
  "outputDirectory": "protected",

  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "Acme.Widgets.Cmdlets.*", "action": "keep"   },
      { "pattern": "Acme.Widgets.Model.*",   "action": "keep"   },
      { "pattern": "Acme.Widgets.Engine.*",  "action": "rename" }
    ],
    "preserveAttributes": [ "System.Management.Automation.*" ]
  },

  "controlFlow": { "exclude": [ "Acme.Widgets.Cmdlets.*" ] }
}

Tres notas para leerlo. preservePublicApi está desactivado a propósito: lo «público» en un ensamblado de módulo es, en su mayor parte, un accidente de la plantilla del SDK, y las dos reglas keep nombran la superficie exacta que PowerShell enlaza. preserveAttributes protege los atributos [Cmdlet], [Parameter], [Alias], [ValidateRange] y [OutputType] frente a cualquier pasada que elimine atributos: perder [Cmdlet] hace que el comando desaparezca sin avisar, perder [Parameter] hace que desaparezca un parámetro, y ambos son horribles de depurar porque el módulo sigue importándose. Excluir las clases de cmdlet del aplanamiento del flujo de control es una decisión de higiene: sus cuerpos tienen tres líneas cada uno, y el enlazador establece propiedades sobre una instancia nueva por invocación, así que no hay ahí nada que justifique el crecimiento del IL.

El cifrado de cadenas es seguro en todas partes, incluso en los nombres de conjunto de parámetros: "ByName" en el atributo es un argumento de atributo y no se toca, y la comparación ParameterSetName == "ById" en ProcessRecord es una cadena de tiempo de ejecución que se reconstruye antes de usarse. Lo mismo vale para los nombres de RuntimeDefinedParameter al estilo dynamicparam si tu módulo construye parámetros en tiempo de ejecución: son cadenas, y las cadenas cifradas se descifran antes de poblar el diccionario.

Verifica importando, no compilando

Todos los modos de fallo de esta entrada son un fallo de reflexión en tiempo de ejecución, así que una compilación en verde no demuestra nada. Importa la DLL protegida y ejercita el contrato en las dos ediciones para las que distribuyes:

Import-Module ./protected/Acme.Widgets.dll -Force

Get-Command -Module Acme.Widgets                     # Get-Widget, Set-Widget, Remove-Widget present?
(Get-Command Get-Widget).Parameters.Keys             # Name, Id, IncludeHidden — not a, b, c
Get-Help Get-Widget -Parameter Name                  # help still finds the parameter

Get-Widget -Name 'foo*'                              # named
Get-Widget 'foo*' -IncludeHidden                     # positional + switch
'foo*' | Get-Widget | Select-Object Name, Size       # pipeline + output property names
[pscustomobject]@{ Name = 'bar' } | Get-Widget       # ValueFromPipelineByPropertyName
Get-Widget -Id 42 | Format-Table                     # other parameter set + .ps1xml view

Luego apunta tu suite de Pester a ./protected en lugar de a bin/Debug y ejecútala tanto en Windows PowerShell 5.1 como en PowerShell 7 si el módulo apunta a ambos: cargan compilaciones de framework distintas, y un ensamblado con varios destinos se ofusca por destino. Si el módulo está firmado, vuelve a firmarlo después de la ofuscación; de lo contrario, una directiva de ejecución AllSigned rechazará la DLL protegida.

La conclusión

El contrato público de un módulo binario no son los nombres de sus clases. PowerShell encuentra un cmdlet por el atributo [Cmdlet] y lo nombra a partir de los argumentos del atributo, así que la clase que lo implementa y todo lo privado a ella se renombran con libertad. El contrato son los nombres de tus propiedades públicas —las propiedades [Parameter] a la entrada, las propiedades del registro de salida a la salida— porque el runtime las enlaza y las lee por nombre mediante reflexión, y los scripts, la ayuda, el autocompletado con tabulador y las vistas .ps1xml dependen todos de esos nombres al pie de la letra. Conserva los espacios de nombres del cmdlet y del modelo, preserva los atributos de System.Management.Automation, empuja la lógica real a un espacio de nombres de motor que se renombra por completo, y demuestra el resultado importando el módulo protegido e invocando cada forma de parámetro en lugar de fiarte de la compilación.

Prueba Nebula.NET

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