A WCF service is one of the harder things to obfuscate safely, and the reason is subtle: the obfuscator and the serializer both care about your member names, but for opposite reasons. Nebula wants to rename CalculateDiscount to a so a decompiler learns nothing. The DataContractSerializer wants to read that same name and stamp it into the SOAP XML your clients parse. Rename the wrong member and you have not broken your build — you have broken every client on the wire, silently, until the first call fails to deserialize.
This post shows how to obfuscate the internals of a WCF service aggressively — renaming, string encryption, control-flow — while keeping the [ServiceContract] / [DataContract] surface byte-for-byte wire-compatible with clients already in the field. The tool is a keep-list in nebula.config.json.
Where WCF turns a CLR name into a wire name
Consider an ordinary order service:
[ServiceContract(Namespace = "http://contoso.com/orders")]
public interface IOrderService
{
[OperationContract]
OrderConfirmation PlaceOrder(OrderRequest request);
}
[DataContract(Namespace = "http://contoso.com/orders")]
public class OrderRequest
{
[DataMember]
public string CustomerId { get; set; }
[DataMember]
public decimal UnitPrice { get; set; }
[DataMember]
public int Quantity { get; set; }
}
Nothing here says what the SOAP will look like, and that is exactly the trap. With no Name= override, WCF derives every wire name from the CLR identifier. The request body for PlaceOrder serializes as:
<PlaceOrder xmlns="http://contoso.com/orders">
<request>
<CustomerId>C-4471</CustomerId>
<UnitPrice>19.99</UnitPrice>
<Quantity>3</Quantity>
</request>
</PlaceOrder>
The element names PlaceOrder, CustomerId, UnitPrice, Quantity are literally the method name, the parameter name and the property names. A client built from your published WSDL hard-codes those strings. Now run a renaming obfuscator with no guard rails and UnitPrice becomes b:
<a xmlns="http://contoso.com/orders">
<request>
<c>C-4471</c>
<b>19.99</b>
<d>3</d>
</request>
</a>
Same logic, same types, completely different wire. Every existing client throws, because the element it is looking for no longer exists in the message.
The keep-list: pin the contract, obfuscate everything else
The goal is not "don't obfuscate this assembly." It is "don't rename the handful of members the serializer reads, and go wild on the rest." Nebula expresses that with a keep-list driven by the WCF attributes themselves, so you declare intent once instead of enumerating every member by hand:
{
"input": "bin/Release/Contoso.Orders.dll",
"rename": {
"enabled": true,
"keep": [
{ "hasAttribute": "System.ServiceModel.ServiceContractAttribute", "scope": "type" },
{ "hasAttribute": "System.ServiceModel.OperationContractAttribute", "scope": "member" },
{ "hasAttribute": "System.Runtime.Serialization.DataContractAttribute", "scope": "type" },
{ "hasAttribute": "System.Runtime.Serialization.DataMemberAttribute", "scope": "member" },
{ "hasAttribute": "System.Runtime.Serialization.EnumMemberAttribute", "scope": "member" },
{ "hasAttribute": "System.ServiceModel.MessageContractAttribute", "scope": "type" },
{ "hasAttribute": "System.ServiceModel.MessageBodyMemberAttribute", "scope": "member" }
]
},
"strings": { "encrypt": true, "exclude": ["hasAttribute:DataMemberAttribute"] },
"controlFlow": { "enabled": true }
}
Two things are doing the work. The keep rules say: any type carrying [ServiceContract], [DataContract] or [MessageContract] keeps its name, and any member carrying [OperationContract], [DataMember], [EnumMember] or [MessageBodyMember] keeps its name. Everything else — your PricingEngine, your private helpers, your validation routines — is renamed normally. The strings.exclude rule keeps literal default values and enum wire strings out of the string-encryption pass, because those too can surface in the contract.
Then you protect:
nebula protect --config nebula.config.json --out bin/Protected
The implementation is now renamed, its strings encrypted and its control flow flattened; the contract surface is untouched. Because [DataMember] attributes stay on exactly the same CLR names, the serializer produces exactly the same XML.
Explicit Name= makes aggressive renaming safe
The keep-list above protects implicit contracts — the fragile case where the CLR name is the wire name. You can do better. If you decouple the two with explicit names, the serializer stops caring about the identifier at all:
[DataContract(Name = "order", Namespace = "http://contoso.com/orders")]
public class OrderRequest
{
[DataMember(Name = "customerId", Order = 0)]
public string CustomerId { get; set; }
[DataMember(Name = "unitPrice", Order = 1)]
public decimal UnitPrice { get; set; }
}
[ServiceContract(Namespace = "http://contoso.com/orders")]
public interface IOrderService
{
[OperationContract(Name = "PlaceOrder", Action = "http://contoso.com/orders/place")]
OrderConfirmation Submit(OrderRequest request);
}
Now the wire says <order>, <customerId>, <unitPrice> and the operation is PlaceOrder on the wire — regardless of what the CLR members are called. Notice Submit is the method name but PlaceOrder is the operation name: Nebula can rename the method to a and the SOAP action is still PlaceOrder, because it is pinned in the attribute, not derived. Explicit Name= turns "you must keep every identifier" into "you only keep the type shells," and even those keeps become about the attributes, not the names.
This is why mature WCF contracts spell out Name= everywhere: it is good versioning hygiene and it is what lets a hardening tool rename the maximum amount of code. A keep-list is the safety net under implicit contracts; explicit names shrink what the net has to catch.
Verify: the WSDL and SOAP must be identical
Never ship on faith. The contract is an observable artifact, so observe it on both sides of the obfuscation step.
-
Diff the WSDL. Export metadata from the original service (
svcutilor the?wsdlendpoint), export it again from the protected build, and diff. The<xsd:element name="...">entries andwsdl:operationnames must match exactly. Any difference is a member you renamed that the serializer was reading. - Diff a live SOAP envelope. Call the original and the protected service with the same request and compare the request/response bodies. Element names, namespaces and ordering should be character-for-character equal.
-
Read the renaming map.
nebula protectwrites a map of old-to-new names. Confirm your[DataContract]types and[DataMember]members are absent from it (they were kept) and your implementation types are present (they were renamed).
If all three agree, a client built against the published WSDL — including clients you do not control — calls the hardened service exactly as before, while a decompiler opening the DLL finds renamed types, encrypted strings and a flattened dispatcher where your pricing logic used to be legible.
The rule to remember
The serializer reads names; the obfuscator rewrites names; the keep-list is where you tell them apart. Pin the [ServiceContract], [DataContract], [DataMember] and [MessageContract] surface by attribute, prefer explicit Name= so the wire never depended on an identifier in the first place, and verify against the WSDL. Do that and you get the full weight of Nebula's renaming, string encryption and control-flow obfuscation on your proprietary logic with zero change to the contract your customers integrate against.
Top comments (0)