Obfuscating a PowerShell binary module: the cmdlet name lives in an attribute, but the parameter names are your property names
A PowerShell binary module is an ordinary .NET assembly that PowerShell loads by reflection, and it is a peculiar thing to obfuscate because its public contract is not where C# developers expect it. The cmdlet name — Get-Widget — comes from the [Cmdlet] attribute, so the class that implements it can be renamed to anything. But the parameter names — -Name, -Force, -Path — are literally the names of your public properties: rename them and every script, every tab completion and every Get-Help page breaks. The same is true of the objects the cmdlet writes to the pipeline, whose property names are the column headers of Format-Table and the targets of Select-Object. This post reads exactly what the PowerShell runtime reflects on when it imports a module and binds parameters, draws the line between what Nebula can rename (the cmdlet class, the engine behind it) and what is pinned (parameter properties, output properties, the attributes), shows the "thin cmdlet, fat engine" layout that makes the pinned surface small, gives the config, and ends with the import-and-invoke check that actually proves the protected module works.
A PowerShell binary module is a plain .NET assembly. You write a class, decorate it with [Cmdlet], build a DLL, and Import-Module turns it into a command a user can type. That makes it an attractive thing to protect — the engine behind a commercial module is real product code — and a slightly treacherous thing to obfuscate, because its public contract is not in the place C# developers look for one. The cmdlet name is an attribute argument and renames away with no effect. The cmdlet’s parameter names are your property names, and the columns in your output are your property names too. Rename those and the module still loads, still “works,” and every script that calls it breaks.
This post reads what the PowerShell runtime actually reflects on, draws the line for Nebula, and shows the layout that keeps the pinned surface tiny.
The module as PowerShell sees it
Here is a representative cmdlet, the shape most modules ship:
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);
And the manifest that goes with it:
@{
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')
}
When Import-Module Acme.Widgets runs, the engine loads Acme.Widgets.dll, enumerates its public types, keeps those that derive from Cmdlet/PSCmdlet and carry [Cmdlet], and registers each under the Verb-Noun stored in the attribute. Note what is absent from that sequence: the class name. GetWidgetCommand is never looked up by anyone — not the manifest, not the engine, not a script. It is found by attribute and named by attribute, which is the same reason a Roslyn source generator renames freely.
Where the contract really lives: the parameter binder
Now the user types Get-Widget -Name foo, or 'foo' | Get-Widget, or Get-Widget foo. The parameter binder resolves all three against the cmdlet instance by reflection on property names:
-Name foofinds a public property calledNamedecorated with[Parameter]and sets it.Get-Widget foofinds the[Parameter]withPosition = 0— still theNameproperty.$obj | Get-WidgetwithValueFromPipelineByPropertyNamematches$obj.Nameto the property by name.
There is no separate metadata slot for “the parameter name”; the CLR property name is the parameter name. Rename Name to a and the cmdlet now binds -a, rejects -Name with A parameter cannot be found that matches parameter name ‘Name’, tab-completes -a, and Get-Help Get-Widget documents -a. [Alias("n")] survives — it is an attribute string constant — but an alias is an extra name; the primary name is still the property, and every script in the field uses the primary name.
The output side is pinned too
WriteObject(new WidgetInfo(...)) sends an object down the pipeline, and every consumer reads it by property name through reflection at run time. Format-Table uses the names as column headers; Select-Object Name, Size, Where-Object { $_.Size -gt 1MB }, Sort-Object Modified and $w.Name all resolve against the live object. A renamed WidgetInfo keeps working mechanically and produces columns called a, b, c — and every downstream script that selects or filters by the documented names silently gets nothing, which is worse than an error.
The formatting file makes the dependency explicit. A .ps1xml view names the type by full name and its properties as 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>
Rename the type and the view never matches; rename a property and the column is blank. So output model types are public contract — type name and property names — exactly like the parameter properties. [OutputType(typeof(WidgetInfo))] is a metadata token and follows a rename automatically, but Get-Command Get-Widget | Select OutputType would then display the renamed name, which is one more reason to leave the type alone.
What renames freely, and the layout that makes it most of the module
Everything else is yours. The cmdlet class name, its namespace, private fields like _catalog, the override bodies, and — most importantly — the engine the cmdlet calls into. ProcessRecord, BeginProcessing and EndProcessing are overrides of base-class virtuals, so they keep their v-table slots under renaming without any special handling; the engine calls them through the base.
The practical consequence is a layout rule: thin cmdlet, fat engine. Put nothing in the cmdlet class except parameter properties, the three overrides, and a call into a separate namespace that holds the real logic. Then the kept surface is a handful of property names per cmdlet plus the output records, and the engine — the part worth protecting — renames, string-encrypts and control-flow-flattens like any other library:
// 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) { /* ... */ }
}
If you have logic sitting inside the cmdlet class today, move it. A cmdlet whose ProcessRecord is the algorithm forces you to keep a type you would rather rename, and tempts you to keep all of its private helpers along with it.
The config
With that layout the Nebula profile is short. Keep the cmdlet and model namespaces by name (so their public properties survive), keep the PowerShell attributes, and rename everything else:
{
"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.*" ] }
}
Three notes on reading it. preservePublicApi is off deliberately: “public” in a module assembly is mostly an accident of the SDK template, and the two keep rules name the exact surface PowerShell binds. preserveAttributes guards the [Cmdlet], [Parameter], [Alias], [ValidateRange] and [OutputType] attributes against any attribute-stripping pass — losing [Cmdlet] makes the command vanish silently, losing [Parameter] makes a parameter vanish, and both are miserable to debug because the module still imports. Excluding the cmdlet classes from control-flow flattening is a hygiene choice: their bodies are three lines each, and the binder sets properties on a fresh instance per invocation, so there is nothing there worth the IL growth.
String encryption is safe throughout, including on parameter set names — "ByName" in the attribute is an attribute argument and is not touched, and the ParameterSetName == "ById" comparison in ProcessRecord is a runtime string that is reconstructed before use. The same holds for dynamicparam-style RuntimeDefinedParameter names if your module builds parameters at run time: they are strings, and encrypted strings decrypt before the dictionary is populated.
Verify by importing, not by building
Every failure mode in this post is a run-time reflection miss, so a green build proves nothing. Import the protected DLL and exercise the contract in both editions you ship for:
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
Then point your Pester suite at ./protected instead of bin/Debug and run it under Windows PowerShell 5.1 as well as PowerShell 7 if the module targets both — they load different framework builds, and a multi-targeted assembly obfuscates per target. If the module is signed, re-sign after obfuscation; an execution policy of AllSigned will refuse the protected DLL otherwise.
The takeaway
A binary module’s public contract is not its class names. PowerShell finds a cmdlet by the [Cmdlet] attribute and names it from the attribute’s arguments, so the implementing class and everything private to it rename freely. The contract is the names of your public properties — [Parameter] properties on the way in, output record properties on the way out — because the runtime binds and reads them by name through reflection, and scripts, help, tab completion and .ps1xml views all depend on those names verbatim. Keep the cmdlet and model namespaces, preserve the System.Management.Automation attributes, push the real logic into an engine namespace that renames wholesale, and prove the result by importing the protected module and invoking every parameter shape rather than by trusting the build.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.