Obfuscating a .NET AWS Lambda Function Without Breaking the Handler String
AWS Lambda's managed .NET runtime finds your code through a handler string of the form Assembly::Namespace.Type::Method, which it resolves by reflection at every cold start. Obfuscate after the build and renaming moves the handler type or method out from under that string — the assembly is valid, but Lambda fails to resolve the entry point and the invocation errors before your code runs. This post explains where the handler string lives, which symbols the managed runtime and the Lambda serializer resolve by name, how to pin exactly those with a Nebula.NET keep-list while renaming everything else, and how to fold nebula protect into dotnet lambda package.
A .NET AWS Lambda function is close to the ideal obfuscation target. It is a small class library — no Main in the managed case, no UI, one method that Lambda calls — that ships to infrastructure you do not administer and runs headless. Almost nothing in it is consumed by name from outside. And yet, like other serverless shapes, it hides one seam where a naive obfuscation pass produces a failure that looks nothing like an obfuscation bug: the package deploys, the assembly loads, and the very first invocation fails with a handler-resolution error, long before any of your code runs. The DLL is valid. Lambda simply cannot find the one method it was told to call.
The reason is specific to how Lambda’s managed .NET runtime locates your entry point, and once you see it the fix is a short keep-list. This post is about that mechanism — where the handler string lives, why renaming the handler breaks resolution and not the code, and how to protect the interior with Nebula.NET without disturbing the names the runtime reads.
Why a Lambda function is worth protecting
A function is business logic with the plumbing deleted. There is no presentation layer and almost no framework glue — just the handler and the thing it does: reprice a basket, score an application, validate a webhook signature, settle a transaction, decide eligibility. That logic is the product, and in a Lambda it is nearly the whole assembly. The function also tends to carry the sensitive constants you would expect of something that integrates with other systems — downstream endpoint URLs, partner API keys, header and queue names, connection-string fragments — frequently as code defaults rather than configuration.
And it runs somewhere you do not control. The deployed artifact is a DLL (or a self-contained executable) that anyone with access to the function — or to a customer’s own AWS account running your function — can pull from the package and open in a decompiler, reading your scoring model or settlement math back as clean C# in seconds. The headlessness that makes a function feel unexposed is exactly what makes it worth reversing, and worth protecting.
How the managed runtime finds your handler
Here is an ordinary Lambda function on the managed runtime:
using Amazon.Lambda.Core;
using Amazon.Lambda.Serialization.SystemTextJson;
// Resolved by TYPE through assembly metadata — renames safely.
[assembly: LambdaSerializer(typeof(DefaultLambdaJsonSerializer))]
namespace Contoso.Orders;
public sealed class OrderFunction
{
private readonly IPricingService _pricing;
public OrderFunction() => _pricing = new PricingService();
// The entry point Lambda resolves from the handler string.
public QuoteResponse FunctionHandler(OrderRequest input, ILambdaContext context)
{
context.Logger.LogInformation($"Repricing order {input.OrderId}");
return _pricing.Reprice(input); // proprietary — the reason this exists
}
}
public sealed class OrderRequest
{
public string OrderId { get; set; } = "";
public decimal Subtotal { get; set; }
public string CurrencyCode { get; set; } = "USD";
}
public sealed class QuoteResponse
{
public string OrderId { get; set; } = "";
public decimal Total { get; set; }
}
Lambda does not scan this assembly for an attribute to decide what to call. Instead, the function is configured with a handler string that names the entry point exactly:
OrderApi::Contoso.Orders.OrderFunction::FunctionHandler
The three parts, separated by ::, are the assembly name, the fully qualified type name, and the method name. That string is not stored in the assembly — it lives in the function configuration. In a project built with the Amazon.Lambda.Tools CLI it is written in aws-lambda-tools-defaults.json:
{
"profile": "default",
"region": "us-east-1",
"configuration": "Release",
"function-runtime": "dotnet8",
"function-handler": "OrderApi::Contoso.Orders.OrderFunction::FunctionHandler"
}
and in a SAM or CDK template it is the Handler property:
Resources:
OrderFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: dotnet8
Handler: OrderApi::Contoso.Orders.OrderFunction::FunctionHandler
CodeUri: ./publish
At every cold start the managed runtime reads that string and resolves the method by reflection, effectively:
var assembly = Assembly.Load("OrderApi");
var type = assembly.GetType("Contoso.Orders.OrderFunction"); // from the handler string
var method = type.GetMethod("FunctionHandler"); // name from the handler string
That is the whole trap. The handler string is fixed at deploy time and points at Contoso.Orders.OrderFunction.FunctionHandler. Obfuscation runs after compilation, on the DLL, and if renaming turns OrderFunction into a and FunctionHandler into b, the configured string is untouched — it still names the original — but the type and method it names no longer exist. GetType returns null, resolution fails, and the invocation errors with a handler-resolution failure. Nothing is wrong with the assembly; the name the runtime was told to find simply isn’t there anymore.
The audit: what renames, what doesn’t
Walk every name the managed runtime and the serializer touch, and the keep-list writes itself.
- The handler type and method —
Contoso.Orders.OrderFunction.FunctionHandler. Resolved by the configured handler string through reflection at cold start. Keep both the type and the method. This is the one seam unique to Lambda, and the whole reason naive obfuscation fails. - The assembly name —
OrderApi, the first segment of the handler string. This is the output assembly name, not a renameable symbol; obfuscation does not change it and you should not either, since the handler string names it. - The Lambda serializer type —
DefaultLambdaJsonSerializernamed in[assembly: LambdaSerializer(typeof(...))]. Resolved by type identity through assembly metadata, not by name. Rename-safe — but see the POCOs below. - Input and output POCOs —
OrderRequest,QuoteResponse. The serializer binds JSON by property name, and those names are the contract on the event payload and the response. Keep the property names, or pin each with[JsonPropertyName("...")]and let the member rename underneath. The type names themselves can rename; it is the properties that are the wire contract. - The constructor and DI — resolved by the runtime constructing your handler type, and any services resolved by type. Renaming preserves type identity. Rename freely.
- Private helpers, domain services, the algorithm —
IPricingService,PricingService.Reprice, everything the handler calls into. Nothing outside the process names these. Rename, encrypt, virtualize.
Two seams to keep, then — the handler type-and-method, and the POCO property names — and everything else is fair game.
The keep-list
Model the handler types by a naming convention so one rule covers them, and nebula.config.json reads:
{
"schemaVersion": 1,
"inputs": [
"publish/OrderApi.dll",
"publish/Contoso.Pricing.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Orders.*Function", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Orders.Contracts.*", "action": "keep", "scope": "members" },
{ "pattern": "Contoso.Orders.*", "action": "rename" },
{ "pattern": "Contoso.Pricing.*", "action": "rename" }
]
}
}
The first rule pins every *Function type and its members, so the handler method keeps the name the handler string expects; scope: "type-and-members" is what preserves FunctionHandler, not just the class. The second keeps the members of the DTO types — the properties that are the JSON contract — while allowing the type names themselves to rename. Everything else in both assemblies is renamed.
If you would rather not rely on a convention, annotate the handler directly — the attribute travels with the code and cannot drift out of sync with a pattern:
[Obfuscation(Exclude = true, ApplyToMembers = false)] // keep this method's name for the handler string
public QuoteResponse FunctionHandler(OrderRequest input, ILambdaContext context) { /* ... */ }
A note on the custom runtime
If you ship a custom runtime (provided.al2023) instead of the managed dotnet8 runtime, the wiring is different. You register the handler as a delegate in Main, so there is no handler string resolved by reflection — the configured handler is just bootstrap, the executable name:
// Program.cs — custom runtime entry point
var handler = (OrderRequest input, ILambdaContext ctx) => new OrderFunction().FunctionHandler(input, ctx);
await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer())
.Build()
.RunAsync();
That removes the rename trap on the handler method — the delegate binds it by reference, not by name — but it moves the keep requirement to the assembly entry point: Main must survive (Nebula preserves the entry point by default), and the serializer POCO property names are still a contract to preserve. The managed runtime trades Main for a configured string; the custom runtime trades the string for a kept entry point.
Protecting the interior
Keeping a method’s name does not mean leaving its body readable. The handler stays resolvable while everything it does becomes opaque.
String encryption for the constants. Endpoint URLs, API keys, header names and connection fragments are what an attacker greps for first; string encryption stores them encrypted and decrypts at the point of use, so a search of the shipped binary returns nothing. It is not a vault — the value is live in memory at runtime — but it removes the zero-effort attack.
The strongest transforms on the crown jewels. PricingService.Reprice and whatever scoring, settlement or eligibility logic the function wraps are where you reach for code virtualization and whole-method encryption — both Pro features in Nebula.NET — applied to that handful of methods rather than the whole assembly. Virtualization rewrites a method into bytecode for an embedded VM so there is no IL left to decompile; method encryption stores the original IL encrypted and reconstructs it at runtime. You can even virtualize the body of the handler itself while its name is kept:
public sealed class PricingService : IPricingService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public QuoteResponse Reprice(OrderRequest order) { /* ... */ }
}
Renaming and control-flow obfuscation run broadly underneath both, turning the rest of the interior into meaningless symbols and tangled branches.
Folding protection into the package
Protect the published output, and only on the build server so local debugging stays fast and readable. The Amazon.Lambda.Tools CLI publishes and zips in one step with dotnet lambda package; run Nebula against that published output before the zip is built, or protect and re-package:
dotnet publish OrderApi -c Release -o publish
nebula protect --config nebula.config.json
Hook it after publish in CI so the deployed artifact is always the protected one:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
The protected protected/ folder is what your packaging step should zip and ship — point dotnet lambda package --package-type zip (or your SAM CodeUri / CDK asset) at the protected output rather than the raw publish folder. nebula protect returns a non-zero exit code on failure, so a broken protection step fails the build instead of shipping an unprotected DLL.
Verifying it actually works
Unit tests will not catch a handler-resolution failure — they reference the project and call FunctionHandler directly, never going through the configured string against the protected DLL. Two checks will. First, invoke the protected package end to end: deploy it to a test function (or run it under the .NET Lambda test tool / sam local invoke against the protected output) with a known event, and confirm the handler resolves and returns the expected quote. A function that deploys cleanly but errors on the first invocation with a handler-resolution message is the exact signature of a missed keep rule, or a handler string that drifted from the kept names.
Second, open the shipped assembly in a decompiler and read it the way an attacker will: the handler type and method names are present (they must be, to match the string), and the POCO property names are present (they are a contract), but the handler body, the pricing service, the internal helpers and the integration strings are gone or unreadable. Glass.NET is the free, static-only companion for that pass — load the protected DLL, confirm Contoso.Orders.OrderFunction.FunctionHandler is still there by name, then confirm there is nothing left to read inside it.
The takeaway
Lambda’s managed .NET runtime is unusual in that it finds your code through a handler string — Assembly::Namespace.Type::Method — that lives in the function configuration, not the assembly, and resolves it by reflection at every cold start. Rename the handler type or method and you move it out from under the string that names it; the runtime, reading the unchanged string, fails to resolve the entry point before your code runs. Keep exactly two things: the handler type and method, so their names still match the configured string, and the POCO property names that are a serialization contract on the event and response. Everything else — the pricing, the scoring, the secrets, the whole interior that is the only reason the function exists — hands to Nebula.NET to rename, string-encrypt and virtualize. Then invoke the protected package, watch the handler resolve and return, and read the shipped DLL to confirm the logic behind it is gone.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.