Skip to content
← Alle Beiträge
· Delta1 Labs Azure FunctionsVerschleierungNebula

Eine Azure Functions Isolated-Worker-App verschleiern, ohne die Funktionserkennung zu brechen

Der isolierte .NET-Worker findet Ihre Funktionen über einen quellcodegenerierten Metadaten-Provider, der den Einstiegspunkt jeder Funktion zur Compile-Zeit als Zeichenkettenliteral — "Namespace.Class.Method" — in die Assembly einbäckt. Verschleiern Sie danach, verschiebt das Umbenennen die Methode aus der Reichweite der Zeichenkette, sodass der Functions-Host Ihre App lädt und dann keine einzige Funktion mehr findet. Dieser Beitrag erklärt genau, welche Namen das isolierte Modell zur Laufzeit auflöst, wie man die Trigger-Methoden und Binding-DTOs mit Nebula.NET auf eine Keep-List setzt und wie man die Geschäftslogik, die der ganze Daseinsgrund der Funktion ist, mit Zeichenkettenverschlüsselung und Virtualisierung schützt.

Eine Azure Functions-App, die auf dem isolierten Worker-Modell (isolated worker) von .NET geschrieben ist, scheint das Einfachste der Welt zum Verschleiern. Es ist eine Klassenbibliothek — kein Main, keine Oberfläche, eine Handvoll mit [Function] dekorierter Methoden — die in die Cloud eines anderen ausgeliefert wird und kopflos läuft. Fast nichts davon wird von außen namentlich konsumiert. Und doch ist es die eine .NET-App-Form, bei der ein naiver Verschleierungsdurchlauf den verwirrendsten Fehler produziert: Die App wird bereitgestellt, der Worker-Prozess startet, der Host protokolliert keine Fehler, und dann gibt jede Funktion 404 zurück oder feuert nie, weil der Host null Funktionen indiziert hat. Der Code ist vollständig da. Der Host findet ihn schlicht nicht.

Der Grund ist spezifisch für das isolierte Modell, und sobald Sie ihn sehen, ist die Lösung eine zweizeilige Keep-List. In diesem Beitrag geht es um diesen Mechanismus: wie der isolierte Worker Ihre Funktionen entdeckt, warum die Verschleierung die Erkennung bricht und nicht den Code, und wie man die Teile schützt, die zählen, mit Nebula.NET, ohne die Teile anzufassen, die der Host liest.

Warum eine Functions-App schützenswert ist

Eine Funktion ist reine Geschäftslogik mit gelöschtem Beiwerk. Es gibt keine Präsentationsschicht, keinen Framework-Klebstoff zum Durchwaten — nur den Trigger und das, was er tut: einen Warenkorb neu bepreisen, einen Kreditantrag scoren, eine Webhook-Signatur validieren, eine Transaktion abrechnen, eine Berechtigung entscheiden. Diese Logik ist das Produkt, und in einer Funktion ist sie nahezu die gesamte Assembly. Die App trägt auch die sensiblen Konstanten, die man von etwas erwartet, das sich in andere Systeme integriert — nachgelagerte Endpunkt-URLs, Partner-API-Schlüssel, Header-Namen, Verbindungszeichenfolgen-Fragmente — häufig als Code-Standardwerte statt als Konfiguration.

Und sie läuft irgendwo, das Sie nicht administrieren. Ob es die Infrastruktur von Azure ist oder das eigene Abonnement eines Kunden, das Ihre Funktion in einem Container ausführt — die bereitgestellte Assembly ist eine DLL, die ein Angreifer ziehen und in einem Decompiler öffnen kann, um Ihr Scoring-Modell oder Ihre Abrechnungsmathematik in Sekunden als sauberes C# zurückzulesen. Die Kopflosigkeit, die eine Funktion ungeschützt erscheinen lässt, ist genau das, was sie des Rückwärtsengineerings würdig macht.

