Skip to content
← Alle Beiträge
· Delta1 Labs ObfuscationNebula

Eine .NET-AWS-Lambda-Funktion obfuskieren, ohne den Handler-String zu zerstören

Die verwaltete .NET-Laufzeit von AWS Lambda findet Ihren Code über einen Handler-String der Form Assembly::Namespace.Type::Method, den sie bei jedem Kaltstart per Reflection auflöst. Obfuskieren Sie nach dem Build, verschiebt die Umbenennung den Handler-Typ oder die Methode weg von unter diesem String: Die Assembly ist gültig, aber Lambda kann den Einstiegspunkt nicht auflösen, und die Invocation schlägt fehl, bevor Ihr Code läuft. Dieser Beitrag erklärt, wo der Handler-String lebt, welche Symbole die verwaltete Laufzeit und der Lambda-Serializer über den Namen auflösen, wie Sie genau diese mit einer Nebula.NET-Keep-List fixieren und alles andere umbenennen, und wie Sie nebula protect in dotnet lambda package einbinden.

Eine .NET-AWS-Lambda-Funktion kommt dem idealen Obfuskationsziel sehr nahe. Sie ist eine kleine Klassenbibliothek — ohne Main im verwalteten Fall, ohne Benutzeroberfläche, eine einzige Methode, die Lambda aufruft — die auf eine Infrastruktur ausgeliefert wird, die Sie nicht administrieren, und ohne Oberfläche läuft. Nahezu nichts darin wird von außen über den Namen konsumiert. Und doch verbirgt sie, wie andere Serverless-Formen, eine Naht, an der ein naiver Obfuskationsdurchlauf ein Versagen erzeugt, das überhaupt nicht wie ein Obfuskationsfehler aussieht: Das Paket wird deployt, die Assembly lädt, und die allererste Invocation scheitert mit einem Handler-Auflösungsfehler, lange bevor irgendetwas von Ihrem Code läuft. Das DLL ist gültig. Lambda findet schlicht die eine Methode nicht, die es aufrufen soll.

Der Grund liegt speziell darin, wie die verwaltete .NET-Laufzeit von Lambda Ihren Einstiegspunkt lokalisiert, und sobald Sie ihn sehen, ist die Lösung eine kurze Keep-List. In diesem Beitrag geht es um diesen Mechanismus: wo der Handler-String lebt, warum das Umbenennen des Handlers die Auflösung bricht und nicht den Code, und wie Sie das Innere mit Nebula.NET schützen, ohne die Namen zu stören, die die Laufzeit liest.

Warum sich eine Lambda-Funktion zu schützen lohnt

Eine Funktion ist Geschäftslogik, der man die Installation entfernt hat. Es gibt keine Präsentationsschicht und kaum Framework-Klebstoff: nur den Handler und das, was er tut — einen Warenkorb neu bepreisen, einen Antrag bewerten, eine Webhook-Signatur validieren, eine Transaktion abwickeln, eine Berechtigung entscheiden. Diese Logik ist das Produkt, und in einer Lambda ist sie fast die gesamte Assembly. Die Funktion trägt außerdem die sensiblen Konstanten, die man von etwas erwartet, das sich mit anderen Systemen integriert — URLs interner Endpunkte, API-Schlüssel von Partnern, Header- und Queue-Namen, Fragmente von Verbindungszeichenfolgen — häufig als Code-Standardwerte statt als Konfiguration.

Und sie läuft irgendwo, das Sie nicht kontrollieren. Das deployte Artefakt ist ein DLL (oder eine eigenständige ausführbare Datei), das jeder mit Zugriff auf die Funktion — oder auf das eigene AWS-Konto eines Kunden, das Ihre Funktion ausführt — aus dem Paket ziehen und in einem Decompiler öffnen kann, um Ihr Scoring-Modell oder Ihre Abrechnungsmathematik in Sekunden als sauberes C# zu lesen. Genau die Oberflächenlosigkeit, die eine Funktion ungeschützt erscheinen lässt, macht sie lohnend zum Rückwärtsentwickeln — und lohnend zum Schützen.

Wie die verwaltete Laufzeit Ihren Handler findet

Hier ist eine gewöhnliche Lambda-Funktion auf der verwalteten Laufzeit:

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 scannt diese Assembly nicht nach einem Attribut, um zu entscheiden, was aufgerufen wird. Stattdessen wird die Funktion mit einem Handler-String konfiguriert, der den Einstiegspunkt exakt benennt:

