Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETAnalyse approfondie

Obfusquer un module binaire PowerShell : le nom du cmdlet vit dans un attribut, mais les noms de paramètres sont les noms de vos propriétés

Un module binaire PowerShell est un assembly .NET ordinaire que PowerShell charge par réflexion, et c'est une chose singulière à obfusquer, car son contrat public ne se trouve pas là où les développeurs C# s'attendent à le voir. Le nom du cmdlet — Get-Widget — provient de l'attribut [Cmdlet], si bien que la classe qui l'implémente peut être renommée en n'importe quoi. Mais les noms de paramètres — -Name, -Force, -Path — sont littéralement les noms de vos propriétés publiques : renommez-les et chaque script, chaque complétion par tabulation et chaque page Get-Help se brise. Il en va de même pour les objets que le cmdlet écrit dans le pipeline, dont les noms de propriétés sont les en-têtes de colonnes de Format-Table et les cibles de Select-Object. Cet article lit exactement ce sur quoi le runtime PowerShell effectue sa réflexion lorsqu'il importe un module et lie les paramètres, trace la frontière entre ce que Nebula peut renommer (la classe du cmdlet, le moteur derrière elle) et ce qui est épinglé (propriétés de paramètres, propriétés de sortie, les attributs), montre la disposition « cmdlet mince, moteur épais » qui réduit la surface épinglée, fournit la configuration, et se termine par la vérification par import-et-invocation qui prouve réellement que le module protégé fonctionne.

Un module binaire PowerShell est un simple assembly .NET. Vous écrivez une classe, vous la décorez de [Cmdlet], vous compilez une DLL, et Import-Module la transforme en une commande qu’un utilisateur peut taper. Cela en fait une cible attrayante à protéger — le moteur derrière un module commercial est du vrai code produit — et une cible un peu traîtresse à obfusquer, car son contrat public ne se trouve pas là où les développeurs C# le cherchent. Le nom du cmdlet est un argument d’attribut et se renomme sans aucun effet. Les noms de paramètres du cmdlet sont les noms de vos propriétés, et les colonnes de votre sortie sont elles aussi les noms de vos propriétés. Renommez-les et le module se charge toujours, « fonctionne » toujours, et chaque script qui l’appelle se brise.

Cet article lit ce sur quoi le runtime PowerShell effectue réellement sa réflexion, trace la frontière pour Nebula, et montre la disposition qui maintient la surface épinglée minuscule.

Le module tel que PowerShell le voit

Voici un cmdlet représentatif, la forme que livrent la plupart des modules :

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);

Et le manifeste qui l’accompagne :

@{
    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')
}

Lorsque Import-Module Acme.Widgets s’exécute, le moteur charge Acme.Widgets.dll, énumère ses types publics, retient ceux qui dérivent de Cmdlet/PSCmdlet et portent [Cmdlet], et enregistre chacun sous le Verbe-Nom stocké dans l’attribut. Remarquez ce qui est absent de cette séquence : le nom de la classe. GetWidgetCommand n’est jamais recherché par qui que ce soit — ni le manifeste, ni le moteur, ni un script. Il est trouvé par attribut et nommé par attribut, pour la même raison qu’un générateur de source Roslyn se renomme librement.

Où réside vraiment le contrat : le lieur de paramètres

L’utilisateur tape maintenant Get-Widget -Name foo, ou 'foo' | Get-Widget, ou Get-Widget foo. Le lieur de paramètres résout les trois formes sur l’instance du cmdlet par réflexion sur les noms de propriétés :

  • -Name foo trouve une propriété publique appelée Name décorée de [Parameter] et la définit.
  • Get-Widget foo trouve le [Parameter] avec Position = 0 — toujours la propriété Name.
  • $obj | Get-Widget avec ValueFromPipelineByPropertyName fait correspondre $obj.Name à la propriété par le nom.

Il n’existe pas d’emplacement de métadonnées distinct pour « le nom du paramètre » ; le nom de propriété CLR est le nom du paramètre. Renommez Name en a et le cmdlet lie désormais -a, rejette -Name avec A parameter cannot be found that matches parameter name ‘Name’, complète -a par tabulation, et Get-Help Get-Widget documente -a. [Alias("n")] survit — c’est une constante chaîne d’attribut — mais un alias est un nom supplémentaire ; le nom principal reste la propriété, et chaque script en circulation utilise le nom principal.

Ce sur quoi le runtime réfléchit quand vous tapez Get-Widget -Name fooImport-Moduletrouve le type par attr [Cmdlet]lieur de paramètrespropriété « Name » par réflexionProcessRecord()substitution de virtuelle de baseWriteObject(WidgetInfo)Format-Table / Select-Objectlit .Name .Size par le nomRenommable sans risque• nom de classe GetWidgetCommand• son espace de noms, champs privés• corps BeginProcessing/ProcessRecord• tout le Acme.Widgets.EngineÉpinglé• chaque nom de propriété [Parameter]• type de sortie + noms de propriétés• [Cmdlet] [Parameter] [Alias] attributs [OutputType]• noms utilisés dans les formats .ps1xmlPourquoiVerbe-Nom est un argumentd'attribut — une constante,jamais renommée ; classe libre.Un paramètre n'a pas de nompropre : le nom de propriété CLREST le -Parameter tapé.Les objets du pipeline sont luspar nom de propriété en aval —colonnes, filtres, $w.Name.Les substitutions gardent leur slot.

Le côté sortie est épinglé lui aussi