Wie der isolierte Worker Ihre Funktionen findet

Hier ist eine gewöhnliche HTTP-Funktion:

namespace Contoso.Orders;

public sealed class OrderFunctions
{
    private readonly IPricingService _pricing;

    public OrderFunctions(IPricingService pricing) => _pricing = pricing;

    [Function("ProcessOrder")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
    {
        var order = await req.ReadFromJsonAsync<OrderRequest>();
        var quote = _pricing.Reprice(order!);          // proprietär — der Daseinsgrund
        var res = req.CreateResponse(HttpStatusCode.OK);
        await res.WriteAsJsonAsync(quote);
        return res;
    }
}

Im alten In-Process-Modell lud der Host Ihre Assembly und scannte sie beim Start nach [Function]. Das isolierte Modell funktioniert nicht so. Ihr Worker ist ein separater Prozess, und um das Reflektieren über Ihre Assembly über diese Grenze hinweg zu vermeiden, liefert das Worker SDK einen Quellcodegenerator, der zur Compile-Zeit läuft und einen Metadaten-Provider ausgibt, der jede gefundene Funktion beschreibt:

// <auto-generated/> — ausgegeben von Microsoft.Azure.Functions.Worker.Sdk.Generators
internal class GeneratedFunctionMetadataProvider : IFunctionMetadataProvider
{
    public Task<ImmutableArray<IFunctionMetadata>> GetFunctionMetadataAsync(string dir)
    {
        var metadata = new DefaultFunctionMetadata
        {
            Name = "ProcessOrder",
            ScriptFile = "Contoso.Orders.dll",
            EntryPoint = "Contoso.Orders.OrderFunctions.Run",   // <-- die kritische Zeichenkette
            RawBindings = { /* httpTrigger + http return, als JSON */ },
        };
        // ...
    }
}

Der EntryPoint ist ein Zeichenkettenliteral — der voll qualifizierte Typname plus der Methodenname — und der Generator schreibt ihn als ldstr-Konstante in die Assembly. Beim Start liest der Worker diese Metadaten, und für jeden Eintrag löst er die tatsächliche Methode per Reflexion auf, im Wesentlichen:

var type = assembly.GetType("Contoso.Orders.OrderFunctions");
var method = type.GetMethod("Run");   // der Name kommt direkt aus der EntryPoint-Zeichenkette

Das ist die ganze Falle. Der Generator läuft zuerst und bäckt "Contoso.Orders.OrderFunctions.Run" in die DLL ein. Die Verschleierung läuft nach der Kompilierung, auf dieser DLL. Wenn das Umbenennen OrderFunctions in a und Run in b verwandelt, bleibt die eingebackene Zeichenkette unberührt — sie sagt immer noch Contoso.Orders.OrderFunctions.Run — aber die Methode, die sie benennt, existiert nicht mehr. GetType gibt null zurück, die Funktion wird übersprungen, und der Host meldet eine App ohne Funktionen. Nichts wirft eine Ausnahme, weil eine App mit null Funktionen eine vollkommen gültige App ist.

1 · compile — generator bakes the stringEntryPoint ="Contoso.Orders.OrderFunctions.Run"2 · obfuscate — renames the methodOrderFunctions.Run → a.bstring constant left unchanged3 · host startup — resolve by the baked stringassembly.GetType("Contoso.Orders.OrderFunctions") → nullhost indexes 0 functions · app loads clean · every route 404sfix · keep the trigger method so the name still matcheskeep Contoso.Orders.*Functions.* → GetType + GetMethod resolve · functions indexed

Das Audit: was umbenannt wird, was nicht

Gehen Sie jeden Namen durch, den das isolierte Modell berührt, und die Keep-List schreibt sich von selbst.