OrderApi::Contoso.Orders.OrderFunction::FunctionHandler

Die drei durch :: getrennten Teile sind der Assembly-Name, der vollqualifizierte Typname und der Methodenname. Dieser String ist nicht in der Assembly gespeichert: Er lebt in der Funktionskonfiguration. In einem mit der Amazon.Lambda.Tools-CLI erstellten Projekt wird er in aws-lambda-tools-defaults.json geschrieben:

{
  "profile": "default",
  "region": "us-east-1",
  "configuration": "Release",
  "function-runtime": "dotnet8",
  "function-handler": "OrderApi::Contoso.Orders.OrderFunction::FunctionHandler"
}

und in einem SAM- oder CDK-Template ist er die Handler-Eigenschaft:

Resources:
  OrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: dotnet8
      Handler: OrderApi::Contoso.Orders.OrderFunction::FunctionHandler
      CodeUri: ./publish

Bei jedem Kaltstart liest die verwaltete Laufzeit diesen String und löst die Methode per Reflection auf, effektiv:

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

Das ist die ganze Falle. Der Handler-String wird zum Deploy-Zeitpunkt fixiert und zeigt auf Contoso.Orders.OrderFunction.FunctionHandler. Die Obfuskation läuft nach der Kompilierung, auf dem DLL, und benennt die Umbenennung OrderFunction in a und FunctionHandler in b um, bleibt der konfigurierte String unberührt — er benennt immer noch das Original — aber der Typ und die Methode, die er benennt, existieren nicht mehr. GetType gibt null zurück, die Auflösung scheitert, und die Invocation endet mit einem Handler-Auflösungsfehler. An der Assembly ist nichts falsch; der Name, den die Laufzeit finden sollte, ist einfach nicht mehr da.

handler string (function config · not in the assembly)OrderApi :: Contoso.Orders.OrderFunction :: FunctionHandlerassemblytype — keepmethod — keepcold start — resolve by reflectionAssembly.Load("OrderApi") · GetType("...OrderFunction") · GetMethod("FunctionHandler")renamed to a / b → GetType returns null → handler-resolution error, before your code runskeep-list restores the matchkeep OrderFunction + FunctionHandlernames survive · resolution succeeds+ keep POCO property names (JSON)rename body · services · helpersencrypt strings · virtualize thealgorithm — the reason it exists

Die Bestandsaufnahme: was umbenannt wird, was nicht

Gehen Sie jeden Namen durch, den die verwaltete Laufzeit und der Serializer berühren, und die Keep-List schreibt sich von selbst.

  • Der Handler-Typ und die Methode — Contoso.Orders.OrderFunction.FunctionHandler. Werden per Reflection beim Kaltstart über den konfigurierten Handler-String aufgelöst. Behalten Sie sowohl den Typ als auch die Methode. Dies ist die einzige Naht, die Lambda eigen ist, und der ganze Grund, warum naive Obfuskation scheitert.
  • Der Assembly-Name — OrderApi, das erste Segment des Handler-Strings. Dies ist der Name der Ausgabe-Assembly, kein umbenennbares Symbol; die Obfuskation ändert ihn nicht, und Sie sollten es auch nicht, da der Handler-String ihn benennt.
  • Der Lambda-Serializer-Typ — DefaultLambdaJsonSerializer, benannt in [assembly: LambdaSerializer(typeof(...))]. Wird über die Typidentität anhand der Assembly-Metadaten aufgelöst, nicht über den Namen. Gefahrlos umbenennbar — aber beachten Sie die POCOs unten.
  • Ein- und Ausgabe-POCOs — OrderRequest, QuoteResponse. Der Serializer bindet JSON über den Eigenschaftsnamen, und diese Namen sind der Vertrag der Event-Nutzlast und der Antwort. Behalten Sie die Eigenschaftsnamen bei, oder fixieren Sie jeden mit [JsonPropertyName("...")] und lassen Sie das Mitglied darunter umbenennen. Die Typnamen selbst können umbenannt werden; es sind die Eigenschaften, die der Nachrichtenvertrag sind.
  • Der Konstruktor und die DI — werden aufgelöst, wenn die Laufzeit Ihren Handler-Typ konstruiert, und jeder über Typ aufgelöste Dienst. Die Umbenennung bewahrt die Typidentität. Frei umbenennen.
  • Private Helfer, Domänendienste, der Algorithmus — IPricingService, PricingService.Reprice, alles, was der Handler aufruft. Nichts außerhalb des Prozesses benennt dies. Umbenennen, verschlüsseln, virtualisieren.

