From d85a37c7ad7e4176c7dbc436d9096a21bd3cc2bb Mon Sep 17 00:00:00 2001 From: olivato Date: Fri, 11 Sep 2026 20:04:06 +0100 Subject: [PATCH] Add paramPadSettings to insert random junk parameters into method signatures --- Editor/ObfusPasses/ObfuscationPassType.cs | 3 +- Editor/ObfusPasses/ParamPad.meta | 8 + Editor/ObfusPasses/ParamPad/ParamPadPass.cs | 104 ++ .../ObfusPasses/ParamPad/ParamPadPass.cs.meta | 11 + .../ObfusPasses/ParamPad/ParameterPadding.cs | 992 ++++++++++++++++++ .../ParamPad/ParameterPadding.cs.meta | 11 + .../SymbolObfus/Policies/SupportPassPolicy.cs | 6 +- .../ObfusPasses/SymbolObfus/SymbolRename.cs | 4 +- Editor/ObfuscatorBuilder.cs | 7 + Editor/Settings/ObfuzSettings.cs | 3 + Editor/Settings/ObfuzSettingsProvider.cs | 3 + Editor/Settings/ParamPadSettings.cs | 72 ++ Editor/Settings/ParamPadSettings.cs.meta | 11 + Tests~/ParamPad/.gitignore | 6 + Tests~/ParamPad/Main.cs | 483 +++++++++ Tests~/ParamPad/caller/Caller.cs | 22 + Tests~/ParamPad/caller/caller.csproj | 12 + Tests~/ParamPad/fixture/Fixture.cs | 253 +++++ Tests~/ParamPad/fixture/fixture.csproj | 13 + Tests~/ParamPad/libA/Thing.cs | 1 + Tests~/ParamPad/libA/libA.csproj | 3 + Tests~/ParamPad/libB/Thing.cs | 1 + Tests~/ParamPad/libB/libB.csproj | 3 + Tests~/ParamPad/probe.csproj | 26 + Tests~/ParamPad/run.sh | 20 + Tests~/ParamPad/stubs.cs | 34 + Tests~/ParamPad/unitystub/Unity.cs | 30 + Tests~/ParamPad/unitystub/unitystub.csproj | 6 + 28 files changed, 2143 insertions(+), 5 deletions(-) create mode 100644 Editor/ObfusPasses/ParamPad.meta create mode 100644 Editor/ObfusPasses/ParamPad/ParamPadPass.cs create mode 100644 Editor/ObfusPasses/ParamPad/ParamPadPass.cs.meta create mode 100644 Editor/ObfusPasses/ParamPad/ParameterPadding.cs create mode 100644 Editor/ObfusPasses/ParamPad/ParameterPadding.cs.meta create mode 100644 Editor/Settings/ParamPadSettings.cs create mode 100644 Editor/Settings/ParamPadSettings.cs.meta create mode 100644 Tests~/ParamPad/.gitignore create mode 100644 Tests~/ParamPad/Main.cs create mode 100644 Tests~/ParamPad/caller/Caller.cs create mode 100644 Tests~/ParamPad/caller/caller.csproj create mode 100644 Tests~/ParamPad/fixture/Fixture.cs create mode 100644 Tests~/ParamPad/fixture/fixture.csproj create mode 100644 Tests~/ParamPad/libA/Thing.cs create mode 100644 Tests~/ParamPad/libA/libA.csproj create mode 100644 Tests~/ParamPad/libB/Thing.cs create mode 100644 Tests~/ParamPad/libB/libB.csproj create mode 100644 Tests~/ParamPad/probe.csproj create mode 100755 Tests~/ParamPad/run.sh create mode 100644 Tests~/ParamPad/stubs.cs create mode 100644 Tests~/ParamPad/unitystub/Unity.cs create mode 100644 Tests~/ParamPad/unitystub/unitystub.csproj diff --git a/Editor/ObfusPasses/ObfuscationPassType.cs b/Editor/ObfusPasses/ObfuscationPassType.cs index ef3ac4c..b12ffa3 100644 --- a/Editor/ObfusPasses/ObfuscationPassType.cs +++ b/Editor/ObfusPasses/ObfuscationPassType.cs @@ -35,11 +35,12 @@ namespace Obfuz.ObfusPasses ExprObfus = 0x400, ControlFlowObfus = 0x800, EvalStackObfus = 0x1000, + ParamPad = 0x2000, RemoveConstField = 0x100000, WaterMark = 0x200000, - AllObfus = SymbolObfus | CallObfus | ExprObfus | ControlFlowObfus | EvalStackObfus, + AllObfus = SymbolObfus | CallObfus | ExprObfus | ControlFlowObfus | EvalStackObfus | ParamPad, AllEncrypt = ConstEncrypt | FieldEncrypt, MethodBodyObfusOrEncrypt = ConstEncrypt | CallObfus | ExprObfus | ControlFlowObfus | EvalStackObfus, diff --git a/Editor/ObfusPasses/ParamPad.meta b/Editor/ObfusPasses/ParamPad.meta new file mode 100644 index 0000000..d755672 --- /dev/null +++ b/Editor/ObfusPasses/ParamPad.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc5d3560708a4cfb94fded33575958e7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ObfusPasses/ParamPad/ParamPadPass.cs b/Editor/ObfusPasses/ParamPad/ParamPadPass.cs new file mode 100644 index 0000000..e037f72 --- /dev/null +++ b/Editor/ObfusPasses/ParamPad/ParamPadPass.cs @@ -0,0 +1,104 @@ +// Copyright 2025 Code Philosophy +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using dnlib.DotNet; +using Obfuz.ObfusPasses.SymbolObfus; +using Obfuz.Settings; +using System; +using UnityEngine; + +namespace Obfuz.ObfusPasses.ParamPad +{ + public class ParamPadPass : ObfuscationPassBase + { + private readonly ParamPadSettingsFacade _settings; + private IObfuscationPolicy _renamePolicy; + + public override ObfuscationPassType Type => ObfuscationPassType.ParamPad; + + public ParamPadPass(ParamPadSettingsFacade settings) + { + _settings = settings; + } + + public override void Start() + { + _renamePolicy = SymbolRename.CreateDefaultRenamePolicy(_settings.ruleFiles, _settings.customRenamePolicyTypes, ObfuscationPassType.ParamPad); + } + + /// + /// Deliberately empty. The work happens in Stop(), see the comment there. + /// + public override void Process() + { + } + + /// + /// Padding runs in Stop(), not Process(), and is registered after CallObfus. + /// + /// CallObfus generates its dispatch proxy BODIES in Stop(). Running before it means those + /// bodies do not exist yet and their calls to padded methods keep the old argument count + /// (a broken build). Running after it in Process() is impossible for the same reason. But + /// Stop() runs in registration order, so padding last in Stop() sees the finished proxies. + /// + /// That ordering is what keeps the proxies useful. CallObfus groups call targets by shared + /// signature, so if it saw padded signatures the pool would fragment — measured on the real + /// game, hubs went from 3209 (mean 6.2 callees) to 9717 (mean 2.46, median 1), i.e. mostly + /// one-to-one indirections that any tool collapses. Padding afterwards leaves the proxy + /// signatures unpadded, so the hubs stay dense, and the junk arguments get materialised + /// once inside each proxy case instead of at every call site that funnels through it. + /// + /// Everything else has already run by now, which is also why the junk constants are never + /// const-encrypted and the consume fold is never re-obfuscated by ExprObfus or flattened + /// by ControlFlowObfus. + /// + public override void Stop() + { + var ctx = ObfuscationPassContext.Current; + int seed = _settings.randomSeed != 0 ? _settings.randomSeed : (Guid.NewGuid().GetHashCode() | 1); + Debug.Log($"[ParamPad] padding parameters with seed {seed}, count range [{_settings.minCount},{_settings.maxCount}]."); + + var padding = new ParameterPadding(seed, _settings.minCount, _settings.maxCount, IsSafeToPad); + padding.Process(ctx.modulesToObfuscate, ctx.allObfuscationRelativeModules); + Debug.Log($"[ParamPad] padded {padding.PaddedMethodCount} of {padding.CandidateCount} candidate methods ({padding.VetoedCount} vetoed because a call site could not be rewritten)."); + } + + private bool IsSafeToPad(MethodDef method) + { + var ctx = ObfuscationPassContext.Current; + if (ctx.whiteList.IsInWhiteList(method.Module) || ctx.whiteList.IsInWhiteList(method.DeclaringType) || ctx.whiteList.IsInWhiteList(method)) + { + return false; + } + if (!Support(ctx.passPolicy.GetMethodObfuscationPasses(method))) + { + return false; + } + if (ctx.obfuzIgnoreScopeComputeCache.HasSelfOrDeclaringOrEnclosingOrInheritObfuzIgnoreScope(method, method.DeclaringType, ObfuzScope.MethodParameter)) + { + return false; + } + // the rename policy already encodes every contract that binds a method from outside + // the IL: MonoBehaviour messages, DOTS and source generated types, MonoPInvokeCallback, + // delegate members, plus the project's own rule files and custom policies. + return _renamePolicy.NeedRename(method); + } + } +} diff --git a/Editor/ObfusPasses/ParamPad/ParamPadPass.cs.meta b/Editor/ObfusPasses/ParamPad/ParamPadPass.cs.meta new file mode 100644 index 0000000..31599c8 --- /dev/null +++ b/Editor/ObfusPasses/ParamPad/ParamPadPass.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 03ae54b9c1b04b12bf0cf6140e3db2bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ObfusPasses/ParamPad/ParameterPadding.cs b/Editor/ObfusPasses/ParamPad/ParameterPadding.cs new file mode 100644 index 0000000..067e6db --- /dev/null +++ b/Editor/ObfusPasses/ParamPad/ParameterPadding.cs @@ -0,0 +1,992 @@ +// Copyright 2025 Code Philosophy +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using dnlib.DotNet; +using dnlib.DotNet.Emit; +using Obfuz.Editor; +using Obfuz.Utils; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Obfuz.ObfusPasses.ParamPad +{ + public enum JunkKind + { + Int32, + UInt32, + Int64, + Single, + Double, + Boolean, + Byte, + Int16, + Char, + } + + /// How the junk parameters are combined into one value. + public enum FoldOp { Xor, Add, Sub, Mul, Or, And } + + /// How that value is driven to zero. Two of these need no literal zero at all. + public enum ZeroOp { MulZero, DupXor, DupSub, AndZero } + + /// Where the zero goes, so that the junk loads are never dead. + public enum SinkKind { BranchPair, SwitchOne, ThreadIntoReturn } + + public class PadPlan + { + public MethodDef method; + // the per-method recipe for consuming the junk. Randomising these is what stops every + // padded method from opening with one greppable prologue. + public FoldOp foldOp; + public ZeroOp zeroOp; + public SinkKind sink; + public int[] junkFoldOrder; + // one entry per slot of the new parameter list. -1 marks a junk slot, otherwise + // the index of the original parameter that lives there. + public int[] slotToReal; + // junk descriptor per slot, only meaningful where slotToReal is -1. + public JunkKind[] slotKind; + public object[] slotValue; + // original parameter index -> new slot index. + public int[] realToSlot; + + public int RealCount => realToSlot.Length; + public int SlotCount => slotToReal.Length; + } + + /// + /// Inserts junk parameters at random positions into eligible methods and fixes up every + /// definition, reference and call site so the result still runs. + /// + /// Pure dnlib on purpose: no ObfuscationPassContext, no Unity, so Tests~/ParamPad can + /// compile this file directly the way Tests~/MemberReorder compiles MemberReorder.cs. + /// + public class ParameterPadding + { + private readonly Random _random; + private readonly int _minCount; + private readonly int _maxCount; + private readonly Func _isSafe; + + private static readonly JunkKind[] s_junkKinds = (JunkKind[])Enum.GetValues(typeof(JunkKind)); + + public ParameterPadding(int seed, int minCount, int maxCount, Func isSafe) + { + if (minCount < 1 || maxCount < minCount) + { + throw new ArgumentException($"invalid parameter padding range [{minCount},{maxCount}]"); + } + _random = new Random(seed); + _minCount = minCount; + _maxCount = maxCount; + _isSafe = isSafe; + } + + private class CallSite + { + public MethodDef host; + public Instruction inst; + public PadPlan plan; + // parameter types as seen at this call site, already valid in the host module. + public TypeSig[] argTypes; + } + + public int PaddedMethodCount { get; private set; } + + /// Methods the safety predicate and the structural checks accepted. + public int CandidateCount { get; private set; } + + /// Candidates dropped because a call site could not be rewritten safely. + public int VetoedCount { get; private set; } + + /// + /// Method operands that could not be resolved at all. Every candidate sharing a name with + /// one of these is vetoed, because an unresolvable reference might BE that candidate. + /// + public int UnresolvedReferenceCount { get; private set; } + + public void Process(List toObfuscate, List allModules) + { + var candidates = new HashSet(); + foreach (ModuleDef mod in toObfuscate) + { + foreach (TypeDef type in mod.GetTypes()) + { + foreach (MethodDef method in type.Methods) + { + if (IsCandidate(method)) + { + candidates.Add(method); + } + } + } + } + if (candidates.Count == 0) + { + return; + } + + // a module can be loaded more than once, in which case resolving a reference hands + // back a MethodDef from the other instance. Matching on identity alone would then + // silently miss the call site and ship a broken assembly, so match on token too. + var byToken = new Dictionary(); + foreach (MethodDef method in candidates) + { + byToken[TokenKey(method.Module, method.MDToken.Raw)] = method; + } + + CandidateCount = candidates.Count; + var vetoed = new HashSet(); + var rawSites = new List(); + var refsByMethod = new Dictionary>(); + var unresolvedNames = new HashSet(); + IndexReferences(allModules, byToken, vetoed, rawSites, refsByMethod, unresolvedNames); + + // An operand we could not resolve may well be one of our candidates, so we cannot tell + // whether its call site needs rewriting. Every other "I do not understand this" path + // in this pass vetoes; this one must too, or the method is padded with a stale call + // site left behind and the assembly ships broken. + if (unresolvedNames.Count > 0) + { + foreach (MethodDef candidate in candidates) + { + if (unresolvedNames.Contains(candidate.Name)) + { + vetoed.Add(candidate); + } + } + } + + candidates.ExceptWith(vetoed); + VetoedCount = CandidateCount - candidates.Count; + if (candidates.Count == 0) + { + return; + } + + // a method whose call sites we could not fully index is dropped wholesale, so the + // transform is never half applied. + var sites = rawSites.Where(s => candidates.Contains(s.plan.method)).ToList(); + + var plans = new Dictionary(); + foreach (MethodDef method in candidates.OrderBy(m => m.Module.Name.String, StringComparer.Ordinal).ThenBy(m => m.MDToken.Raw)) + { + plans.Add(method, BuildPlan(method)); + } + foreach (CallSite site in sites) + { + site.plan = plans[site.plan.method]; + } + + foreach (PadPlan plan in plans.Values) + { + ApplyToDefinition(plan); + } + foreach (var e in refsByMethod) + { + if (!plans.TryGetValue(e.Key, out PadPlan plan)) + { + continue; + } + foreach (MemberRef memberRef in e.Value) + { + ApplyToReference(memberRef, plan); + } + } + RewriteCallSites(sites); + PaddedMethodCount = plans.Count; + + // CleanUpInstructionPass runs in the Process() phase, which is already over by the + // time this pass works, so nothing else will compact what we emit. + var touched = new HashSet(plans.Keys); + foreach (CallSite site in sites) + { + touched.Add(site.host); + } + foreach (MethodDef method in touched) + { + CilBody body = method.Body; + body.OptimizeMacros(); + body.OptimizeBranches(); + } + } + + /// + /// Attributes that pin a method's ARGUMENT LIST, as opposed to its name. The rename + /// policy does not cover these: a serialization callback is located by attribute, so it + /// is perfectly safe to rename and still fatal to re-sign — BinaryFormatter and + /// Newtonsoft both validate the signature and throw. Unity's ContextMenu and the editor + /// callbacks are invoked with a fixed (usually empty) argument list for the same reason. + /// + private static readonly HashSet s_signaturePinningAttributes = new HashSet + { + "System.Runtime.Serialization.OnSerializingAttribute", + "System.Runtime.Serialization.OnSerializedAttribute", + "System.Runtime.Serialization.OnDeserializingAttribute", + "System.Runtime.Serialization.OnDeserializedAttribute", + "System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute", + "UnityEngine.RuntimeInitializeOnLoadMethodAttribute", + "UnityEngine.ContextMenu", + "UnityEditor.MenuItem", + "UnityEditor.InitializeOnLoadMethodAttribute", + "UnityEditor.Callbacks.DidReloadScripts", + "UnityEditor.Callbacks.PostProcessBuildAttribute", + "UnityEditor.Callbacks.PostProcessSceneAttribute", + "UnityEditor.Callbacks.OnOpenAssetAttribute", + }; + + private static bool HasSignaturePinningAttribute(MethodDef method) + { + foreach (CustomAttribute ca in method.CustomAttributes) + { + ITypeDefOrRef attrType = ca.AttributeType; + if (attrType == null) + { + continue; + } + if (s_signaturePinningAttributes.Contains(attrType.FullName) + || attrType.Name == ConstValues.MonoPInvokeCallbackAttributeName) + { + return true; + } + } + return false; + } + + private bool IsCandidate(MethodDef method) + { + if (!method.HasBody || method.Body.Instructions.Count == 0) + { + return false; + } + if (method.IsPinvokeImpl || method.IsInternalCall || method.IsNative || method.IsRuntime || method.IsUnmanagedExport) + { + return false; + } + if (method.IsRuntimeSpecialName || method.IsConstructor || method.IsStaticConstructor) + { + return false; + } + // vtable slots, interface contracts and MethodImpl entries: same pin set as + // MemberReorder.IsPositionPinnedMethod. + if (method.IsVirtual || method.IsAbstract || method.HasOverrides || method.IsNewSlot) + { + return false; + } + // PropertyDef/EventDef carry their own signature and nothing keeps them in step. + if (method.SemanticsAttributes != 0) + { + return false; + } + MethodSig sig = method.MethodSig; + if (sig == null || sig.IsVarArg || sig.ParamsAfterSentinel != null) + { + return false; + } + TypeDef declaringType = method.DeclaringType; + if (declaringType == null || declaringType.IsDelegate || declaringType.IsInterface) + { + return false; + } + if (method.Module != null && method.Module.EntryPoint == method) + { + return false; + } + if (method.Parameters.Any(p => p.Type != null && p.Type.ElementType == ElementType.TypedByRef)) + { + return false; + } + // `this` as an explicit signature entry would shift the ldarg remap by one. + if (sig.ExplicitThis) + { + return false; + } + if (HasSignaturePinningAttribute(method)) + { + return false; + } + return _isSafe(method); + } + + private static string TokenKey(ModuleDef module, uint token) + { + return (module?.Name.String ?? "?") + "!" + token.ToString("X8"); + } + + private void IndexReferences(List allModules, Dictionary byToken, + HashSet vetoed, List sites, Dictionary> refsByMethod, + HashSet unresolvedNames) + { + var resolveCache = new Dictionary(); + foreach (ModuleDef mod in allModules) + { + foreach (TypeDef type in mod.GetTypes()) + { + foreach (MethodDef host in type.Methods) + { + if (!host.HasBody) + { + continue; + } + IList instructions = host.Body.Instructions; + for (int i = 0; i < instructions.Count; i++) + { + Instruction inst = instructions[i]; + if (!(inst.Operand is IMethod operand) || !operand.IsMethod) + { + continue; + } + MethodDef resolved = Resolve(operand, resolveCache); + if (resolved == null) + { + if (unresolvedNames.Add(operand.Name)) + { + UnresolvedReferenceCount++; + } + continue; + } + if (!byToken.TryGetValue(TokenKey(resolved.Module, resolved.MDToken.Raw), out MethodDef target)) + { + continue; + } + switch (inst.OpCode.Code) + { + case Code.Call: + case Code.Callvirt: + { + Instruction prev = i > 0 ? instructions[i - 1] : null; + if (prev != null && (prev.OpCode.Code == Code.Constrained || prev.OpCode.Code == Code.Tailcall)) + { + vetoed.Add(target); + break; + } + TypeSig[] argTypes = TryGetCallSiteArgTypes(operand); + if (argTypes == null || argTypes.Length != target.MethodSig.Params.Count) + { + vetoed.Add(target); + break; + } + sites.Add(new CallSite + { + host = host, + inst = inst, + plan = new PadPlan { method = target }, + argTypes = argTypes, + }); + break; + } + // the signature is pinned by a delegate type or handed to reflection. + case Code.Ldftn: + case Code.Ldvirtftn: + case Code.Ldtoken: + case Code.Newobj: + case Code.Jmp: + default: + { + vetoed.Add(target); + break; + } + } + CollectMemberRef(operand, target, refsByMethod); + } + } + } + } + } + + private static void CollectMemberRef(IMethod operand, MethodDef target, Dictionary> refsByMethod) + { + MemberRef memberRef = operand as MemberRef ?? (operand as MethodSpec)?.Method as MemberRef; + if (memberRef == null) + { + return; + } + if (!refsByMethod.TryGetValue(target, out HashSet set)) + { + set = new HashSet(); + refsByMethod.Add(target, set); + } + set.Add(memberRef); + } + + private static MethodDef Resolve(IMethod method, Dictionary cache) + { + if (method is MethodDef def) + { + return def; + } + if (cache.TryGetValue(method, out MethodDef cached)) + { + return cached; + } + MethodDef resolved = null; + try + { + resolved = method.ResolveMethodDef(); + } + catch (Exception) + { + resolved = null; + } + cache.Add(method, resolved); + return resolved; + } + + private static TypeSig[] TryGetCallSiteArgTypes(IMethod operand) + { + try + { + MethodSig sig = MetaUtil.GetInflatedMethodSig(operand, null); + if (sig == null || sig.IsVarArg || sig.ParamsAfterSentinel != null) + { + return null; + } + if (sig.Params.Any(p => p == null || p.ElementType == ElementType.TypedByRef)) + { + return null; + } + return sig.Params.ToArray(); + } + catch (Exception) + { + return null; + } + } + + private PadPlan BuildPlan(MethodDef method) + { + int realCount = method.MethodSig.Params.Count; + int junkCount = _random.Next(_minCount, _maxCount + 1); + int slotCount = realCount + junkCount; + + // choose which slots hold junk + var junkSlots = new HashSet(); + while (junkSlots.Count < junkCount) + { + junkSlots.Add(_random.Next(slotCount)); + } + + // and shuffle the real parameters across the slots left over. Free: the call site + // already spills every real argument to a local and re-pushes it, so an arbitrary + // permutation costs exactly the same instructions as the identity one. Arguments are + // still EVALUATED in source order - only the push order changes - so side effects in + // argument expressions keep their sequence. + var realOrder = new int[realCount]; + for (int i = 0; i < realCount; i++) + { + realOrder[i] = i; + } + for (int i = realCount - 1; i > 0; i--) + { + int j = _random.Next(i + 1); + int tmp = realOrder[i]; + realOrder[i] = realOrder[j]; + realOrder[j] = tmp; + } + + var plan = new PadPlan + { + method = method, + slotToReal = new int[slotCount], + slotKind = new JunkKind[slotCount], + slotValue = new object[slotCount], + realToSlot = new int[realCount], + }; + int nextReal = 0; + for (int slot = 0; slot < slotCount; slot++) + { + if (junkSlots.Contains(slot)) + { + plan.slotToReal[slot] = -1; + JunkKind kind = s_junkKinds[_random.Next(s_junkKinds.Length)]; + plan.slotKind[slot] = kind; + plan.slotValue[slot] = MakeJunkValue(kind); + } + else + { + int real = realOrder[nextReal++]; + plan.slotToReal[slot] = real; + plan.realToSlot[real] = slot; + } + } + plan.foldOp = (FoldOp)_random.Next(6); + plan.zeroOp = (ZeroOp)_random.Next(4); + plan.sink = (SinkKind)_random.Next(3); + + // fold the junk in a shuffled order too, so even the ldarg sequence differs + var junkOrder = new List(); + for (int slot = 0; slot < slotCount; slot++) + { + if (plan.slotToReal[slot] < 0) + { + junkOrder.Add(slot); + } + } + for (int i = junkOrder.Count - 1; i > 0; i--) + { + int j = _random.Next(i + 1); + int tmp = junkOrder[i]; + junkOrder[i] = junkOrder[j]; + junkOrder[j] = tmp; + } + plan.junkFoldOrder = junkOrder.ToArray(); + return plan; + } + + private object MakeJunkValue(JunkKind kind) + { + switch (kind) + { + case JunkKind.Int32: return _random.Next(int.MinValue, int.MaxValue); + case JunkKind.UInt32: return _random.Next(int.MinValue, int.MaxValue); + case JunkKind.Int64: return ((long)_random.Next() << 32) | (uint)_random.Next(); + case JunkKind.Single: return (float)(_random.NextDouble() * 1000.0); + case JunkKind.Double: return _random.NextDouble() * 1000.0; + case JunkKind.Boolean: return _random.Next(2); + case JunkKind.Byte: return _random.Next(256); + case JunkKind.Int16: return _random.Next(short.MinValue, short.MaxValue + 1); + case JunkKind.Char: return _random.Next(char.MaxValue + 1); + default: throw new NotSupportedException(kind.ToString()); + } + } + + private static TypeSig JunkTypeSig(ICorLibTypes corLibTypes, JunkKind kind) + { + switch (kind) + { + case JunkKind.Int32: return corLibTypes.Int32; + case JunkKind.UInt32: return corLibTypes.UInt32; + case JunkKind.Int64: return corLibTypes.Int64; + case JunkKind.Single: return corLibTypes.Single; + case JunkKind.Double: return corLibTypes.Double; + case JunkKind.Boolean: return corLibTypes.Boolean; + case JunkKind.Byte: return corLibTypes.Byte; + case JunkKind.Int16: return corLibTypes.Int16; + case JunkKind.Char: return corLibTypes.Char; + default: throw new NotSupportedException(kind.ToString()); + } + } + + private static Instruction PushJunk(PadPlan plan, int slot) + { + object value = plan.slotValue[slot]; + switch (plan.slotKind[slot]) + { + case JunkKind.Int64: return Instruction.Create(OpCodes.Ldc_I8, (long)value); + case JunkKind.Single: return Instruction.Create(OpCodes.Ldc_R4, (float)value); + case JunkKind.Double: return Instruction.Create(OpCodes.Ldc_R8, (double)value); + default: return Instruction.Create(OpCodes.Ldc_I4, (int)value); + } + } + + private static void ApplyToDefinition(PadPlan plan) + { + MethodDef method = plan.method; + CilBody body = method.Body; + + // ldarg.0 and friends carry no operand, so the remap below cannot see them until + // they are expanded. CleanUpInstructionPass re-compacts afterwards. + body.SimplifyMacros(method.Parameters); + body.SimplifyBranches(); + + int thisOffset = method.HasThis ? 1 : 0; + var oldOperandIndex = new List>(); + foreach (Instruction inst in body.Instructions) + { + if (inst.Operand is Parameter param) + { + oldOperandIndex.Add(new KeyValuePair(inst, param.Index)); + } + } + + ICorLibTypes corLibTypes = method.Module.CorLibTypes; + var oldParams = method.MethodSig.Params.ToList(); + method.MethodSig.Params.Clear(); + for (int slot = 0; slot < plan.SlotCount; slot++) + { + int real = plan.slotToReal[slot]; + method.MethodSig.Params.Add(real >= 0 ? oldParams[real] : JunkTypeSig(corLibTypes, plan.slotKind[slot])); + } + method.Parameters.UpdateParameterTypes(); + + // ParamDef.Sequence is 1 based over the explicit parameters, 0 being the return value. + foreach (ParamDef paramDef in method.ParamDefs) + { + int oldReal = paramDef.Sequence - 1; + if (oldReal >= 0 && oldReal < plan.RealCount) + { + paramDef.Sequence = (ushort)(plan.realToSlot[oldReal] + 1); + } + else if (paramDef.Sequence != 0) + { + // Sequence 0 is the return value and stays. Anything else out of range is + // malformed metadata that would collide with a renumbered entry. + throw new Exception($"parameter padding found ParamDef sequence {paramDef.Sequence} on `{method}`, " + + $"which has {plan.RealCount} parameters."); + } + } + var sortedParamDefs = method.ParamDefs.OrderBy(p => p.Sequence).ToList(); + method.ParamDefs.Clear(); + foreach (ParamDef paramDef in sortedParamDefs) + { + method.ParamDefs.Add(paramDef); + } + method.Parameters.UpdateParameterTypes(); + + // dnlib parameters are addressed by index, so an untouched operand now means a + // different parameter. Every one of them has to be re-pointed. + foreach (var e in oldOperandIndex) + { + int oldIndex = e.Value; + int newIndex; + if (thisOffset == 1 && oldIndex == 0) + { + newIndex = 0; + } + else + { + int oldReal = oldIndex - thisOffset; + newIndex = plan.realToSlot[oldReal] + thisOffset; + } + e.Key.Operand = method.Parameters[newIndex]; + } + + EmitConsumePrologue(plan); + } + + /// + /// Makes the junk parameters load-bearing without making them cost anything. + /// + /// Every step is drawn per method — which operator folds the junk, in which order, how + /// the result is driven to zero, and where the zero is consumed — so there is no single + /// instruction sequence to grep for. That matters more than the individual tricks: a + /// fixed prologue is a fingerprint of the obfuscator, and one script keyed on it strips + /// every junk parameter in the assembly. + /// + /// Whatever the recipe, the result is provably zero and is consumed by a branch or folded + /// into a value the method already returns, so liveness alone cannot delete the parameter + /// loads, while clang folds the arithmetic away during IL2CPP compilation. Only holds + /// while this pass runs after ConstEncrypt, which would otherwise turn the literal + /// constants into VM decrypt calls. + /// + private static void EmitConsumePrologue(PadPlan plan) + { + MethodDef method = plan.method; + CilBody body = method.Body; + int thisOffset = method.HasThis ? 1 : 0; + + if (plan.junkFoldOrder.Length == 0) + { + return; + } + + var prologue = new List(); + bool first = true; + foreach (int slot in plan.junkFoldOrder) + { + Parameter param = method.Parameters[slot + thisOffset]; + prologue.Add(Instruction.Create(OpCodes.Ldarg, param)); + switch (plan.slotKind[slot]) + { + case JunkKind.Int64: + prologue.Add(Instruction.Create(OpCodes.Conv_I4)); + break; + case JunkKind.Single: + prologue.Add(Instruction.Create(OpCodes.Ldc_R4, 0f)); + prologue.Add(Instruction.Create(OpCodes.Ceq)); + break; + case JunkKind.Double: + prologue.Add(Instruction.Create(OpCodes.Ldc_R8, 0d)); + prologue.Add(Instruction.Create(OpCodes.Ceq)); + break; + } + if (!first) + { + prologue.Add(Instruction.Create(FoldOpCode(plan.foldOp))); + } + first = false; + } + + // drive the fold to zero + switch (plan.zeroOp) + { + case ZeroOp.MulZero: + prologue.Add(Instruction.Create(OpCodes.Ldc_I4_0)); + prologue.Add(Instruction.Create(OpCodes.Mul)); + break; + case ZeroOp.AndZero: + prologue.Add(Instruction.Create(OpCodes.Ldc_I4_0)); + prologue.Add(Instruction.Create(OpCodes.And)); + break; + case ZeroOp.DupXor: + prologue.Add(Instruction.Create(OpCodes.Dup)); + prologue.Add(Instruction.Create(OpCodes.Xor)); + break; + case ZeroOp.DupSub: + prologue.Add(Instruction.Create(OpCodes.Dup)); + prologue.Add(Instruction.Create(OpCodes.Sub)); + break; + } + + SinkKind sink = plan.sink; + if (sink == SinkKind.ThreadIntoReturn && !TryThreadIntoReturn(plan, prologue)) + { + sink = SinkKind.BranchPair; + } + if (sink != SinkKind.ThreadIntoReturn) + { + // The branch target has to be an instruction of our own, never the original first + // instruction: in a Release build that is frequently the start of a try block, and + // branching into a protected region is invalid IL. + Instruction resume = Instruction.Create(OpCodes.Nop); + if (sink == SinkKind.SwitchOne) + { + // Instruction[] specifically, not List: that is what dnlib + // produces when reading a body, and what Obfuz's own BasicBlockCollection + // type-checks for when a later pass walks this method. + prologue.Add(new Instruction(OpCodes.Switch, new Instruction[] { resume })); + } + else + { + prologue.Add(Instruction.Create(OpCodes.Brfalse, resume)); + } + prologue.Add(Instruction.Create(OpCodes.Br, resume)); + prologue.Add(resume); + } + + for (int i = prologue.Count - 1; i >= 0; i--) + { + body.Instructions.Insert(0, prologue[i]); + } + } + + private static OpCode FoldOpCode(FoldOp op) + { + switch (op) + { + case FoldOp.Add: return OpCodes.Add; + case FoldOp.Sub: return OpCodes.Sub; + case FoldOp.Mul: return OpCodes.Mul; + case FoldOp.Or: return OpCodes.Or; + case FoldOp.And: return OpCodes.And; + default: return OpCodes.Xor; + } + } + + /// + /// Stashes the zero and adds it into every returned value, so the junk parameters feed a + /// value the method genuinely produces instead of a branch that exists only for them. + /// Returns false when the return type cannot absorb an integer zero, leaving the caller to + /// fall back to a branch sink. + /// + private static bool TryThreadIntoReturn(PadPlan plan, List prologue) + { + MethodDef method = plan.method; + CilBody body = method.Body; + TypeSig retType = method.MethodSig.RetType; + if (retType == null) + { + return false; + } + + OpCode widen; + switch (retType.ElementType) + { + case ElementType.I1: + case ElementType.U1: + case ElementType.I2: + case ElementType.U2: + case ElementType.I4: + case ElementType.U4: + case ElementType.Char: + case ElementType.Boolean: + widen = OpCodes.Nop; + break; + case ElementType.I8: + case ElementType.U8: + widen = OpCodes.Conv_I8; + break; + case ElementType.R4: + widen = OpCodes.Conv_R4; + break; + case ElementType.R8: + widen = OpCodes.Conv_R8; + break; + default: + return false; + } + + var returns = body.Instructions.Where(i => i.OpCode.Code == Code.Ret).ToList(); + if (returns.Count == 0) + { + return false; + } + + var sink = new Local(method.Module.CorLibTypes.Int32); + body.Variables.Add(sink); + prologue.Add(Instruction.Create(OpCodes.Stloc, sink)); + + foreach (Instruction ret in returns) + { + // mutate the ret in place so anything branching to it still runs the fold, then + // re-emit the ret after it. Stack stays balanced on both paths. + var tail = new List { Instruction.Create(OpCodes.Ldloc, sink) }; + if (widen != OpCodes.Nop) + { + tail.Add(Instruction.Create(widen)); + } + tail.Add(Instruction.Create(OpCodes.Add)); + tail.Add(Instruction.Create(OpCodes.Ret)); + + int at = body.Instructions.IndexOf(ret); + ret.OpCode = tail[0].OpCode; + ret.Operand = tail[0].Operand; + for (int k = tail.Count - 1; k >= 1; k--) + { + body.Instructions.Insert(at + 1, tail[k]); + } + } + return true; + } + + private static void ApplyToReference(MemberRef memberRef, PadPlan plan) + { + MethodSig sig = memberRef.MethodSig; + if (sig == null || sig.Params.Count != plan.RealCount) + { + // Skipping here would leave the definition padded and this reference stale, so the + // call site would push the wrong number of arguments. Fail the build instead. + throw new Exception($"parameter padding cannot retarget reference `{memberRef}` of `{plan.method}`: " + + $"expected {plan.RealCount} parameters, found {(sig == null ? "no signature" : sig.Params.Count.ToString())}."); + } + ICorLibTypes corLibTypes = memberRef.Module.CorLibTypes; + var oldParams = sig.Params.ToList(); + sig.Params.Clear(); + for (int slot = 0; slot < plan.SlotCount; slot++) + { + int real = plan.slotToReal[slot]; + sig.Params.Add(real >= 0 ? oldParams[real] : JunkTypeSig(corLibTypes, plan.slotKind[slot])); + } + } + + private static void RewriteCallSites(List sites) + { + foreach (var byHost in sites.GroupBy(s => s.host)) + { + MethodDef host = byHost.Key; + CilBody body = host.Body; + var siteByInst = byHost.ToDictionary(s => s.inst, s => s); + + // inserting instructions can push a short branch out of range. + body.SimplifyBranches(); + + var localPool = new List>(); + var final = new List(body.Instructions.Count + siteByInst.Count * 8); + foreach (Instruction inst in body.Instructions) + { + if (!siteByInst.TryGetValue(inst, out CallSite site)) + { + final.Add(inst); + continue; + } + List output = BuildCallSite(body, site, localPool); + + // the call may be a branch target, so it keeps its identity and becomes the + // first emitted instruction. Same trick as InstructionObfuscationPassBase. + inst.OpCode = output[0].OpCode; + inst.Operand = output[0].Operand; + final.Add(inst); + for (int k = 1; k < output.Count; k++) + { + final.Add(output[k]); + } + } + body.Instructions.Clear(); + foreach (Instruction inst in final) + { + body.Instructions.Add(inst); + } + + } + } + + private static List BuildCallSite(CilBody body, CallSite site, List> localPool) + { + PadPlan plan = site.plan; + OpCode callOpCode = site.inst.OpCode; + IMethod callOperand = (IMethod)site.inst.Operand; + + var output = new List(); + var used = new List(); + var spilled = new Local[plan.RealCount]; + + // arguments are already on the stack in order, so pop them back to front. + for (int real = plan.RealCount - 1; real >= 0; real--) + { + Local local = RentLocal(body, localPool, used, site.argTypes[real]); + used.Add(local); + spilled[real] = local; + output.Add(Instruction.Create(OpCodes.Stloc, local)); + } + for (int slot = 0; slot < plan.SlotCount; slot++) + { + int real = plan.slotToReal[slot]; + output.Add(real >= 0 + ? Instruction.Create(OpCodes.Ldloc, spilled[real]) + : PushJunk(plan, slot)); + } + output.Add(Instruction.Create(callOpCode, callOperand)); + return output; + } + + /// + /// Rents a local of exactly this type that is not already spoken for at this call site. + /// Matching is by type identity, never by TypeSig.FullName: that omits the assembly, so + /// two same-named types from different assemblies would share one wrongly typed local. + /// LocalVariableAllocator.AllocateLocal compares the same way. + /// + private static Local RentLocal(CilBody body, List> localPool, List used, TypeSig type) + { + foreach (List bucket in localPool) + { + if (bucket.Count == 0 || !TypeEqualityComparer.Instance.Equals(bucket[0].Type, type)) + { + continue; + } + foreach (Local candidate in bucket) + { + if (!used.Contains(candidate)) + { + return candidate; + } + } + var extra = new Local(type); + body.Variables.Add(extra); + bucket.Add(extra); + return extra; + } + var local = new Local(type); + body.Variables.Add(local); + localPool.Add(new List { local }); + return local; + } + } +} diff --git a/Editor/ObfusPasses/ParamPad/ParameterPadding.cs.meta b/Editor/ObfusPasses/ParamPad/ParameterPadding.cs.meta new file mode 100644 index 0000000..23e58aa --- /dev/null +++ b/Editor/ObfusPasses/ParamPad/ParameterPadding.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f5ad401929d488695f7edbf9690295f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ObfusPasses/SymbolObfus/Policies/SupportPassPolicy.cs b/Editor/ObfusPasses/SymbolObfus/Policies/SupportPassPolicy.cs index 6602cc9..21cd658 100644 --- a/Editor/ObfusPasses/SymbolObfus/Policies/SupportPassPolicy.cs +++ b/Editor/ObfusPasses/SymbolObfus/Policies/SupportPassPolicy.cs @@ -25,16 +25,18 @@ namespace Obfuz.ObfusPasses.SymbolObfus.Policies internal class SupportPassPolicy : ObfuscationPolicyBase { private readonly ConfigurablePassPolicy _policy; + private readonly ObfuscationPassType _passType; private bool Support(ObfuscationPassType passType) { - return passType.HasFlag(ObfuscationPassType.SymbolObfus); + return passType.HasFlag(_passType); } - public SupportPassPolicy(ConfigurablePassPolicy policy) + public SupportPassPolicy(ConfigurablePassPolicy policy, ObfuscationPassType passType = ObfuscationPassType.SymbolObfus) { _policy = policy; + _passType = passType; } public override bool NeedRename(TypeDef typeDef) diff --git a/Editor/ObfusPasses/SymbolObfus/SymbolRename.cs b/Editor/ObfusPasses/SymbolObfus/SymbolRename.cs index 741ba66..f856094 100644 --- a/Editor/ObfusPasses/SymbolObfus/SymbolRename.cs +++ b/Editor/ObfusPasses/SymbolObfus/SymbolRename.cs @@ -88,13 +88,13 @@ namespace Obfuz.ObfusPasses.SymbolObfus BuildCustomAttributeArguments(); } - public static IObfuscationPolicy CreateDefaultRenamePolicy(List obfuscationRuleFiles, List customPolicyTypes) + public static IObfuscationPolicy CreateDefaultRenamePolicy(List obfuscationRuleFiles, List customPolicyTypes, ObfuscationPassType passType = ObfuscationPassType.SymbolObfus) { var ctx = ObfuscationPassContext.Current; var obfuscateRuleConfig = new ConfigurableRenamePolicy(ctx.coreSettings.assembliesToObfuscate, ctx.modulesToObfuscate, obfuscationRuleFiles); var totalRenamePolicies = new List { - new SupportPassPolicy(ctx.passPolicy), + new SupportPassPolicy(ctx.passPolicy, passType), new SystemRenamePolicy(ctx.obfuzIgnoreScopeComputeCache), new UnityRenamePolicy(), obfuscateRuleConfig, diff --git a/Editor/ObfuscatorBuilder.cs b/Editor/ObfuscatorBuilder.cs index 3f923d9..bb8a89b 100644 --- a/Editor/ObfuscatorBuilder.cs +++ b/Editor/ObfuscatorBuilder.cs @@ -27,6 +27,7 @@ using Obfuz.ObfusPasses.ControlFlowObfus; using Obfuz.ObfusPasses.EvalStackObfus; using Obfuz.ObfusPasses.ExprObfus; using Obfuz.ObfusPasses.FieldEncrypt; +using Obfuz.ObfusPasses.ParamPad; using Obfuz.ObfusPasses.RemoveConstField; using Obfuz.ObfusPasses.SymbolObfus; using Obfuz.ObfusPasses.Watermark; @@ -251,6 +252,12 @@ namespace Obfuz { builder.AddPass(new WatermarkPass(settings.watermarkSettings.ToFacade())); } + // Registered last on purpose: it works in Stop(), which runs in registration order, so + // it must come after CallObfus to see the dispatch proxy bodies. See ParamPadPass.Stop. + if (obfuscationPasses.HasFlag(ObfuscationPassType.ParamPad)) + { + builder.AddPass(new ParamPadPass(settings.paramPadSettings.ToFacade())); + } if (obfuscationPasses.HasFlag(ObfuscationPassType.SymbolObfus)) { builder.AddPass(new SymbolObfusPass(settings.symbolObfusSettings.ToFacade())); diff --git a/Editor/Settings/ObfuzSettings.cs b/Editor/Settings/ObfuzSettings.cs index fa4d3bf..766ebb7 100644 --- a/Editor/Settings/ObfuzSettings.cs +++ b/Editor/Settings/ObfuzSettings.cs @@ -46,6 +46,9 @@ namespace Obfuz.Settings [Tooltip("encryption virtual machine settings")] public EncryptionVMSettings encryptionVMSettings; + [Tooltip("parameter padding settings")] + public ParamPadSettings paramPadSettings; + [Tooltip("symbol obfuscation settings")] public SymbolObfuscationSettings symbolObfusSettings; diff --git a/Editor/Settings/ObfuzSettingsProvider.cs b/Editor/Settings/ObfuzSettingsProvider.cs index d92826d..94675de 100644 --- a/Editor/Settings/ObfuzSettingsProvider.cs +++ b/Editor/Settings/ObfuzSettingsProvider.cs @@ -52,6 +52,7 @@ namespace Obfuz.Settings private SerializedProperty _secretSettings; private SerializedProperty _encryptionVMSettings; + private SerializedProperty _paramPadSettings; private SerializedProperty _symbolObfusSettings; private SerializedProperty _constEncryptSettings; private SerializedProperty _removeConstFieldSettings; @@ -95,6 +96,7 @@ namespace Obfuz.Settings _encryptionVMSettings = _serializedObject.FindProperty("encryptionVMSettings"); + _paramPadSettings = _serializedObject.FindProperty("paramPadSettings"); _symbolObfusSettings = _serializedObject.FindProperty("symbolObfusSettings"); _constEncryptSettings = _serializedObject.FindProperty("constEncryptSettings"); _removeConstFieldSettings = _serializedObject.FindProperty("removeConstFieldSettings"); @@ -128,6 +130,7 @@ namespace Obfuz.Settings EditorGUILayout.PropertyField(_encryptionVMSettings); + EditorGUILayout.PropertyField(_paramPadSettings); EditorGUILayout.PropertyField(_symbolObfusSettings); EditorGUILayout.PropertyField(_constEncryptSettings); EditorGUILayout.PropertyField(_removeConstFieldSettings); diff --git a/Editor/Settings/ParamPadSettings.cs b/Editor/Settings/ParamPadSettings.cs new file mode 100644 index 0000000..99c8b11 --- /dev/null +++ b/Editor/Settings/ParamPadSettings.cs @@ -0,0 +1,72 @@ +// Copyright 2025 Code Philosophy +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using Obfuz.Utils; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Obfuz.Settings +{ + public class ParamPadSettingsFacade + { + public int randomSeed; + public int minCount; + public int maxCount; + public List ruleFiles; + public List customRenamePolicyTypes; + } + + [Serializable] + public class ParamPadSettings + { + [Tooltip("random seed for the junk parameters. 0 draws a fresh seed on every build, so every build has a different signature table")] + public int randomSeed = 0; + + [Tooltip("minimum number of junk parameters added to an eligible method")] + [Range(1, 20)] + public int minCount = 5; + + [Tooltip("maximum number of junk parameters added to an eligible method. Every call to a padded method writes this many extra argument slots, so lower it if a build shows a measurable cost")] + [Range(1, 20)] + public int maxCount = 10; + + [Tooltip("a method is only padded if it is also safe to rename, so these are the symbol obfuscation rule files")] + public string[] ruleFiles; + + [Tooltip("custom rename policy types, same contract as SymbolObfuscationSettings.customRenamePolicyTypes")] + public string[] customRenamePolicyTypes; + + public ParamPadSettingsFacade ToFacade() + { + return new ParamPadSettingsFacade + { + randomSeed = randomSeed, + // an asset serialized before this section existed deserializes as zeros, which + // the transform rejects; clamp rather than fail the build + minCount = Math.Max(1, minCount), + maxCount = Math.Max(Math.Max(1, minCount), maxCount), + ruleFiles = ruleFiles?.ToList() ?? new List(), + customRenamePolicyTypes = customRenamePolicyTypes?.Select(typeName => ReflectionUtil.FindUniqueTypeInCurrentAppDomain(typeName)).ToList() ?? new List(), + }; + } + } +} diff --git a/Editor/Settings/ParamPadSettings.cs.meta b/Editor/Settings/ParamPadSettings.cs.meta new file mode 100644 index 0000000..065d1c7 --- /dev/null +++ b/Editor/Settings/ParamPadSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 82f587ee0f604d9b8cf68acbaa1f631a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests~/ParamPad/.gitignore b/Tests~/ParamPad/.gitignore new file mode 100644 index 0000000..7a1da23 --- /dev/null +++ b/Tests~/ParamPad/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +out/ +out[A-Z]/ +pristine/ +UnityEngine.CoreModule.dll diff --git a/Tests~/ParamPad/Main.cs b/Tests~/ParamPad/Main.cs new file mode 100644 index 0000000..46c0007 --- /dev/null +++ b/Tests~/ParamPad/Main.cs @@ -0,0 +1,483 @@ +// Copyright 2025 Code Philosophy +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using dnlib.DotNet; +using dnlib.DotNet.Emit; +using Obfuz.ObfusPasses.ParamPad; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; + +static class Program +{ + static int failures; + + static void Check(bool ok, string what) + { + Console.WriteLine((ok ? "PASS " : "FAIL ") + what); + if (!ok) failures++; + } + + class DirLoadContext : AssemblyLoadContext + { + private readonly string _dir; + public DirLoadContext(string dir, string name) : base(name, isCollectible: false) { _dir = dir; } + protected override Assembly Load(AssemblyName name) + { + string candidate = Path.Combine(_dir, name.Name + ".dll"); + return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null; + } + } + + static ModuleContext MakeContext(string dir) + { + var ctx = ModuleDef.CreateModuleContext(); + var res = (AssemblyResolver)ctx.AssemblyResolver; + res.EnableTypeDefCache = true; + res.DefaultModuleContext = ctx; + res.PreSearchPaths.Add(Path.GetFullPath(dir)); + res.PreSearchPaths.Add(Path.GetDirectoryName(typeof(object).Assembly.Location)); + res.PostSearchPaths.Add(Path.GetDirectoryName(typeof(object).Assembly.Location)); + return ctx; + } + + // Mirrors the shape of the real predicate: the pass composes whitelist + pass policy + + // rename policy, none of which are structural. Here we only pin by name, so what the + // fixture actually exercises is ParameterPadding.IsCandidate. + static readonly HashSet PolicyPinned = new HashSet { "Reflected" }; + + static bool IsSafe(MethodDef m) => !PolicyPinned.Contains(m.Name); + + // every method the transform must refuse to touch, and why + static readonly Dictionary MustNotPad = new Dictionary + { + { "Fx.Square::Area", "implicit interface impl (virtual newslot)" }, + { "Fx.Square::Describe", "implicit interface impl (virtual newslot)" }, + { "Fx.Square::Sides", "abstract override" }, + { "Fx.Square::Weight", "virtual override" }, + { "Fx.Square::get_Side", "property accessor" }, + { "Fx.Square::set_Side", "property accessor" }, + { "Fx.Square::add_Resized", "event accessor" }, + { "Fx.Square::remove_Resized", "event accessor" }, + { "Fx.Square::.ctor", "constructor" }, + { "Fx.Square::.cctor", "static constructor" }, + { "Fx.ShapeBase::Sides", "abstract" }, + { "Fx.ShapeBase::Weight", "virtual" }, + { "Fx.ShapeBase::.ctor", "constructor" }, + { "Fx.Explicit::Fx.IShape.Area", "explicit interface impl" }, + { "Fx.Explicit::Fx.IShape.Describe", "explicit interface impl" }, + { "Fx.IShape::Area", "interface declaration" }, + { "Fx.IShape::Describe", "interface declaration" }, + { "Fx.Ops::Add", "ldftn delegate target" }, + { "Fx.Ops::Native", "DllImport" }, + { "Fx.Ops::Reflected", "pinned by the safety predicate" }, + { "Fx.Payload::AfterLoad", "[OnDeserialized]: renameable but arity-pinned" }, + { "Fx.Payload::BeforeSave", "[OnSerializing]: renameable but arity-pinned" }, + { "Fx.Combine::Invoke", "delegate member" }, + { "Fx.Combine::.ctor", "delegate member" }, + }; + + // every method that must actually gain junk parameters + static readonly string[] MustPad = + { + "Fx.Square::Scaled", "Fx.Square::Bare", "Fx.Ops::Mul", "Fx.Ops::Neg", "Fx.Ops::Zero", + "Fx.Ops::Clamp", "Fx.Ops::Fact", "Fx.Ops::Split", "Fx.Ops::Pair", "Fx.Ops::Mixed", + "Fx.Ops::Boom", "Fx.Ops::Sum", "Fx.Ops::Add3", "Fx.Ops::UseDelegate", + "Fx.Ops::UseReflection", "Fx.Ops::Guarded", "Fx.Ops::Wrapped", "Fx.Ops::SameName", "Fx.Ops::TakeA", "Fx.Ops::TakeB", "Fx.Ops::Order", "Fx.Ops::Tick", "Fx.Counter::Step", "Fx.Counter::Value", "Fx.Entry::RunAll", + }; + + static string Key(MethodDef m) => m.DeclaringType.FullName + "::" + m.Name; + + static Dictionary SnapshotSigs(ModuleDefMD mod) + { + var d = new Dictionary(); + foreach (TypeDef t in mod.GetTypes()) + foreach (MethodDef m in t.Methods) + d[Key(m)] = m.MethodSig; + return d; + } + + const int MinCount = 5; + const int MaxCount = 10; + + static int Pad(string srcDir, string dstDir, int seed) + { + Directory.CreateDirectory(dstDir); + var ctx = MakeContext(srcDir); + ModuleDefMD fixtureMod = ModuleDefMD.Load(Path.Combine(srcDir, "fixture.dll"), ctx); + ModuleDefMD callerMod = ModuleDefMD.Load(Path.Combine(srcDir, "caller.dll"), ctx); + // Obfuz's AssemblyCache does this for the real pipeline; without it the resolver would + // load a second copy of fixture.dll when caller.dll references it. + var resolver = (AssemblyResolver)ctx.AssemblyResolver; + resolver.AddToCache(fixtureMod.Assembly); + resolver.AddToCache(callerMod.Assembly); + + var padding = new ParameterPadding(seed, MinCount, MaxCount, IsSafe); + padding.Process(new List { fixtureMod }, new List { fixtureMod, callerMod }); + + Verify(fixtureMod, srcDir); + + fixtureMod.Write(Path.Combine(dstDir, "fixture.dll")); + callerMod.Write(Path.Combine(dstDir, "caller.dll")); + foreach (string extra in Directory.GetFiles(srcDir, "*.dll")) + { + string name = Path.GetFileName(extra); + if (name != "fixture.dll" && name != "caller.dll") + File.Copy(extra, Path.Combine(dstDir, name), true); + } + foreach (string cfg in Directory.GetFiles(srcDir, "*.json")) + File.Copy(cfg, Path.Combine(dstDir, Path.GetFileName(cfg)), true); + return padding.PaddedMethodCount; + } + + static void Verify(ModuleDefMD padded, string srcDir) + { + ModuleDefMD original = ModuleDefMD.Load(Path.Combine(srcDir, "fixture.dll"), MakeContext(srcDir)); + Dictionary before = SnapshotSigs(original); + + foreach (var e in MustNotPad) + { + MethodDef m = FindMethod(padded, e.Key); + if (m == null) { Check(false, $"{e.Key} not found in fixture"); continue; } + if (!before.TryGetValue(e.Key, out MethodSig oldSig)) { Check(false, $"{e.Key} missing baseline"); continue; } + Check(m.MethodSig.Params.Count == oldSig.Params.Count, + $"untouched: {e.Key} ({e.Value}) keeps {oldSig.Params.Count} params"); + } + + foreach (string key in MustPad) + { + MethodDef m = FindMethod(padded, key); + if (m == null) { Check(false, $"{key} not found in fixture"); continue; } + int oldCount = before[key].Params.Count; + int added = m.MethodSig.Params.Count - oldCount; + Check(added >= MinCount && added <= MaxCount, + $"padded: {key} gained {added} params (was {oldCount})"); + } + + // every real parameter survives exactly once; its POSITION is free to move, so this is a + // multiset check rather than a subsequence one. + int reorderedMethods = 0; + foreach (string key in MustPad) + { + MethodDef m = FindMethod(padded, key); + if (m == null) continue; + var oldParams = before[key].Params.Select(p => p.FullName).ToList(); + var newParams = m.MethodSig.Params.Select(p => p.FullName).ToList(); + var remaining = new List(newParams); + bool allPresent = oldParams.All(want => remaining.Remove(want)); + Check(allPresent, $"params: {key} still carries every real parameter"); + + // did this one actually get its real parameters shuffled? + int idx = 0; bool inOrder = true; + foreach (string want in oldParams) + { + int at = newParams.IndexOf(want, idx); + if (at < 0) { inOrder = false; break; } + idx = at + 1; + } + if (!inOrder) reorderedMethods++; + } + Check(reorderedMethods > 0, $"order: real parameters are permuted, not just interleaved ({reorderedMethods} methods reordered)"); + + // branching into a protected region is invalid IL per ECMA-335, and ilverify does not + // check it. Release builds put a try at instruction 0, so the consume prologue has to + // branch to an instruction of its own rather than to the original body start. + int branchesIntoTry = 0; + foreach (TypeDef t in padded.GetTypes()) + { + foreach (MethodDef m in t.Methods) + { + if (!m.HasBody || m.Body.ExceptionHandlers.Count == 0) continue; + var idx = new Dictionary(); + for (int i = 0; i < m.Body.Instructions.Count; i++) idx[m.Body.Instructions[i]] = i; + foreach (ExceptionHandler eh in m.Body.ExceptionHandlers) + { + if (eh.TryStart == null || eh.TryEnd == null) continue; + int lo = idx[eh.TryStart], hi = idx[eh.TryEnd]; + for (int i = 0; i < m.Body.Instructions.Count; i++) + { + if (i >= lo && i < hi) continue; + if (m.Body.Instructions[i].Operand is Instruction tgt + && idx.TryGetValue(tgt, out int ti) && ti >= lo && ti < hi) + { + Console.WriteLine($" branch into try: {Key(m)} #{i} -> #{ti}"); + branchesIntoTry++; + } + } + } + } + } + Check(branchesIntoTry == 0, "protected regions: no branch jumps into a try block"); + + // Downstream Obfuz passes walk these bodies with their own analyzers, which type-check + // operands against what dnlib produces on read. A List switch operand is + // valid IL and survives ilverify, but throws in BasicBlockCollection.BuildInOutGraph. + int badOperands = 0; + foreach (TypeDef t in padded.GetTypes()) + foreach (MethodDef m in t.Methods) + if (m.HasBody) + foreach (Instruction inst in m.Body.Instructions) + if (inst.OpCode.Code == Code.Switch && !(inst.Operand is Instruction[])) badOperands++; + Check(badOperands == 0, $"operands: every switch carries Instruction[], as dnlib and Obfuz's analyzers expect ({badOperands} bad)"); + + // the transform must leave every body with a coherent argument count at each call site + foreach (TypeDef t in padded.GetTypes()) + { + foreach (MethodDef m in t.Methods) + { + if (!m.HasBody) continue; + foreach (Instruction inst in m.Body.Instructions) + { + if (inst.Operand is Parameter p) + Check(p.Index < m.Parameters.Count, $"operand: {Key(m)} parameter operand in range"); + } + } + } + } + + static MethodDef FindMethod(ModuleDefMD mod, string key) + { + foreach (TypeDef t in mod.GetTypes()) + foreach (MethodDef m in t.Methods) + if (Key(m) == key) return m; + return null; + } + + static string Invoke(string dir, string tag) + { + var alc = new DirLoadContext(Path.GetFullPath(dir), tag); + Assembly caller = alc.LoadFromAssemblyPath(Path.Combine(Path.GetFullPath(dir), "caller.dll")); + Type entry = caller.GetType("Cl.Caller"); + return (string)entry.GetMethod("RunAll").Invoke(null, null); + } + + // abstract stack simulation: catches an unbalanced path that a single behaviour run may + // simply never take, and that ilverify does not always reach. + static string StackCheck(MethodDef m) + { + CilBody body = m.Body; + IList ins = body.Instructions; + var depth = new int[ins.Count]; + for (int i = 0; i < ins.Count; i++) depth[i] = int.MinValue; + var idx = new Dictionary(); + for (int i = 0; i < ins.Count; i++) idx[ins[i]] = i; + depth[0] = 0; + foreach (ExceptionHandler eh in body.ExceptionHandlers) + { + if (eh.HandlerStart != null && idx.TryGetValue(eh.HandlerStart, out int h)) + depth[h] = eh.HandlerType == ExceptionHandlerType.Finally || eh.HandlerType == ExceptionHandlerType.Fault ? 0 : 1; + if (eh.FilterStart != null && idx.TryGetValue(eh.FilterStart, out int f)) depth[f] = 1; + } + var work = new Stack(); + for (int i = 0; i < ins.Count; i++) if (depth[i] != int.MinValue) work.Push(i); + while (work.Count > 0) + { + int i = work.Pop(); + Instruction inst = ins[i]; + int d = depth[i]; + inst.CalculateStackUsage(out int push, out int pop); + if (pop == -1) d = 0; else d -= pop; + if (d < 0) return $"underflow at #{i} {inst.OpCode}"; + d += push; + foreach (int nxt in Successors(ins, idx, i, inst)) + { + if (nxt < 0 || nxt >= ins.Count) continue; + if (depth[nxt] == int.MinValue) { depth[nxt] = d; work.Push(nxt); } + else if (depth[nxt] != d) return $"mismatch at #{nxt} {ins[nxt].OpCode}: {depth[nxt]} vs {d}"; + } + } + return null; + } + + static IEnumerable Successors(IList ins, Dictionary idx, int i, Instruction inst) + { + FlowControl fc = inst.OpCode.FlowControl; + if (fc != FlowControl.Branch && fc != FlowControl.Return && fc != FlowControl.Throw) yield return i + 1; + if (inst.Operand is Instruction t && idx.TryGetValue(t, out int ti)) yield return ti; + if (inst.Operand is IList ts) foreach (Instruction x in ts) if (idx.TryGetValue(x, out int xi)) yield return xi; + } + + static int BranchesIntoTry(ModuleDefMD mod) + { + int bad = 0; + foreach (TypeDef t in mod.GetTypes()) + { + foreach (MethodDef m in t.Methods) + { + if (!m.HasBody || m.Body.ExceptionHandlers.Count == 0) continue; + var idx = new Dictionary(); + for (int i = 0; i < m.Body.Instructions.Count; i++) idx[m.Body.Instructions[i]] = i; + foreach (ExceptionHandler eh in m.Body.ExceptionHandlers) + { + if (eh.TryStart == null || eh.TryEnd == null) continue; + int lo = idx[eh.TryStart], hi = idx[eh.TryEnd]; + for (int i = 0; i < m.Body.Instructions.Count; i++) + { + if (i >= lo && i < hi) continue; + if (m.Body.Instructions[i].Operand is Instruction tgt + && idx.TryGetValue(tgt, out int ti) && ti >= lo && ti < hi) bad++; + } + } + } + } + return bad; + } + + // The consume code must not be one greppable shape. Fuzz many seeds, prove every one is + // structurally sound, and count how many distinct prologue shapes the recipes produce. + static void Fuzz(string srcDir, int seeds) + { + int stackFailures = 0, tryFailures = 0, padded = 0; + var shapes = new Dictionary(); + int paddedBodies = 0; + for (int seed = 1; seed <= seeds; seed++) + { + var ctx = MakeContext(srcDir); + ModuleDefMD fx = ModuleDefMD.Load(Path.Combine(srcDir, "fixture.dll"), ctx); + ModuleDefMD cl = ModuleDefMD.Load(Path.Combine(srcDir, "caller.dll"), ctx); + var resolver = (AssemblyResolver)ctx.AssemblyResolver; + resolver.AddToCache(fx.Assembly); + resolver.AddToCache(cl.Assembly); + + var pad = new ParameterPadding(seed, MinCount, MaxCount, IsSafe); + pad.Process(new List { fx }, new List { fx, cl }); + padded += pad.PaddedMethodCount; + + paddedBodies += pad.PaddedMethodCount; + foreach (ModuleDefMD mod in new[] { fx, cl }) + { + tryFailures += BranchesIntoTry(mod); + foreach (TypeDef t in mod.GetTypes()) + { + foreach (MethodDef m in t.Methods) + { + if (!m.HasBody || m.Body.Instructions.Count == 0) continue; + if (StackCheck(m) != null) stackFailures++; + } + } + } + foreach (TypeDef t in fx.GetTypes()) + foreach (MethodDef m in t.Methods) + if (m.HasBody && m.Body.Instructions.Count > 6) + foreach (string w in Windows(m, 5)) { shapes.TryGetValue(w, out int c); shapes[w] = c + 1; } + } + Check(stackFailures == 0, $"fuzz: {seeds} seeds, {padded} padded methods, no unbalanced stack ({stackFailures} failures)"); + Check(tryFailures == 0, $"fuzz: no branch into a protected region across {seeds} seeds ({tryFailures} failures)"); + // The old fixed prologue ended `ldc.i4.0 mul brfalse br nop` in EVERY padded method, so one + // 5-instruction grep found all of them. Measure that directly: how much of the padded + // population does the single most common 5-instruction window cover? + int worst = 0; string worstShape = ""; + foreach (var e in shapes) if (e.Value > worst) { worst = e.Value; worstShape = e.Key; } + double share = paddedBodies == 0 ? 0 : 100.0 * worst / paddedBodies; + Check(share < 25.0, + $"fuzz: no single 5-instruction window identifies padded methods (most common covers {share:F1}%: {worstShape})"); + } + + // distinct opcode windows of the given length, deduplicated within one method so a long body + // does not inflate the count + static IEnumerable Windows(MethodDef m, int len) + { + IList ins = m.Body.Instructions; + var seen = new HashSet(); + for (int i = 0; i + len <= ins.Count && i < 40; i++) + { + var w = string.Join(" ", Enumerable.Range(i, len).Select(k => ins[k].OpCode.Name)); + if (seen.Add(w)) yield return w; + } + } + + static int Main() + { + string bin = Path.GetFullPath("caller/bin/Release/net7.0"); + if (!File.Exists(Path.Combine(bin, "caller.dll"))) + { + Console.WriteLine("FAIL caller.dll not built; run run.sh"); + return 1; + } + + string pristine = Path.GetFullPath("pristine"); + if (Directory.Exists(pristine)) Directory.Delete(pristine, true); + Directory.CreateDirectory(pristine); + foreach (string f in Directory.GetFiles(bin)) + File.Copy(f, Path.Combine(pristine, Path.GetFileName(f)), true); + + string expected = Invoke(pristine, "pristine"); + Console.WriteLine("baseline: " + expected); + + int padded = Pad(pristine, "out", 12345); + Check(padded > 0, $"padded {padded} methods"); + + // the written assemblies must round-trip + try + { + ModuleDefMD.Load(Path.GetFullPath("out/fixture.dll"), MakeContext("out")); + ModuleDefMD.Load(Path.GetFullPath("out/caller.dll"), MakeContext("out")); + Check(true, "roundtrip: padded assemblies reload through dnlib"); + } + catch (Exception e) + { + Check(false, "roundtrip: " + e.Message); + } + + // the decisive check: the padded build still behaves identically + try + { + string actual = Invoke("out", "padded"); + Check(actual == expected, "behaviour: padded build produces identical output"); + if (actual != expected) + { + Console.WriteLine(" expected: " + expected); + Console.WriteLine(" actual: " + actual); + } + } + catch (Exception e) + { + Check(false, "behaviour: padded build threw " + e.GetType().Name + ": " + e.Message); + Console.WriteLine(e.ToString()); + } + + // determinism and variation + int a = Pad(pristine, "outA", 777); + int b = Pad(pristine, "outB", 777); + int c = Pad(pristine, "outC", 999); + Check(SigDump("outA") == SigDump("outB"), "seed: the same seed reproduces the same signatures"); + Check(SigDump("outA") != SigDump("outC"), "seed: a different seed produces different signatures"); + Check(Invoke("outC", "outC") == expected, "behaviour: a second seed also behaves identically"); + + Fuzz(pristine, 40); + + Console.WriteLine(failures == 0 ? "ALL PASS" : $"{failures} FAILURES"); + return failures == 0 ? 0 : 1; + } + + static string SigDump(string dir) + { + ModuleDefMD mod = ModuleDefMD.Load(Path.GetFullPath(Path.Combine(dir, "fixture.dll")), MakeContext(dir)); + var lines = new List(); + foreach (TypeDef t in mod.GetTypes()) + foreach (MethodDef m in t.Methods) + lines.Add(Key(m) + "(" + string.Join(",", m.MethodSig.Params.Select(p => p.FullName)) + ")"); + lines.Sort(StringComparer.Ordinal); + return string.Join("\n", lines); + } +} diff --git a/Tests~/ParamPad/caller/Caller.cs b/Tests~/ParamPad/caller/Caller.cs new file mode 100644 index 0000000..de90650 --- /dev/null +++ b/Tests~/ParamPad/caller/Caller.cs @@ -0,0 +1,22 @@ +using System.Text; + +namespace Cl +{ + // Stands in for a nonObfuscatedButReferencingObfuscated assembly: never padded itself, + // but its call sites into the padded assembly must still be fixed up. + public static class Caller + { + public static string RunAll() + { + var sb = new StringBuilder(); + sb.Append(Fx.Entry.RunAll()).Append('#'); + sb.Append(Fx.Ops.Mul(9, 9)).Append(';'); + sb.Append(Fx.Ops.Clamp(500, 0, 99)).Append(';'); + sb.Append(Fx.Ops.Pair(7, 8)).Append(';'); + var sq = new Fx.Square(4); + sb.Append(sq.Scaled(2, 2)).Append(';'); + sb.Append(sq.Bare()).Append(';'); + return sb.ToString(); + } + } +} diff --git a/Tests~/ParamPad/caller/caller.csproj b/Tests~/ParamPad/caller/caller.csproj new file mode 100644 index 0000000..310808c --- /dev/null +++ b/Tests~/ParamPad/caller/caller.csproj @@ -0,0 +1,12 @@ + + + net7.0 + caller + latest + + + + + + + diff --git a/Tests~/ParamPad/fixture/Fixture.cs b/Tests~/ParamPad/fixture/Fixture.cs new file mode 100644 index 0000000..6e2e0fe --- /dev/null +++ b/Tests~/ParamPad/fixture/Fixture.cs @@ -0,0 +1,253 @@ +extern alias LA; +extern alias LB; +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Runtime.Serialization; +using System.Text; + +namespace Fx +{ + public interface IShape + { + int Area(int scale); + string Describe(string prefix); + } + + public delegate int Combine(int a, int b); + + public abstract class ShapeBase + { + public abstract int Sides(); + public virtual int Weight(int density) { return density * 2; } + } + + public class Square : ShapeBase, IShape + { + private int _side; + + // ctor: excluded + public Square(int side) { _side = side; } + static Square() { Origin = 7; } + + public static int Origin; + + // implicit interface impl: virtual newslot, excluded + public int Area(int scale) { return _side * _side * scale; } + public string Describe(string prefix) { return prefix + ":square:" + _side; } + + // abstract impl: excluded + public override int Sides() { return 4; } + // virtual override: excluded + public override int Weight(int density) { return density * 3; } + + // property accessors: excluded + public int Side { get { return _side; } set { _side = value; } } + + // event accessors: excluded + public event Action Resized; + public void RaiseResized(int v) { Resized?.Invoke(v); } + + // ordinary instance method: PADDED + public int Scaled(int factor, int offset) { return _side * factor + offset; } + + // zero arg instance method: PADDED + public int Bare() { return _side; } + } + + public class Explicit : IShape + { + // explicit interface impl: excluded + int IShape.Area(int scale) { return scale; } + string IShape.Describe(string prefix) { return prefix + ":explicit"; } + } + + public static class Ops + { + // taken with ldftn via method group conversion: excluded + public static int Add(int a, int b) { return a + b; } + + // PADDED, ordinary statics + public static int Mul(int a, int b) { return a * b; } + public static int Neg(int a) { return -a; } + public static int Zero() { return 0; } + + // PADDED, called as a nested argument and as a branch target + public static int Clamp(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } + + // PADDED, recursion + public static int Fact(int n) { return n <= 1 ? 1 : n * Fact(n - 1); } + + // PADDED, byref parameters + public static void Split(int v, out int lo, ref int hi, in int bump) + { + lo = v & 0xFF; + hi = (v >> 8) + bump; + } + + // PADDED, generic + public static string Pair(T a, T b) { return a + "|" + b; } + + // PADDED, many argument types + public static string Mixed(byte b, short s, long l, float f, double d, char c, bool t, string str) + { + return b + "/" + s + "/" + l + "/" + f.ToString("F1") + "/" + d.ToString("F1") + "/" + c + "/" + t + "/" + str; + } + + // PADDED, called inside a try/catch and throws + public static int Boom(int v) { if (v > 0) throw new InvalidOperationException("boom" + v); return v; } + + // PADDED, loop with a backward branch over a call + public static int Sum(int n) + { + int acc = 0; + for (int i = 0; i < n; i++) { acc = Add3(acc, i); } + return acc; + } + + // PADDED, called from the loop above + public static int Add3(int a, int b) { return a + b; } + + // whole body wrapped in try/catch: the consume prologue must not branch into the + // protected region + public static int Guarded(int v) + { + try { return 100 / v; } + catch (DivideByZeroException) { return -1; } + finally { Touched++; } + } + + public static int Touched; + + // try block starting at the very first instruction, with a nested call + public static string Wrapped(int a, int b) + { + try { return "w" + Mul(a, b); } + catch (Exception e) { return e.Message; } + } + + // Two DIFFERENT types that share a namespace-qualified name. Spilling both at the same + // ordinal must not reuse one local: TypeSig.FullName omits the assembly, so keying on it + // would type the second spill as the first type. + public static int TakeA(LA::Shared.Thing t, int n) { return t.V + n; } + public static int TakeB(LB::Shared.Thing t, int n) { return t.V * n; } + + public static int SameName() + { + return TakeA(new LA::Shared.Thing(1), 2) + TakeB(new LB::Shared.Thing(3), 4); + } + + // Arguments must still be EVALUATED left to right even though they are PUSHED in a + // permuted order. Tick records evaluation order; Order records value routing. + public static string OrderLog = ""; + public static int Tick(int n) { OrderLog += n; return n; } + public static int Order(int a, int b, int c) { return a * 100 + b * 10 + c; } + + // ldtoken / reflection target: excluded + public static int Reflected(int a) { return a + 1000; } + + public static string UseReflection() + { + var m = typeof(Ops).GetMethod("Reflected"); + return m == null ? "null" : m.Name + ":" + m.GetParameters().Length; + } + + // [DllImport]: excluded (no body, never a candidate) + [DllImport("nonexistent", EntryPoint = "never_called")] + public static extern int Native(int a); + + public static int UseDelegate(int a, int b) + { + Combine c = Add; // ldftn Ops::Add + Func lam = x => x * 5; // ldftn on the lambda + return c(a, b) + lam(a); + } + } + + [Serializable] + public class Payload + { + public int Value; + public static int AfterCount; + + // located by attribute, so the rename policy permits renaming it - but BinaryFormatter and + // Newtonsoft both validate the signature, so its argument list must not change. + [OnDeserialized] + internal void AfterLoad(StreamingContext ctx) { AfterCount++; } + + [OnSerializing] + internal void BeforeSave(StreamingContext ctx) { AfterCount += 2; } + } + + public class Counter + { + private int _n; + public int Step(int by) { _n += by; return _n; } + public int Value() { return _n; } + } + + public static class Entry + { + public static string RunAll() + { + var sb = new StringBuilder(); + var sq = new Square(5); + sb.Append(sq.Area(2)).Append(';'); + sb.Append(sq.Describe("p")).Append(';'); + sb.Append(sq.Sides()).Append(';'); + sb.Append(sq.Weight(4)).Append(';'); + sq.Side = 6; + sb.Append(sq.Side).Append(';'); + sb.Append(sq.Scaled(3, 1)).Append(';'); + sb.Append(sq.Bare()).Append(';'); + sb.Append(Square.Origin).Append(';'); + + int captured = -1; + sq.Resized += v => captured = v; + sq.RaiseResized(42); + sb.Append(captured).Append(';'); + + IShape ex = new Explicit(); + sb.Append(ex.Area(9)).Append(';'); + sb.Append(ex.Describe("q")).Append(';'); + + sb.Append(Ops.Mul(6, 7)).Append(';'); + sb.Append(Ops.Neg(11)).Append(';'); + sb.Append(Ops.Zero()).Append(';'); + // nested call as an argument, exercises overlapping spills + sb.Append(Ops.Clamp(Ops.Mul(3, 40), 10, 100)).Append(';'); + sb.Append(Ops.Clamp(5, Ops.Neg(-20), Ops.Mul(5, 5))).Append(';'); + sb.Append(Ops.Fact(6)).Append(';'); + sb.Append(Ops.Sum(10)).Append(';'); + + int lo, hi = 3, bump = 4; + Ops.Split(0x1234, out lo, ref hi, in bump); + sb.Append(lo).Append(',').Append(hi).Append(';'); + + sb.Append(Ops.Pair(1, 2)).Append(';'); + sb.Append(Ops.Pair("a", "b")).Append(';'); + sb.Append(Ops.Mixed(1, -2, 3L, 4.5f, 6.5, 'z', true, "s")).Append(';'); + + try { Ops.Boom(3); } + catch (InvalidOperationException e) { sb.Append(e.Message).Append(';'); } + sb.Append(Ops.Boom(0)).Append(';'); + + Ops.OrderLog = ""; + int ordered = Ops.Order(Ops.Tick(1), Ops.Tick(2), Ops.Tick(3)); + sb.Append(ordered).Append(',').Append(Ops.OrderLog).Append(';'); + sb.Append(Ops.SameName()).Append(';'); + sb.Append(Ops.Guarded(4)).Append(';'); + sb.Append(Ops.Guarded(0)).Append(';'); + sb.Append(Ops.Touched).Append(';'); + sb.Append(Ops.Wrapped(3, 4)).Append(';'); + sb.Append(Ops.UseDelegate(2, 3)).Append(';'); + sb.Append(Ops.UseReflection()).Append(';'); + + var c = new Counter(); + for (int i = 0; i < 4; i++) { c.Step(i); } + sb.Append(c.Value()).Append(';'); + + return sb.ToString(); + } + } +} diff --git a/Tests~/ParamPad/fixture/fixture.csproj b/Tests~/ParamPad/fixture/fixture.csproj new file mode 100644 index 0000000..59af239 --- /dev/null +++ b/Tests~/ParamPad/fixture/fixture.csproj @@ -0,0 +1,13 @@ + + + net7.0 + fixture + latest + CS0649;CS0067;CS0414 + + + + + + + diff --git a/Tests~/ParamPad/libA/Thing.cs b/Tests~/ParamPad/libA/Thing.cs new file mode 100644 index 0000000..fc8c92b --- /dev/null +++ b/Tests~/ParamPad/libA/Thing.cs @@ -0,0 +1 @@ +namespace Shared { public class Thing { public int V; public Thing(int v) { V = v; } } } diff --git a/Tests~/ParamPad/libA/libA.csproj b/Tests~/ParamPad/libA/libA.csproj new file mode 100644 index 0000000..03d5bf4 --- /dev/null +++ b/Tests~/ParamPad/libA/libA.csproj @@ -0,0 +1,3 @@ + + net7.0libA + diff --git a/Tests~/ParamPad/libB/Thing.cs b/Tests~/ParamPad/libB/Thing.cs new file mode 100644 index 0000000..fc8c92b --- /dev/null +++ b/Tests~/ParamPad/libB/Thing.cs @@ -0,0 +1 @@ +namespace Shared { public class Thing { public int V; public Thing(int v) { V = v; } } } diff --git a/Tests~/ParamPad/libB/libB.csproj b/Tests~/ParamPad/libB/libB.csproj new file mode 100644 index 0000000..c35fb2d --- /dev/null +++ b/Tests~/ParamPad/libB/libB.csproj @@ -0,0 +1,3 @@ + + net7.0libB + diff --git a/Tests~/ParamPad/probe.csproj b/Tests~/ParamPad/probe.csproj new file mode 100644 index 0000000..8e57237 --- /dev/null +++ b/Tests~/ParamPad/probe.csproj @@ -0,0 +1,26 @@ + + + Exe + net7.0 + disable + probe + probe + false + CS0168;CS0219;CS0414;CS1998;CS0162 + latest + + + ../../Plugins/dnlib.dll + + + + + + + + + + + + + diff --git a/Tests~/ParamPad/run.sh b/Tests~/ParamPad/run.sh new file mode 100755 index 0000000..f3e4eb3 --- /dev/null +++ b/Tests~/ParamPad/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +# Release: the shape that ships, and the one where a try block can start at instruction 0 +dotnet build libA/libA.csproj -c Release -v q --nologo +dotnet build libB/libB.csproj -c Release -v q --nologo +dotnet build fixture/fixture.csproj -c Release -v q --nologo +dotnet build caller/caller.csproj -c Release -v q --nologo +dotnet build probe.csproj -v q --nologo +dotnet bin/Debug/net7.0/probe.dll + +ilverify=$(command -v ilverify || echo "$HOME/.dotnet/tools/ilverify") +if [ -x "$ilverify" ]; then + refs=$(dirname "$(find /usr/share/dotnet/shared/Microsoft.NETCore.App/7.* -name System.Private.CoreLib.dll 2>/dev/null | head -1)") + for dll in out/fixture.dll out/caller.dll; do + "$ilverify" "$dll" -r "$refs/*.dll" -r "out/*.dll" + done +else + echo "SKIP ilverify not installed (dotnet tool install -g dotnet-ilverify --version 7.0.0)" +fi diff --git a/Tests~/ParamPad/stubs.cs b/Tests~/ParamPad/stubs.cs new file mode 100644 index 0000000..935c411 --- /dev/null +++ b/Tests~/ParamPad/stubs.cs @@ -0,0 +1,34 @@ +// Copyright 2025 Code Philosophy +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace UnityEngine.Assertions { + public static class Assert { + public static void IsTrue(bool c) { if(!c) throw new System.Exception("assert"); } + public static void IsTrue(bool c, string m) { if(!c) throw new System.Exception(m); } + public static void IsNotNull(object o) { if(o==null) throw new System.Exception("null"); } + } +} +namespace UnityEngine { + public static class Debug { + public static void Log(object o) { System.Console.WriteLine(o); } + public static void LogWarning(object o) { System.Console.WriteLine(o); } + public static void LogError(object o) { System.Console.WriteLine(o); } + } +} diff --git a/Tests~/ParamPad/unitystub/Unity.cs b/Tests~/ParamPad/unitystub/Unity.cs new file mode 100644 index 0000000..6178eaa --- /dev/null +++ b/Tests~/ParamPad/unitystub/Unity.cs @@ -0,0 +1,30 @@ +// Copyright 2025 Code Philosophy +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +namespace UnityEngine +{ + public class Object { } + public class Component : Object { } + public class Behaviour : Component { } + public class MonoBehaviour : Behaviour { } + public class ScriptableObject : Object { } + [System.AttributeUsage(System.AttributeTargets.Method)] + public class RuntimeInitializeOnLoadMethodAttribute : System.Attribute { } +} diff --git a/Tests~/ParamPad/unitystub/unitystub.csproj b/Tests~/ParamPad/unitystub/unitystub.csproj new file mode 100644 index 0000000..cda1142 --- /dev/null +++ b/Tests~/ParamPad/unitystub/unitystub.csproj @@ -0,0 +1,6 @@ + + + netstandard2.0 + UnityEngine.CoreModule + +