  • Trigger-Methoden und ihre deklarierenden Typen — OrderFunctions.Run. Aufgelöst durch die eingebackene EntryPoint-Zeichenkette per Reflexion. Behalten. Das ist die einzige Nahtstelle, die Functions eigen ist, und der ganze Grund, warum naive Verschleierung scheitert.
  • Der Name [Function("ProcessOrder")] — der öffentliche Funktionsname, das Routensegment, die Queue, die er abhört. Das ist ein Zeichenkettenliteral-Argument, völlig unabhängig vom Methodennamen, also übersteht es das Umbenennen unberührt. Keine Aktion nötig. Ihre URL bleibt /api/ProcessOrder, auch nachdem Run zu b wird.
  • Binding-Parameter (HttpTrigger, QueueTrigger, BlobInput, Ausgabe-Bindings). Ihre Konfiguration — Queue-Namen, Verbindungseinstellungsnamen, Auth-Level — wird zur Compile-Zeit als JSON in den generierten RawBindings erfasst und ist alles Literale. Das Umbenennen von Parametern rührt es nicht an. Keine Aktion nötig.
  • Konstruktor-Injektion (IPricingService). Aufgelöst nach Typ aus dem in Program.cs gebauten DI-Container, genau wie in jedem Host. Das Umbenennen bewahrt die Typidentität. Frei umbenennen.
  • Der generierte GeneratedFunctionMetadataProvider selbst. Registriert nach Typ über die generierte Start-Erweiterung des SDK, nicht nach Namen. Er benennt sich problemlos um — was zählt, ist der Zeichenketteninhalt darin, der auf die Trigger-Methoden zeigt, die Sie behalten. Frei umbenennen.
  • Private Helfer, Domänendienste, der Algorithmus. Nichts außerhalb des Prozesses benennt diese. Umbenennen, verschlüsseln, virtualisieren.
  • Request/Response-POCOs (OrderRequest, das Angebot, das Sie zurückgeben). Serialisiert von System.Text.Json anhand von Eigenschaftsnamen, die ein Draht-Vertrag mit Ihren Aufrufern sind. Die Eigenschaftsnamen behalten — oder jeden mit [JsonPropertyName("...")] festnageln und das Mitglied umbenennen lassen. Das ist die allgemeine Serialisierungsfalle, keine Functions-spezifische.

Zwei Nahtstellen also zu behalten — Trigger-Methoden und Binding-DTOs — und alles andere ist Freiwild.

Die Keep-List

Modellieren Sie die Funktionsklassen über eine Namenskonvention, sodass eine Regel alle abdeckt. Mit einer Solution, die in Contoso.Orders (die Funktionen und DTOs) und Contoso.Pricing (der Algorithmus) aufgeteilt ist, liest sich nebula.config.json:

{
  "schemaVersion": 1,
  "inputs": [
    "bin/Release/net8.0/publish/Contoso.Orders.dll",
    "bin/Release/net8.0/publish/Contoso.Pricing.dll"
  ],
  "outputDirectory": "protected",

  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "Contoso.Orders.*Functions",        "action": "keep", "scope": "type-and-members" },
      { "pattern": "Contoso.Orders.Contracts.*",       "action": "keep" },
      { "pattern": "Contoso.Orders.*",                 "action": "rename" },
      { "pattern": "Contoso.Pricing.*",                "action": "rename" }
    ]
  }
}

Die erste Regel nagelt jeden *Functions-Typ und seine Mitglieder fest, sodass die Trigger-Methoden die Namen behalten, die die generierten Zeichenketten erwarten; scope: "type-and-members" ist das, was Run bewahrt, nicht nur die Klasse. Die zweite nagelt die serialisierten DTOs fest. Alles andere in beiden Assemblies wird umbenannt. Wenn Sie sich lieber nicht auf eine Konvention verlassen, annotieren Sie die Trigger-Methoden direkt — das Attribut reist mit dem Code und kann nicht aus dem Takt mit einem Umbenennungsmuster geraten:

[Function("ProcessOrder")]
[Obfuscation(Exclude = true, ApplyToMembers = false)]   // Name dieser Methode für die Erkennung behalten
public async Task<HttpResponseData> Run(/* ... */) { /* ... */ }

Das Innere schützen

Den Namen einer Methode zu behalten heißt nicht, ihren Rumpf lesbar zu lassen. Der Trigger bleibt auffindbar, während alles, was er tut, undurchsichtig wird.

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

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

public sealed class PricingService : IPricingService
{
    [Obfuscation(Feature = "virtualization")]   // Pro — der Algorithmus, nicht das Beiwerk
    public Quote Reprice(OrderRequest order) { /* ... */ }
}

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

Nebula in die Veröffentlichung einbinden

Schützen Sie die veröffentlichte Ausgabe, und nur auf dem Build-Server, damit lokales Debuggen schnell und lesbar bleibt:

dotnet publish Contoso.Orders -c Release -o publish
nebula protect --config nebula.config.json

Hängen Sie es in CI nach der Veröffentlichung ein, sodass das bereitgestellte 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 Bereitstellungsschritt (zip-deploy, Container-Image oder func azure functionapp publish gegen die gebaute Ausgabe) ausliefern sollte. Entwickler-Builds unverschleiert zu halten und nur in CI zu verschleiern ist eine Disziplin für sich — die Begründung steht in nur auf dem Build-Server verschleiern.

Überprüfen, dass es wirklich funktioniert

Unit-Tests fangen einen Erkennungsfehler nicht ab — sie referenzieren das Projekt und führen nie die geschützte DLL aus. Zwei Prüfungen tun es. Erstens: Führen Sie die geschützte App Ende zu Ende aus — starten Sie den Functions-Host gegen die geschützte Ausgabe (lokal mit func start, oder einem Staging-Slot) und bestätigen Sie, dass das Host-Protokoll Ihre Funktionen namentlich auflistet — ProcessOrder indiziert, keine leere App — dann rufen Sie jeden Trigger auf und prüfen das reale Verhalten, dass eine bekannte Anfrage das erwartete Angebot erzeugt. Eine App, die sauber startet, aber keine Funktionen auflistet, ist die exakte Signatur einer verpassten Keep-Regel. Zweitens: Öffnen Sie die ausgelieferte Assembly in einem Decompiler und lesen Sie sie, wie es ein Angreifer tun wird: Die *Functions-Typnamen sind vorhanden (das müssen sie sein), aber ihre Rümpfe, der Pricing-Service, die internen Helfer und die Integrationszeichenketten sind verschwunden oder unlesbar. Glass.NET ist der kostenlose, rein statische Begleiter für diesen Durchlauf; die vollständige Vorgehensweise steht in hat Ihre Verschleierung wirklich funktioniert?.

Das Fazit

Der isolierte Worker ist ungewöhnlich darin, dass er Ihre Funktionen über Zeichenketten findet, die ein Quellcodegenerator zur Compile-Zeit in die Assembly eingebacken hat, bevor die Verschleierung überhaupt läuft. Benennen Sie eine Trigger-Methode um, und Sie verschieben sie aus der Reichweite der Zeichenkette, die sie benennt, und der Host — der die veraltete Zeichenkette liest — indiziert stillschweigend nichts. Behalten Sie genau zwei Dinge: die Trigger-Methoden, damit ihre Namen weiterhin mit den generierten EntryPoint-Zeichenketten übereinstimmen, und die DTOs, deren Eigenschaften ein Serialisierungsvertrag sind. Alles andere — die Preisgestaltung, das Scoring, die Geheimnisse, das ganze Innere, das der einzige Daseinsgrund der Funktion ist — übergeben Sie Nebula.NET zum Umbenennen, Zeichenkettenverschlüsseln und Virtualisieren. Führen Sie dann den geschützten Build aus, beobachten Sie, wie der Host Ihre Funktionen namentlich indiziert, und lesen Sie die 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.