Zwei zu behaltende Nähte also — der Typ-und-Methode des Handlers und die Eigenschaftsnamen der POCOs — und alles andere ist Freiwild.

Die Keep-List

Modellieren Sie die Handler-Typen über eine Namenskonvention, damit eine einzige Regel sie alle abdeckt, und nebula.config.json liest sich so:

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

Die erste Regel fixiert jeden *Function-Typ und seine Mitglieder, sodass die Handler-Methode den Namen behält, den der Handler-String erwartet; scope: "type-and-members" ist das, was FunctionHandler bewahrt, nicht nur die Klasse. Die zweite behält die Mitglieder der DTO-Typen — die Eigenschaften, die der JSON-Vertrag sind — und lässt dabei die Typnamen selbst umbenennen. Alles andere in beiden Assemblys wird umbenannt.

Wenn Sie sich lieber nicht auf eine Konvention verlassen möchten, annotieren Sie den Handler direkt: Das Attribut reist mit dem Code mit und kann nicht aus dem Takt mit einem Muster geraten:

[Obfuscation(Exclude = true, ApplyToMembers = false)]   // keep this method's name for the handler string
public QuoteResponse FunctionHandler(OrderRequest input, ILambdaContext context) { /* ... */ }

Eine Anmerkung zur benutzerdefinierten Laufzeit

Wenn Sie eine benutzerdefinierte Laufzeit (provided.al2023) statt der verwalteten Laufzeit dotnet8 ausliefern, ist die Verdrahtung anders. Sie registrieren den Handler als Delegate in Main, sodass es keinen per Reflection aufgelösten Handler-String gibt: Der konfigurierte Handler ist nur bootstrap, der Name der ausführbaren Datei:

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

Das beseitigt die Umbenennungsfalle bei der Methode des Handlers — der Delegate bindet sie per Referenz, nicht per Name — verschiebt aber die Keep-Anforderung auf den Einstiegspunkt der Assembly: Main muss überleben (Nebula bewahrt den Einstiegspunkt standardmäßig), und die Eigenschaftsnamen der Serializer-POCOs bleiben ein zu bewahrender Vertrag. Die verwaltete Laufzeit tauscht Main gegen einen konfigurierten String; die benutzerdefinierte Laufzeit tauscht den String gegen einen bewahrten Einstiegspunkt.

Das Innere schützen

Den Namen einer Methode zu behalten bedeutet nicht, ihren Rumpf lesbar zu lassen. Der Handler bleibt auflösbar, während alles, was er tut, undurchsichtig wird.

String-Verschlüsselung für die Konstanten. Endpunkt-URLs, API-Schlüssel, Header-Namen und Verbindungsfragmente sind das, wonach ein Angreifer zuerst mit grep sucht; die String-Verschlüsselung speichert sie verschlüsselt und entschlüsselt sie am Verwendungspunkt, sodass eine Suche im ausgelieferten Binary nichts zurückgibt. Es ist kein Tresor — der Wert ist zur Laufzeit lebendig im Speicher — aber es beseitigt den Angriff ohne Aufwand.

Die stärksten Transformationen auf den Kronjuwelen. PricingService.Reprice und welche Scoring-, Abrechnungs- oder Berechtigungslogik die Funktion auch umhüllt, sind der Ort, an dem Sie zur Code-Virtualisierung und zur Ganzmethodenverschlüsselung greifen — beides Pro-Funktionen in Nebula.NET — angewendet auf diese Handvoll Methoden statt auf die ganze Assembly. Die Virtualisierung schreibt eine Methode in Bytecode für eine eingebettete VM um, sodass kein IL zum Dekompilieren übrig bleibt; die Methodenverschlüsselung speichert das ursprüngliche IL verschlüsselt und rekonstruiert es zur Laufzeit. Sie können sogar den Rumpf des Handlers selbst virtualisieren, während sein Name behalten wird:

public sealed class PricingService : IPricingService
{
    [Obfuscation(Feature = "virtualization")]   // Pro — the algorithm, not the plumbing
    public QuoteResponse Reprice(OrderRequest order) { /* ... */ }
}

Umbenennung und Kontrollfluss-Obfuskation laufen breit unter beiden ab und verwandeln den Rest des Inneren in bedeutungslose Symbole und verworrene Verzweigungen.

Den Schutz ins Paketieren einbinden