WriteObject(new WidgetInfo(...)) envoie un objet dans le pipeline, et chaque consommateur le lit par nom de propriété au moyen de la réflexion à l’exécution. Format-Table utilise les noms comme en-têtes de colonnes ; Select-Object Name, Size, Where-Object { $_.Size -gt 1MB }, Sort-Object Modified et $w.Name se résolvent tous sur l’objet vivant. Un WidgetInfo renommé continue de fonctionner mécaniquement et produit des colonnes appelées a, b, c — et chaque script en aval qui sélectionne ou filtre par les noms documentés n’obtient silencieusement rien, ce qui est pire qu’une erreur.

Le fichier de mise en forme rend la dépendance explicite. Une vue .ps1xml nomme le type par son nom complet et ses propriétés sous forme de chaînes :

<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>

Renommez le type et la vue ne correspond jamais ; renommez une propriété et la colonne est vide. Les types de modèle de sortie sont donc un contrat public — nom de type et noms de propriétés — exactement comme les propriétés de paramètres. [OutputType(typeof(WidgetInfo))] est un jeton de métadonnées et suit un renommage automatiquement, mais Get-Command Get-Widget | Select OutputType afficherait alors le nom renommé, ce qui est une raison de plus de ne pas toucher au type.

Ce qui se renomme librement, et la disposition qui en fait l’essentiel du module

Tout le reste vous appartient. Le nom de classe du cmdlet, son espace de noms, les champs privés comme _catalog, les corps des substitutions, et — surtout — le moteur que le cmdlet appelle. ProcessRecord, BeginProcessing et EndProcessing sont des substitutions de méthodes virtuelles de la classe de base, de sorte qu’ils conservent leurs emplacements de table virtuelle sous le renommage sans traitement particulier ; le moteur les appelle via la base.

La conséquence pratique est une règle de disposition : cmdlet mince, moteur épais. Ne mettez rien dans la classe du cmdlet hormis les propriétés de paramètres, les trois substitutions, et un appel vers un espace de noms distinct qui détient la vraie logique. La surface conservée se réduit alors à une poignée de noms de propriétés par cmdlet plus les enregistrements de sortie, et le moteur — la partie qui mérite d’être protégée — se renomme, chiffre ses chaînes et aplatit son flux de contrôle comme n’importe quelle autre bibliothèque :

// 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 de la logique se trouve aujourd’hui à l’intérieur de la classe du cmdlet, déplacez-la. Un cmdlet dont le ProcessRecord est l’algorithme vous force à conserver un type que vous préféreriez renommer, et vous incite à conserver tous ses assistants privés avec lui.

La configuration

Avec cette disposition, le profil Nebula est court. Conservez les espaces de noms du cmdlet et du modèle par leur nom (afin que leurs propriétés publiques survivent), conservez les attributs PowerShell, et renommez tout le reste :

{
  "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.*" ] }
}

Trois remarques pour le lire. preservePublicApi est désactivé à dessein : « public » dans un assembly de module est surtout un accident du modèle de SDK, et les deux règles keep nomment exactement la surface que PowerShell lie. preserveAttributes protège les attributs [Cmdlet], [Parameter], [Alias], [ValidateRange] et [OutputType] contre toute passe de suppression d’attributs — perdre [Cmdlet] fait disparaître la commande silencieusement, perdre [Parameter] fait disparaître un paramètre, et les deux sont pénibles à déboguer car le module s’importe tout de même. Exclure les classes de cmdlet de l’aplatissement du flux de contrôle est un choix d’hygiène : leurs corps font trois lignes chacun, et le lieur définit les propriétés sur une instance neuve à chaque invocation, si bien qu’il n’y a là rien qui vaille la croissance d’IL.

Le chiffrement des chaînes est sans danger partout, y compris sur les noms de jeux de paramètres — "ByName" dans l’attribut est un argument d’attribut et n’est pas touché, et la comparaison ParameterSetName == "ById" dans ProcessRecord est une chaîne d’exécution reconstruite avant utilisation. Il en va de même pour les noms RuntimeDefinedParameter de style dynamicparam si votre module construit des paramètres à l’exécution : ce sont des chaînes, et les chaînes chiffrées se déchiffrent avant que le dictionnaire ne soit peuplé.

Vérifiez en important, pas en compilant

Chaque mode de défaillance de cet article est un échec de réflexion à l’exécution, donc une compilation réussie ne prouve rien. Importez la DLL protégée et exercez le contrat dans les deux éditions pour lesquelles vous livrez :

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

Pointez ensuite votre suite Pester vers ./protected au lieu de bin/Debug et exécutez-la sous Windows PowerShell 5.1 comme sous PowerShell 7 si le module cible les deux — ils chargent des versions de framework différentes, et un assembly multi-cible s’obfusque par cible. Si le module est signé, resignez-le après l’obfuscation ; sinon, une stratégie d’exécution AllSigned refusera la DLL protégée.

À retenir

Le contrat public d’un module binaire n’est pas ses noms de classes. PowerShell trouve un cmdlet par l’attribut [Cmdlet] et le nomme à partir des arguments de l’attribut, si bien que la classe d’implémentation et tout ce qui lui est privé se renomment librement. Le contrat, ce sont les noms de vos propriétés publiques — les propriétés [Parameter] à l’entrée, les propriétés des enregistrements de sortie à la sortie — car le runtime les lie et les lit par leur nom au moyen de la réflexion, et les scripts, l’aide, la complétion par tabulation et les vues .ps1xml dépendent tous de ces noms mot pour mot. Conservez les espaces de noms du cmdlet et du modèle, préservez les attributs System.Management.Automation, repoussez la vraie logique dans un espace de noms de moteur qui se renomme intégralement, et prouvez le résultat en important le module protégé et en invoquant chaque forme de paramètre plutôt qu’en vous fiant à la compilation.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.