Ein binäres PowerShell-Modul obfuskieren: Der Cmdlet-Name lebt in einem Attribut, aber die Parameternamen sind Ihre Eigenschaftsnamen
Ein binäres PowerShell-Modul ist eine gewöhnliche .NET-Assembly, die PowerShell per Reflection lädt, und es ist etwas Eigentümliches zu obfuskieren, weil sein öffentlicher Vertrag nicht dort liegt, wo C#-Entwickler ihn erwarten. Der Cmdlet-Name – Get-Widget – stammt aus dem [Cmdlet]-Attribut, sodass die Klasse, die ihn implementiert, in alles Mögliche umbenannt werden kann. Aber die Parameternamen – -Name, -Force, -Path – sind buchstäblich die Namen Ihrer öffentlichen Eigenschaften: Benennen Sie sie um, und jedes Skript, jede Tab-Vervollständigung und jede Get-Help-Seite bricht. Dasselbe gilt für die Objekte, die das Cmdlet in die Pipeline schreibt, deren Eigenschaftsnamen die Spaltenüberschriften von Format-Table und die Ziele von Select-Object sind. Dieser Beitrag liest genau, worauf die PowerShell-Laufzeit per Reflection zugreift, wenn sie ein Modul importiert und Parameter bindet, zieht die Linie zwischen dem, was Nebula umbenennen darf (die Cmdlet-Klasse, die Engine dahinter), und dem, was festgenagelt ist (Parameter-Eigenschaften, Ausgabe-Eigenschaften, die Attribute), zeigt das Layout „schlankes Cmdlet, fette Engine“, das die festgenagelte Oberfläche klein hält, liefert die Konfiguration und endet mit der Importieren-und-Aufrufen-Prüfung, die tatsächlich beweist, dass das geschützte Modul funktioniert.
Ein binäres PowerShell-Modul ist eine schlichte .NET-Assembly. Sie schreiben eine Klasse, dekorieren sie mit [Cmdlet], bauen eine DLL, und Import-Module verwandelt sie in einen Befehl, den ein Benutzer tippen kann. Das macht es zu einer attraktiven Sache zum Schützen – die Engine hinter einem kommerziellen Modul ist echter Produktcode – und zu einer leicht tückischen Sache zum Obfuskieren, weil sein öffentlicher Vertrag nicht an der Stelle liegt, an der C#-Entwickler ihn suchen. Der Cmdlet-Name ist ein Attributargument und benennt sich ohne Wirkung weg. Die Parameternamen des Cmdlets sind Ihre Eigenschaftsnamen, und die Spalten in Ihrer Ausgabe sind ebenfalls Ihre Eigenschaftsnamen. Benennen Sie diese um, und das Modul lädt weiterhin, „funktioniert“ weiterhin, und jedes Skript, das es aufruft, bricht.
Dieser Beitrag liest, worauf die PowerShell-Laufzeit tatsächlich per Reflection zugreift, zieht die Linie für Nebula und zeigt das Layout, das die festgenagelte Oberfläche winzig hält.
Das Modul, wie PowerShell es sieht
Hier ist ein repräsentatives Cmdlet, die Form, die die meisten Module ausliefern:
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);
Und das Manifest, das dazugehört:
@{
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')
}
Wenn Import-Module Acme.Widgets läuft, lädt die Engine Acme.Widgets.dll, zählt ihre öffentlichen Typen auf, behält diejenigen, die von Cmdlet/PSCmdlet abgeleitet sind und [Cmdlet] tragen, und registriert jeden unter dem Verb-Noun, das im Attribut gespeichert ist. Beachten Sie, was in dieser Abfolge fehlt: der Klassenname. GetWidgetCommand wird von niemandem nachgeschlagen – nicht vom Manifest, nicht von der Engine, nicht von einem Skript. Es wird per Attribut gefunden und per Attribut benannt, was derselbe Grund ist, aus dem ein Roslyn-Source-Generator frei umbenennt.
Wo der Vertrag wirklich lebt: der Parameter-Binder
Nun tippt der Benutzer Get-Widget -Name foo, oder 'foo' | Get-Widget, oder Get-Widget foo. Der Parameter-Binder löst alle drei gegen die Cmdlet-Instanz auf, per Reflection auf Eigenschaftsnamen:
-Name foofindet eine öffentliche Eigenschaft namensName, dekoriert mit[Parameter], und setzt sie.Get-Widget foofindet den[Parameter]mitPosition = 0– weiterhin dieName-Eigenschaft.$obj | Get-WidgetmitValueFromPipelineByPropertyNamematcht$obj.Namegegen die Eigenschaft per Name.
Es gibt keinen separaten Metadaten-Slot für „den Parameternamen“; der CLR-Eigenschaftsname ist der Parametername. Benennen Sie Name in a um, und das Cmdlet bindet nun -a, lehnt -Name mit A parameter cannot be found that matches parameter name ‘Name’ ab, vervollständigt -a per Tab, und Get-Help Get-Widget dokumentiert -a. [Alias("n")] überlebt – es ist eine Attribut-String-Konstante –, aber ein Alias ist ein zusätzlicher Name; der primäre Name ist weiterhin die Eigenschaft, und jedes Skript im Feld verwendet den primären Namen.
Auch die Ausgabeseite ist festgenagelt
WriteObject(new WidgetInfo(...)) sendet ein Objekt die Pipeline hinab, und jeder Konsument liest es zur Laufzeit per Eigenschaftsnamen über Reflection. Format-Table verwendet die Namen als Spaltenüberschriften; Select-Object Name, Size, Where-Object { $_.Size -gt 1MB }, Sort-Object Modified und $w.Name lösen allesamt gegen das lebende Objekt auf. Ein umbenanntes WidgetInfo funktioniert mechanisch weiter und erzeugt Spalten namens a, b, c – und jedes nachgelagerte Skript, das nach den dokumentierten Namen auswählt oder filtert, bekommt stillschweigend nichts, was schlimmer ist als ein Fehler.
Die Formatierungsdatei macht die Abhängigkeit explizit. Eine .ps1xml-Ansicht benennt den Typ mit vollständigem Namen und seine Eigenschaften als Strings:
<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>
Benennen Sie den Typ um, und die Ansicht matcht nie; benennen Sie eine Eigenschaft um, und die Spalte ist leer. Ausgabemodell-Typen sind also öffentlicher Vertrag – Typname und Eigenschaftsnamen – genau wie die Parameter-Eigenschaften. [OutputType(typeof(WidgetInfo))] ist ein Metadaten-Token und folgt einer Umbenennung automatisch, aber Get-Command Get-Widget | Select OutputType würde dann den umbenannten Namen anzeigen, was ein weiterer Grund ist, den Typ in Ruhe zu lassen.
Was frei umbenennt, und das Layout, das es zum Großteil des Moduls macht
Alles andere gehört Ihnen. Der Cmdlet-Klassenname, sein Namespace, private Felder wie _catalog, die Rümpfe der Overrides und – am wichtigsten – die Engine, in die das Cmdlet hineinruft. ProcessRecord, BeginProcessing und EndProcessing sind Overrides von Basisklassen-Virtuals, sie behalten also ihre V-Table-Slots unter Umbenennung ohne jede Sonderbehandlung; die Engine ruft sie über die Basis auf.
Die praktische Konsequenz ist eine Layout-Regel: schlankes Cmdlet, fette Engine. Legen Sie in die Cmdlet-Klasse nichts außer Parameter-Eigenschaften, die drei Overrides und einen Aufruf in einen separaten Namespace, der die eigentliche Logik hält. Dann ist die beibehaltene Oberfläche eine Handvoll Eigenschaftsnamen pro Cmdlet plus die Ausgabe-Records, und die Engine – der Teil, der des Schutzes wert ist – benennt um, verschlüsselt Strings und flacht den Kontrollfluss ab wie jede andere Bibliothek:
// 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) { /* ... */ }
}
Wenn bei Ihnen heute Logik innerhalb der Cmdlet-Klasse sitzt, verschieben Sie sie. Ein Cmdlet, dessen ProcessRecord der Algorithmus ist, zwingt Sie, einen Typ zu behalten, den Sie lieber umbenennen würden, und verleitet Sie dazu, alle seine privaten Helfer gleich mit zu behalten.
Die Konfiguration
Mit diesem Layout ist das Nebula-Profil kurz. Behalten Sie die Cmdlet- und Modell-Namespaces namentlich bei (damit ihre öffentlichen Eigenschaften überleben), behalten Sie die PowerShell-Attribute bei und benennen Sie alles andere um:
{
"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.*" ] }
}
Drei Hinweise zum Lesen. preservePublicApi ist bewusst aus: „öffentlich“ in einer Modul-Assembly ist meist ein Zufall der SDK-Vorlage, und die beiden keep-Regeln benennen genau die Oberfläche, die PowerShell bindet. preserveAttributes schützt die Attribute [Cmdlet], [Parameter], [Alias], [ValidateRange] und [OutputType] vor jedem attribut-strippenden Durchlauf – der Verlust von [Cmdlet] lässt den Befehl stillschweigend verschwinden, der Verlust von [Parameter] lässt einen Parameter verschwinden, und beides ist elend zu debuggen, weil das Modul trotzdem importiert. Die Cmdlet-Klassen von der Kontrollfluss-Abflachung auszuschließen, ist eine Hygiene-Entscheidung: Ihre Rümpfe sind jeweils drei Zeilen, und der Binder setzt Eigenschaften auf einer frischen Instanz pro Aufruf, es gibt dort also nichts, was das IL-Wachstum wert wäre.
String-Verschlüsselung ist durchweg sicher, auch bei Parameter-Set-Namen – "ByName" im Attribut ist ein Attributargument und wird nicht angerührt, und der Vergleich ParameterSetName == "ById" in ProcessRecord ist ein Laufzeit-String, der vor der Verwendung rekonstruiert wird. Dasselbe gilt für RuntimeDefinedParameter-Namen im dynamicparam-Stil, falls Ihr Modul Parameter zur Laufzeit baut: Sie sind Strings, und verschlüsselte Strings entschlüsseln, bevor das Dictionary befüllt wird.
Verifizieren durch Importieren, nicht durch Bauen
Jeder Fehlermodus in diesem Beitrag ist ein Reflection-Fehlschlag zur Laufzeit, ein grüner Build beweist also nichts. Importieren Sie die geschützte DLL und üben Sie den Vertrag in beiden Editionen aus, für die Sie ausliefern:
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
Richten Sie dann Ihre Pester-Suite auf ./protected statt auf bin/Debug und führen Sie sie unter Windows PowerShell 5.1 ebenso wie unter PowerShell 7 aus, wenn das Modul auf beide zielt – sie laden unterschiedliche Framework-Builds, und eine mehrfach-gezielte Assembly obfuskiert pro Ziel. Ist das Modul signiert, signieren Sie nach der Obfuskierung neu; eine Ausführungsrichtlinie von AllSigned würde die geschützte DLL andernfalls verweigern.
Das Fazit
Der öffentliche Vertrag eines binären Moduls sind nicht seine Klassennamen. PowerShell findet ein Cmdlet über das [Cmdlet]-Attribut und benennt es aus den Argumenten des Attributs, sodass die implementierende Klasse und alles ihr Private frei umbenennen. Der Vertrag sind die Namen Ihrer öffentlichen Eigenschaften – [Parameter]-Eigenschaften auf dem Weg hinein, Ausgabe-Record-Eigenschaften auf dem Weg hinaus –, weil die Laufzeit sie per Namen über Reflection bindet und liest, und Skripte, Hilfe, Tab-Vervollständigung und .ps1xml-Ansichten allesamt wortwörtlich von diesen Namen abhängen. Behalten Sie die Cmdlet- und Modell-Namespaces bei, bewahren Sie die System.Management.Automation-Attribute, schieben Sie die eigentliche Logik in einen Engine-Namespace, der vollständig umbenennt, und beweisen Sie das Ergebnis, indem Sie das geschützte Modul importieren und jede Parameterform aufrufen, statt dem Build zu vertrauen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.