Schützen Sie die veröffentlichte Ausgabe, und zwar nur auf dem Build-Server, damit lokales Debuggen schnell und lesbar bleibt. Die Amazon.Lambda.Tools-CLI veröffentlicht und komprimiert in einem Schritt mit dotnet lambda package; führen Sie Nebula auf dieser veröffentlichten Ausgabe aus, bevor das Zip gebaut wird, oder schützen und paketieren Sie neu:

dotnet publish OrderApi -c Release -o publish
nebula protect --config nebula.config.json

Hängen Sie es in der CI nach dem Publish ein, damit das deployte Artefakt immer das geschützte ist:

<Target Name="NebulaProtect" AfterTargets="Publish"
        Condition="'$(CI)' == 'true'">
  <Exec Command="nebula protect --config nebula.config.json" />
</Target>

Der geschützte Ordner protected/ ist das, was Ihr Paketierungsschritt komprimieren und ausliefern soll: Richten Sie dotnet lambda package --package-type zip (oder Ihr SAM-CodeUri / CDK-Asset) auf die geschützte Ausgabe statt auf den rohen Publish-Ordner. nebula protect gibt bei einem Fehler einen von null verschiedenen Exit-Code zurück, sodass ein kaputter Schutzschritt den Build scheitern lässt, statt ein ungeschütztes DLL auszuliefern.

Prüfen, dass es wirklich funktioniert

Unit-Tests fangen einen Handler-Auflösungsfehler nicht ab: Sie referenzieren das Projekt und rufen FunctionHandler direkt auf, ohne jemals über den konfigurierten String gegen das geschützte DLL zu gehen. Zwei Prüfungen tun das. Erstens: Rufen Sie das geschützte Paket Ende-zu-Ende auf: Deployen Sie es auf eine Testfunktion (oder führen Sie es unter dem .NET-Lambda-Testwerkzeug / sam local invoke gegen die geschützte Ausgabe aus) mit einem bekannten Event und bestätigen Sie, dass der Handler sich auflöst und das erwartete Angebot zurückgibt. Eine Funktion, die sauber deployt, aber bei der ersten Invocation mit einer Handler-Auflösungsmeldung scheitert, ist die exakte Signatur einer vergessenen Keep-Regel oder eines Handler-Strings, der von den behaltenen Namen abgedriftet ist.

Zweitens: Öffnen Sie die ausgelieferte Assembly in einem Decompiler und lesen Sie sie so, wie es ein Angreifer tun wird: Die Namen des Handler-Typs und der Methode sind vorhanden (das müssen sie, um zum String zu passen), und die Eigenschaftsnamen der POCOs sind vorhanden (sie sind ein Vertrag), aber der Rumpf des Handlers, der Pricing-Dienst, die internen Helfer und die Integrations-Strings sind verschwunden oder unlesbar. Glass.NET ist der kostenlose, rein statische Begleiter für diesen Durchlauf: Laden Sie das geschützte DLL, bestätigen Sie, dass Contoso.Orders.OrderFunction.FunctionHandler noch über den Namen vorhanden ist, und bestätigen Sie dann, dass darin nichts mehr zu lesen ist.

Das Fazit

Die verwaltete .NET-Laufzeit von Lambda ist insofern ungewöhnlich, als sie Ihren Code über einen Handler-String — Assembly::Namespace.Type::Method — findet, der in der Funktionskonfiguration lebt, nicht in der Assembly, und ihn bei jedem Kaltstart per Reflection auflöst. Benennen Sie den Handler-Typ oder die Methode um, und Sie verschieben ihn weg von unter dem String, der ihn benennt; die Laufzeit, die den unveränderten String liest, scheitert daran, den Einstiegspunkt aufzulösen, bevor Ihr Code läuft. Behalten Sie genau zwei Dinge: den Handler-Typ und die Methode, damit ihre Namen weiterhin zum konfigurierten String passen, und die Eigenschaftsnamen der POCOs, die ein Serialisierungsvertrag für Event und Antwort sind. Alles andere — das Pricing, das Scoring, die Geheimnisse, das ganze Innere, das der einzige Grund für die Existenz der Funktion ist — übergeben Sie Nebula.NET zum Umbenennen, zur String-Verschlüsselung und zur Virtualisierung. Rufen Sie dann das geschützte Paket auf, beobachten Sie, wie der Handler sich auflöst und antwortet, und lesen Sie das ausgelieferte DLL, um zu bestätigen, dass die Logik dahinter verschwunden ist.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.