diff --git a/Directory.Build.props b/Directory.Build.props index f9af8ba..0896f1e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.8.0 + 1.8.1 Bars MLVScan diff --git a/MLVScan.Core.Tests/Integration/NewSampleThreatFamilyTests.cs b/MLVScan.Core.Tests/Integration/NewSampleThreatFamilyTests.cs new file mode 100644 index 0000000..bc713ae --- /dev/null +++ b/MLVScan.Core.Tests/Integration/NewSampleThreatFamilyTests.cs @@ -0,0 +1,87 @@ +using FluentAssertions; +using MLVScan.Services; +using MLVScan.Services.ThreatIntel; +using Xunit; + +namespace MLVScan.Core.Tests.Integration; + +public class NewSampleThreatFamilyTests +{ + [SkippableTheory] + [InlineData(@"BetterPatrols1\bin\Win64_Shipping_Client\BetterPatrols.dll.di", "family-pawns-app-dropper-v1", "archive-userprofile-runkey-hidden-launch")] + [InlineData(@"BetterPatrols2\bin\Win64_Shipping_Client\BetterPatrols.dll.di", "family-pawns-app-dropper-v1", "archive-userprofile-runkey-hidden-launch")] + [InlineData(@"BloodAndBanners\bin\Win64_Shipping_Client\BloodAndBanners.Core.dll.di", "family-pawns-app-dropper-v1", "archive-userprofile-runkey-hidden-launch")] + [InlineData(@"Polygamy\ValleyPolygamy.dll.di", "family-blockchain-java-stager-v1", "evm-resolved-jar-runas")] + public void Scan_NewMaliciousSample_WithoutHashEvidence_ShouldMatchBehaviorFamily( + string relativePath, + string expectedFamilyId, + string expectedVariantId) + { + string path = GetSamplePath(relativePath); + var scanner = new AssemblyScanner(RuleFactory.CreateDefaultRules()); + + var findings = scanner.Scan(path).ToList(); + var matches = new ThreatFamilyClassifier().Classify(findings, sha256Hash: null); + + matches.Should().Contain(match => + match.FamilyId == expectedFamilyId && + match.VariantId == expectedVariantId && + !match.ExactHashMatch, + "the confirmed sample must be recognized from behavior before an exact hash is added"); + } + + [SkippableFact] + public void Scan_BloodAndBannersWrapper_WithoutCoreCompanion_ShouldRemainClean() + { + string path = GetSamplePath(@"BloodAndBanners\bin\Win64_Shipping_Client\BloodAndBanners.dll.di"); + var scanner = new AssemblyScanner(RuleFactory.CreateDefaultRules()); + + var findings = scanner.Scan(path).ToList(); + var matches = new ThreatFamilyClassifier().Classify(findings, sha256Hash: null); + + findings.Should().BeEmpty("the wrapper has no retained malicious behavior in isolation"); + matches.Should().BeEmpty("the malicious behavior resides in BloodAndBanners.Core.dll"); + } + + [SkippableTheory] + [InlineData(@"BetterPatrols1\bin\Win64_Shipping_Client\BetterPatrols.dll.di", "family-pawns-app-dropper-v1")] + [InlineData(@"BetterPatrols2\bin\Win64_Shipping_Client\BetterPatrols.dll.di", "family-pawns-app-dropper-v1")] + [InlineData(@"BloodAndBanners\bin\Win64_Shipping_Client\BloodAndBanners.Core.dll.di", "family-pawns-app-dropper-v1")] + [InlineData(@"Polygamy\ValleyPolygamy.dll.di", "family-blockchain-java-stager-v1")] + public void Scan_NewMaliciousSample_WithHashEvidence_ShouldBeExactKnownThreat( + string relativePath, + string expectedFamilyId) + { + string path = GetSamplePath(relativePath); + byte[] bytes = File.ReadAllBytes(path); + var scanner = new AssemblyScanner(RuleFactory.CreateDefaultRules()); + + var dto = ScanResultMapper.ToDto(scanner.Scan(path).ToList(), Path.GetFileName(path), bytes, false); + + dto.ThreatFamilies.Should().Contain(match => + match.FamilyId == expectedFamilyId && + match.VariantId == "exact-known-sample" && + match.ExactHashMatch); + dto.Disposition.Should().NotBeNull(); + dto.Disposition!.Classification.Should().Be("KnownThreat"); + } + + private static string GetSamplePath(string relativePath) + { + string? current = Directory.GetCurrentDirectory(); + + while (current != null) + { + string candidate = Path.Combine(current, "TO_ANALYZE", relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + current = Directory.GetParent(current)?.FullName; + } + + Skip.If(true, $"Static sample not found in TO_ANALYZE: {relativePath}"); + return string.Empty; + } +} diff --git a/MLVScan.Core.Tests/Integration/ThreatFamilyQuarantineTests.cs b/MLVScan.Core.Tests/Integration/ThreatFamilyQuarantineTests.cs index c514803..ad407de 100644 --- a/MLVScan.Core.Tests/Integration/ThreatFamilyQuarantineTests.cs +++ b/MLVScan.Core.Tests/Integration/ThreatFamilyQuarantineTests.cs @@ -25,6 +25,7 @@ public class ThreatFamilyQuarantineTests "PlayMakerX.dll.di", "RealRadio.dll.di", "RentalCars.dll.di", + "newauth.dll.di", "S1API.Il2Cpp.MelonLoader.dll.di", "ScheduleIMoreNpcs.dll.di", "Skitching.dll.di", @@ -41,6 +42,11 @@ public class ThreatFamilyQuarantineTests "malware-clean-with-findings" ]; + private static readonly HashSet RecursiveSamplesAwaitingBehaviorModel = new(StringComparer.OrdinalIgnoreCase) + { + @"malware-suspicious-with-findings\RexonV5Menu.dll.di" + }; + private readonly ITestOutputHelper _output; private readonly string? _quarantineFolder; @@ -64,6 +70,7 @@ public ThreatFamilyQuarantineTests(ITestOutputHelper output) [InlineData("MelonLoaderMod55.dll.di", "family-webdownload-stage-exec-v3")] [InlineData("NoPolice.dll.di", "family-webdownload-stage-exec-v3")] [InlineData("RentalCars.dll.di", "family-webdownload-stage-exec-v3")] + [InlineData("newauth.dll.di", "family-obfuscated-metadata-loader-v2")] [InlineData("ScheduleIMoreNpcs.dll.di", "family-obfuscated-metadata-loader-v2")] [InlineData("Skitching.dll.di", "family-webdownload-stage-exec-v3")] [InlineData("StorageHub.dll.di", "family-webdownload-stage-exec-v3")] @@ -90,6 +97,23 @@ public void Scan_QuarantineSample_ShouldEmitExpectedThreatFamily(string filename WriteThreatFamilyLog(filename, dto.ThreatFamilies!, dto.Findings); } + [SkippableTheory] + [InlineData(@"malware-suspicious-with-findings\RexonV5Menu.dll.di")] + public void Scan_QuarantineSampleAwaitingBehaviorModel_ShouldRetainFindingsWithoutFamily(string filename) + { + var path = GetSamplePath(filename); + var assemblyBytes = File.ReadAllBytes(path); + var scanner = new AssemblyScanner(RuleFactory.CreateDefaultRules()); + + var findings = scanner.Scan(path).ToList(); + var dto = ScanResultMapper.ToDto(findings, Path.GetFileName(path), assemblyBytes, false); + + dto.Findings.Should().NotBeEmpty(); + dto.ThreatFamilies.Should().BeNullOrEmpty(); + dto.Disposition.Should().NotBeNull(); + dto.Disposition!.Classification.Should().NotBe("KnownThreat"); + } + [SkippableTheory] [InlineData("DynamicOrders.dll.di", "webdownload-temp-ps1-hidden-powershell")] [InlineData("LongLastingFertilizer.dll.di", "webdownload-temp-ps1-hidden-powershell")] @@ -229,6 +253,11 @@ public void Scan_RecursiveQuarantineSamplesWithBehaviorEvidence_ShouldNotClassif foreach (var path in samplePaths) { var relativePath = Path.GetRelativePath(_quarantineFolder!, path); + if (RecursiveSamplesAwaitingBehaviorModel.Contains(relativePath)) + { + continue; + } + var assemblyBytes = File.ReadAllBytes(path); var findings = scanner.Scan(path).ToList(); var dto = ScanResultMapper.ToDto(findings, Path.GetFileName(path), assemblyBytes, false); diff --git a/MLVScan.Core.Tests/Unit/Rules/CoordinatedPayloadDeliveryRuleTests.cs b/MLVScan.Core.Tests/Unit/Rules/CoordinatedPayloadDeliveryRuleTests.cs new file mode 100644 index 0000000..46aff88 --- /dev/null +++ b/MLVScan.Core.Tests/Unit/Rules/CoordinatedPayloadDeliveryRuleTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using MLVScan.Models; +using MLVScan.Models.Rules; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace MLVScan.Core.Tests.Unit.Rules; + +public class CoordinatedPayloadDeliveryRuleTests +{ + private readonly CoordinatedPayloadDeliveryRule _rule = new(); + + [Fact] + public void PostAnalysisRefine_ArchiveAutorunHiddenLaunch_ReturnsFinding() + { + using var assembly = CreateArchiveInstaller(includeRegistryPersistence: true); + var existing = new[] + { + ProcessFinding("CreateNoWindow=true, WindowStyle=Hidden, Arguments: --hidden") + }; + + var findings = _rule.PostAnalysisRefine(assembly.MainModule, existing).ToList(); + + findings.Should().ContainSingle(finding => + finding.RuleId == "CoordinatedPayloadDeliveryRule" && + finding.Description.Contains("archive payload delivery", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void PostAnalysisRefine_ArchiveUpdaterWithoutPersistence_ReturnsNoFinding() + { + using var assembly = CreateArchiveInstaller(includeRegistryPersistence: false); + var existing = new[] + { + ProcessFinding("CreateNoWindow=true, WindowStyle=Hidden, Arguments: --hidden") + }; + + var findings = _rule.PostAnalysisRefine(assembly.MainModule, existing).ToList(); + + findings.Should().BeEmpty(); + } + + [Fact] + public void PostAnalysisRefine_ArchiveInstallerWithVisibleLaunch_ReturnsNoFinding() + { + using var assembly = CreateArchiveInstaller(includeRegistryPersistence: true); + var existing = new[] { ProcessFinding("Target: updater.exe") }; + + var findings = _rule.PostAnalysisRefine(assembly.MainModule, existing).ToList(); + + findings.Should().BeEmpty(); + } + + private static AssemblyDefinition CreateArchiveInstaller(bool includeRegistryPersistence) + { + var assembly = AssemblyDefinition.CreateAssembly( + new AssemblyNameDefinition("ArchiveInstaller", new Version(1, 0)), + "ArchiveInstaller", + ModuleKind.Dll); + var module = assembly.MainModule; + var type = new TypeDefinition("Test.Sample", "Installer", TypeAttributes.Public, module.TypeSystem.Object); + module.Types.Add(type); + + var method = new MethodDefinition("Install", MethodAttributes.Public | MethodAttributes.Static, module.TypeSystem.Void); + method.Body = new MethodBody(method); + type.Methods.Add(method); + var il = method.Body.GetILProcessor(); + il.Emit(OpCodes.Ldstr, "http://198.51.100.8/payload.zip"); + il.Emit(OpCodes.Call, Method(module, "System.Net", "HttpWebRequest", "GetResponse")); + il.Emit(OpCodes.Call, Method(module, "System.IO", "File", "Create")); + il.Emit(OpCodes.Call, Method(module, "System.IO.Compression", "ZipFile", "ExtractToDirectory")); + il.Emit(OpCodes.Call, Method(module, "System", "Environment", "GetFolderPath")); + if (includeRegistryPersistence) + { + il.Emit(OpCodes.Ldstr, @"Software\Microsoft\Windows\CurrentVersion\Run"); + il.Emit(OpCodes.Call, Method(module, "Microsoft.Win32", "RegistryKey", "SetValue")); + } + il.Emit(OpCodes.Call, Method(module, "System.Diagnostics", "Process", "Start")); + il.Emit(OpCodes.Ret); + return assembly; + } + + private static MethodReference Method(ModuleDefinition module, string ns, string type, string name) + { + return new MethodReference( + name, + module.TypeSystem.Void, + new TypeReference(ns, type, module, module.TypeSystem.CoreLibrary)); + } + + private static ScanFinding ProcessFinding(string description) + { + return new ScanFinding("Test.Sample.Installer.Install", description, Severity.High, description) + { + RuleId = "ProcessStartRule" + }; + } +} diff --git a/MLVScan.Core.Tests/Unit/Rules/EmbeddedArchivePayloadRuleTests.cs b/MLVScan.Core.Tests/Unit/Rules/EmbeddedArchivePayloadRuleTests.cs new file mode 100644 index 0000000..851c12f --- /dev/null +++ b/MLVScan.Core.Tests/Unit/Rules/EmbeddedArchivePayloadRuleTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; +using MLVScan.Models; +using MLVScan.Models.Rules; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace MLVScan.Core.Tests.Unit.Rules; + +public class EmbeddedArchivePayloadRuleTests +{ + private readonly EmbeddedArchivePayloadRule _rule = new(); + + [Fact] + public void PostAnalysisRefine_EmbeddedZipWrittenBesideConcealedProcess_ReturnsFinding() + { + using var assembly = CreateAssemblyWithEmbeddedArchive(); + var existing = new[] + { + new ScanFinding("Test.Loader.Run", "Controlled child process with redirected I/O", Severity.Medium, null) + { + RuleId = "ProcessStartRule" + } + }; + + var findings = _rule.PostAnalysisRefine(assembly.MainModule, existing).ToList(); + + findings.Should().ContainSingle(finding => + finding.RuleId == "EmbeddedArchivePayloadRule" && + finding.Description.Contains("ZIP/JAR", StringComparison.Ordinal)); + } + + [Fact] + public void PostAnalysisRefine_EmbeddedZipUsedAsOrdinaryAsset_ReturnsNoFinding() + { + using var assembly = CreateAssemblyWithEmbeddedArchive(); + + var findings = _rule.PostAnalysisRefine(assembly.MainModule, []).ToList(); + + findings.Should().BeEmpty(); + } + + private static AssemblyDefinition CreateAssemblyWithEmbeddedArchive() + { + var assembly = AssemblyDefinition.CreateAssembly( + new AssemblyNameDefinition("EmbeddedArchive", new Version(1, 0)), + "EmbeddedArchive", + ModuleKind.Dll); + var module = assembly.MainModule; + var type = new TypeDefinition("Test", "Loader", TypeAttributes.Public, module.TypeSystem.Object); + module.Types.Add(type); + + var archive = Enumerable.Repeat((byte)0x41, 512).ToArray(); + archive[0] = 0x50; + archive[1] = 0x4b; + archive[2] = 0x03; + archive[3] = 0x04; + var blobType = new TypeDefinition( + string.Empty, + "__StaticArrayInitTypeSize=512", + TypeAttributes.NestedPrivate | TypeAttributes.Sealed | TypeAttributes.ExplicitLayout, + new TypeReference("System", "ValueType", module, module.TypeSystem.CoreLibrary)) + { + ClassSize = archive.Length, + PackingSize = 1 + }; + type.NestedTypes.Add(blobType); + var field = new FieldDefinition( + "ArchiveBytes", + FieldAttributes.Static | FieldAttributes.Assembly | FieldAttributes.HasFieldRVA, + blobType) + { + InitialValue = archive + }; + type.Fields.Add(field); + + var method = new MethodDefinition( + "Write", + MethodAttributes.Public | MethodAttributes.Static, + module.TypeSystem.Void); + method.Body = new MethodBody(method); + type.Methods.Add(method); + var fileType = new TypeReference("System.IO", "File", module, module.TypeSystem.CoreLibrary); + var write = new MethodReference("WriteAllBytes", module.TypeSystem.Void, fileType); + method.Body.GetILProcessor().Emit(OpCodes.Ldstr, "payload.jar"); + method.Body.GetILProcessor().Emit(OpCodes.Call, write); + method.Body.GetILProcessor().Emit(OpCodes.Ret); + return assembly; + } +} diff --git a/MLVScan.Core.Tests/Unit/Rules/RuleFactoryTests.cs b/MLVScan.Core.Tests/Unit/Rules/RuleFactoryTests.cs index 3c22287..9265fbf 100644 --- a/MLVScan.Core.Tests/Unit/Rules/RuleFactoryTests.cs +++ b/MLVScan.Core.Tests/Unit/Rules/RuleFactoryTests.cs @@ -22,8 +22,8 @@ public void CreateDefaultRules_ReturnsExpectedCount() { var rules = RuleFactory.CreateDefaultRules(); - // Based on RuleFactory.cs, there are 19 rules. - rules.Should().HaveCount(19); + // Based on RuleFactory.cs, there are 21 rules. + rules.Should().HaveCount(21); } [Fact] @@ -49,6 +49,8 @@ public void CreateDefaultRules_ContainsAllExpectedRuleTypes() rules.Should().ContainSingle(r => r is SuspiciousLocalVariableRule); rules.Should().ContainSingle(r => r is ObfuscatedReflectiveExecutionRule); rules.Should().ContainSingle(r => r is EmbeddedResourceScriptRule); + rules.Should().ContainSingle(r => r is EmbeddedArchivePayloadRule); + rules.Should().ContainSingle(r => r is CoordinatedPayloadDeliveryRule); rules.Should().ContainSingle(r => r is SuspiciousAssemblyNameRule); } @@ -111,8 +113,8 @@ public void CreateDefaultRulesWith_AppendsAdditionalRulesAfterBuiltInRules() var rules = RuleFactory.CreateDefaultRulesWith(customRule); - rules.Should().HaveCount(20); - rules.Take(19).Should().ContainSingle(r => r is Base64Rule); + rules.Should().HaveCount(22); + rules.Take(21).Should().ContainSingle(r => r is Base64Rule); rules[^1].Should().BeSameAs(customRule); rules.Should().BeAssignableTo>(); } diff --git a/MLVScan.Core.Tests/Unit/Services/DataFlowOperationClassifierTests.cs b/MLVScan.Core.Tests/Unit/Services/DataFlowOperationClassifierTests.cs index 0388a5f..7ead50c 100644 --- a/MLVScan.Core.Tests/Unit/Services/DataFlowOperationClassifierTests.cs +++ b/MLVScan.Core.Tests/Unit/Services/DataFlowOperationClassifierTests.cs @@ -9,6 +9,32 @@ namespace MLVScan.Core.Tests.Unit.Services; public class DataFlowOperationClassifierTests { + [Fact] + public void IdentifyInterestingOperations_ZipExtraction_IsArchiveTransform() + { + var method = CreateCallerMethod(out var module); + var il = method.Body.GetILProcessor(); + var zipFileType = CreateTypeReference(module, "System.IO.Compression", "ZipFile"); + var extract = CreateMethodReference( + zipFileType, + "ExtractToDirectory", + module.TypeSystem.Void, + hasThis: false, + module.TypeSystem.String, + module.TypeSystem.String); + il.Emit(OpCodes.Ldstr, "payload.zip"); + il.Emit(OpCodes.Ldstr, "staging"); + il.Emit(OpCodes.Call, extract); + il.Emit(OpCodes.Ret); + + var operations = new DataFlowOperationClassifier(method.Body.Instructions) + .IdentifyInterestingOperations(method, method.Body.Instructions); + + operations.Should().ContainSingle(operation => + operation.NodeType == MLVScan.Models.DataFlowNodeType.Transform && + operation.Operation.Contains("ExtractToDirectory", StringComparison.Ordinal)); + } + [Fact] public void IdentifyInterestingOperations_ProcessStartInfoOverload_UsesFileNameIdentity() { diff --git a/MLVScan.Core.Tests/Unit/Services/NewThreatFamilyClassifierTests.cs b/MLVScan.Core.Tests/Unit/Services/NewThreatFamilyClassifierTests.cs new file mode 100644 index 0000000..48e7a7e --- /dev/null +++ b/MLVScan.Core.Tests/Unit/Services/NewThreatFamilyClassifierTests.cs @@ -0,0 +1,114 @@ +using FluentAssertions; +using MLVScan.Models; +using MLVScan.Models.ThreatIntel; +using MLVScan.Services.ThreatIntel; +using Xunit; + +namespace MLVScan.Core.Tests.Unit.Services; + +public class NewThreatFamilyClassifierTests +{ + [Fact] + public void Classify_ArchiveAutorunHiddenLaunch_ReturnsPawnsBehaviorFamily() + { + var findings = new[] + { + Finding( + "CoordinatedPayloadDeliveryRule", + "Detected coordinated archive payload delivery to a user-profile location, Windows autorun persistence, and hidden executable launch across sibling methods."), + Finding("DataInfiltrationRule", "Suspicious payload URL is passed to a local download helper."), + Finding("RegistryRule", "Detected Windows Registry write operation."), + Finding( + "ProcessStartRule", + "Arguments: --hidden [Evasion: CreateNoWindow=true, WindowStyle=Hidden]") + }; + + var matches = new ThreatFamilyClassifier().Classify(findings, sha256Hash: null); + + matches.Should().ContainSingle(match => + match.FamilyId == "family-pawns-app-dropper-v1" && + match.VariantId == "archive-userprofile-runkey-hidden-launch" && + match.MatchKind == ThreatMatchKind.BehaviorVariant && + !match.ExactHashMatch); + } + + [Fact] + public void Classify_ArchiveUpdaterWithoutAutorun_DoesNotReturnPawnsFamily() + { + var findings = new[] + { + Finding("DataInfiltrationRule", "Downloaded update.zip from a release endpoint."), + Finding("ProcessStartRule", "Target: updater.exe. CreateNoWindow=true") + }; + + var matches = new ThreatFamilyClassifier().Classify(findings, sha256Hash: null); + + matches.Should().NotContain(match => match.FamilyId == "family-pawns-app-dropper-v1"); + } + + [Fact] + public void Classify_EvmResolvedConcealedJavaStager_ReturnsBehaviorFamily() + { + var findings = new[] + { + Finding( + "CoordinatedPayloadDeliveryRule", + "Detected coordinated blockchain-resolved Java archive stager: EVM RPC service discovery, fixed-key string decoding, JAR staging, and hidden or elevated Java execution."), + Finding( + "EncodedStringPipelineRule", + "Detected fixed-key byte-array XOR string reconstruction concealing network indicators."), + Finding("EmbeddedArchivePayloadRule", "Detected embedded ZIP/JAR archive payload."), + Finding("DataExfiltrationRule", "Detected transformed host data sent to a runtime-computed endpoint."), + Finding("ProcessStartRule", "UseShellExecute=true, WindowStyle=Hidden") + }; + + var matches = new ThreatFamilyClassifier().Classify(findings, sha256Hash: null); + + matches.Should().ContainSingle(match => + match.FamilyId == "family-blockchain-java-stager-v1" && + match.VariantId == "evm-resolved-jar-runas" && + match.MatchKind == ThreatMatchKind.BehaviorVariant && + !match.ExactHashMatch); + } + + [Fact] + public void Classify_OrdinaryRpcJavaClient_DoesNotReturnBlockchainJavaFamily() + { + var findings = new[] + { + Finding("ProcessStartRule", "Target: java.exe. Arguments: -cp client.jar"), + Finding("DataInfiltrationRule", "Read-only network operation to API endpoint.") + }; + + var matches = new ThreatFamilyClassifier().Classify(findings, sha256Hash: null); + + matches.Should().NotContain(match => match.FamilyId == "family-blockchain-java-stager-v1"); + } + + [Theory] + [InlineData("4f1f3bc0028d9059939c9218dc6d975974b656f4c540ee83a51a7a39278c9c8b", "family-pawns-app-dropper-v1")] + [InlineData("9dcc2c192b1b5e8bb9e9db99e03f58a385c4aff0bd117c8b60d93ff482d67516", "family-pawns-app-dropper-v1")] + [InlineData("7b96c506a062bc7a8deb99fb8429c7b72db5a7fbe86f85b46584fc7f4e3d48f7", "family-pawns-app-dropper-v1")] + [InlineData("3a6a9292767af6c4df205c766cda0e811b8ac12a61a7e2aa5c88d08a7a8de144", "family-blockchain-java-stager-v1")] + public void Classify_ConfirmedHash_ReturnsExactKnownThreat(string hash, string expectedFamily) + { + var classifier = new ThreatFamilyClassifier(); + + var matches = classifier.Classify([], hash); + var disposition = new ThreatDispositionClassifier().Classify([], matches); + + matches.Should().ContainSingle(match => + match.FamilyId == expectedFamily && + match.MatchKind == ThreatMatchKind.ExactSampleHash && + match.ExactHashMatch); + disposition.Classification.Should().Be(ThreatDispositionClassification.KnownThreat); + } + + private static ScanFinding Finding(string ruleId, string description) + { + return new ScanFinding("Test.Sample", description, Severity.High, description) + { + RuleId = ruleId + }; + } +} diff --git a/MLVScan.WASM/npm/package-lock.json b/MLVScan.WASM/npm/package-lock.json index 9f6da41..c437b42 100644 --- a/MLVScan.WASM/npm/package-lock.json +++ b/MLVScan.WASM/npm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mlvscan/wasm-core", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mlvscan/wasm-core", - "version": "1.8.0", + "version": "1.8.1", "license": "GPL-3.0-or-later", "devDependencies": { "@types/node": "^18.0.0", diff --git a/MLVScan.WASM/npm/package.json b/MLVScan.WASM/npm/package.json index c853a7f..d3b9a16 100644 --- a/MLVScan.WASM/npm/package.json +++ b/MLVScan.WASM/npm/package.json @@ -1,6 +1,6 @@ { "name": "@mlvscan/wasm-core", - "version": "1.8.0", + "version": "1.8.1", "description": "WebAssembly core for MLVScan - scanning managed .NET assemblies and mod DLLs in the browser", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/MLVScanVersions.cs b/MLVScanVersions.cs index 043d3bc..91f7c61 100644 --- a/MLVScanVersions.cs +++ b/MLVScanVersions.cs @@ -7,7 +7,7 @@ namespace MLVScan; /// public static class MLVScanVersions { - internal const string DeclaredCoreVersion = "1.8.0"; + internal const string DeclaredCoreVersion = "1.8.1"; /// /// The JSON schema version (semver). diff --git a/Models/Rules/CoordinatedPayloadDeliveryRule.cs b/Models/Rules/CoordinatedPayloadDeliveryRule.cs new file mode 100644 index 0000000..34bc36b --- /dev/null +++ b/Models/Rules/CoordinatedPayloadDeliveryRule.cs @@ -0,0 +1,267 @@ +using System.Text; +using MLVScan.Abstractions; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace MLVScan.Models.Rules; + +/// +/// Correlates high-risk payload delivery, persistence, and execution operations that are deliberately +/// split across sibling helper methods or compiler-generated async state machines. +/// +public sealed class CoordinatedPayloadDeliveryRule : IScanRule +{ + /// + public string Description => + "Detected coordinated payload delivery, persistence, and concealed execution across multiple methods."; + + /// + public Severity Severity => Severity.Critical; + + /// + public string RuleId => "CoordinatedPayloadDeliveryRule"; + + /// + public bool RequiresCompanionFinding => false; + + /// + public bool IsSuspicious(MethodReference method) => false; + + /// + public IEnumerable PostAnalysisRefine( + ModuleDefinition module, + IEnumerable existingFindings) + { + if (module == null) + { + return []; + } + + var priorFindings = existingFindings?.ToList() ?? []; + var findings = new List(); + + foreach (var namespaceGroup in module.Types + .Where(static type => !string.IsNullOrWhiteSpace(type.Namespace)) + .GroupBy(static type => type.Namespace, StringComparer.Ordinal)) + { + var namespaceTypes = namespaceGroup.SelectMany(EnumerateTypeAndNested).ToList(); + var methods = namespaceTypes + .SelectMany(EnumerateMethods) + .Where(static method => method.HasBody) + .ToList(); + var calls = methods + .SelectMany(static method => method.Body.Instructions + .Where(static instruction => instruction.Operand is MethodReference) + .Select(instruction => (Method: method, Called: (MethodReference)instruction.Operand))) + .ToList(); + var literals = methods + .SelectMany(static method => method.Body.Instructions) + .Where(static instruction => instruction.OpCode == OpCodes.Ldstr) + .Select(static instruction => instruction.Operand as string) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToList(); + var scopedProcessFindings = priorFindings + .Where(finding => + string.Equals(finding.RuleId, "ProcessStartRule", StringComparison.Ordinal) && + finding.Location.StartsWith(namespaceGroup.Key + ".", StringComparison.Ordinal)) + .ToList(); + + if (IsArchiveAutorunHiddenLaunch(calls, literals, scopedProcessFindings)) + { + findings.Add(CreateFinding( + namespaceGroup.Key, + "Detected coordinated archive payload delivery to a user-profile location, Windows autorun persistence, and hidden executable launch across sibling methods.", + [ + DescribeCall(calls, IsNetworkRead, "network read"), + DescribeCall(calls, IsArchiveExtraction, "archive extraction"), + DescribeCall(calls, IsRegistryWrite, "registry autorun write"), + "staging: user-profile path with archive payload", + "execution: concealed Process.Start with hidden arguments" + ])); + } + + if (IsBlockchainResolvedJavaStager(methods, calls, literals, scopedProcessFindings)) + { + findings.Add(CreateFinding( + namespaceGroup.Key, + "Detected coordinated blockchain-resolved Java archive stager: EVM RPC service discovery, fixed-key string decoding, JAR staging, and hidden or elevated Java execution.", + [ + DescribeCall(calls, IsNetworkSend, "EVM RPC/network send"), + DescribeCall(calls, IsFileWrite, "archive write"), + "transform: fixed-key XOR string reconstruction", + "staging: .jar classpath payload", + "execution: hidden or elevated Java child process" + ])); + } + } + + return findings; + } + + private static bool IsArchiveAutorunHiddenLaunch( + IReadOnlyList<(MethodDefinition Method, MethodReference Called)> calls, + IReadOnlyList literals, + IReadOnlyList processFindings) + { + bool hasHiddenLaunch = processFindings.Any(finding => + Contains(finding.Description, "CreateNoWindow=true") && + Contains(finding.Description, "WindowStyle=Hidden") && + Contains(finding.Description, "--hidden")); + + return calls.Any(call => IsNetworkRead(call.Called)) && + calls.Any(call => IsFileWrite(call.Called)) && + calls.Any(call => IsArchiveExtraction(call.Called)) && + calls.Any(call => IsRegistryWrite(call.Called)) && + calls.Any(call => IsUserProfilePath(call.Called)) && + literals.Any(static literal => literal.Contains(".zip", StringComparison.OrdinalIgnoreCase)) && + literals.Any(static literal => + literal.Contains(@"CurrentVersion\Run", StringComparison.OrdinalIgnoreCase)) && + hasHiddenLaunch; + } + + private static bool IsBlockchainResolvedJavaStager( + IReadOnlyList methods, + IReadOnlyList<(MethodDefinition Method, MethodReference Called)> calls, + IReadOnlyList literals, + IReadOnlyList processFindings) + { + bool hasFixedKeyXorDecoder = methods.Any(method => + method.Body.Instructions.Any(static instruction => instruction.OpCode == OpCodes.Xor) && + method.Body.Instructions.Any(instruction => + instruction.Operand is MethodReference called && + called.DeclaringType?.FullName == "System.Text.Encoding" && + called.Name == "GetString")); + bool hasEvmLookup = literals.Any(static literal => + literal.Equals("eth_call", StringComparison.OrdinalIgnoreCase)) && + literals.Any(static literal => + literal.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && + literal.Length >= 10); + bool hasJavaArchiveMarkers = literals.Any(static literal => + literal.Contains(".jar", StringComparison.OrdinalIgnoreCase)) && + literals.Any(static literal => + literal.Contains("-cp ", StringComparison.OrdinalIgnoreCase) || + literal.Contains("MemJarBootstrap", StringComparison.OrdinalIgnoreCase) || + literal.Contains("com.renderassist.Main", StringComparison.OrdinalIgnoreCase)); + bool hasConcealedJavaLaunch = processFindings.Any(finding => + Contains(finding.Description, "WindowStyle=Hidden") || + Contains(finding.Description, "Redirected I/O") || + Contains(finding.Description, "CreateNoWindow=true")); + + return hasFixedKeyXorDecoder && + hasEvmLookup && + hasJavaArchiveMarkers && + hasConcealedJavaLaunch && + calls.Any(call => IsNetworkSend(call.Called)) && + calls.Any(call => IsFileWrite(call.Called)) && + calls.Any(call => IsProcessStart(call.Called)); + } + + private static bool IsNetworkRead(MethodReference method) + { + string type = method.DeclaringType?.FullName ?? string.Empty; + string name = method.Name; + return IsNetworkType(type) && + (name.Contains("GetResponse", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetAsync", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetByteArray", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetStream", StringComparison.OrdinalIgnoreCase) || + name.Contains("Download", StringComparison.OrdinalIgnoreCase) || + name.Equals("Get", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsNetworkSend(MethodReference method) + { + string type = method.DeclaringType?.FullName ?? string.Empty; + string name = method.Name; + return IsNetworkType(type) && + (name.Contains("Post", StringComparison.OrdinalIgnoreCase) || + name.Contains("Put", StringComparison.OrdinalIgnoreCase) || + name.Contains("Send", StringComparison.OrdinalIgnoreCase) || + name.Contains("Upload", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetRequestStream", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsNetworkType(string type) => + type.StartsWith("System.Net", StringComparison.OrdinalIgnoreCase) || + type.Contains("UnityWebRequest", StringComparison.OrdinalIgnoreCase); + + private static bool IsArchiveExtraction(MethodReference method) => + method.DeclaringType?.FullName == "System.IO.Compression.ZipFile" && + method.Name.Contains("ExtractToDirectory", StringComparison.Ordinal); + + private static bool IsFileWrite(MethodReference method) + { + string type = method.DeclaringType?.FullName ?? string.Empty; + return (type == "System.IO.File" && + (method.Name.StartsWith("Write", StringComparison.Ordinal) || + method.Name == "Create")) || + (type == "System.IO.FileStream" && method.Name == ".ctor") || + (type == "System.IO.Stream" && + (method.Name.Contains("CopyTo", StringComparison.Ordinal) || + method.Name.Contains("Write", StringComparison.Ordinal))); + } + + private static bool IsRegistryWrite(MethodReference method) + { + string type = method.DeclaringType?.FullName ?? string.Empty; + return type.Contains("Registry", StringComparison.OrdinalIgnoreCase) && + (method.Name.Contains("SetValue", StringComparison.OrdinalIgnoreCase) || + method.Name.Contains("CreateSubKey", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsUserProfilePath(MethodReference method) => + method.DeclaringType?.FullName == "System.Environment" && method.Name == "GetFolderPath"; + + private static bool IsProcessStart(MethodReference method) => + method.DeclaringType?.FullName == "System.Diagnostics.Process" && method.Name == "Start"; + + private static string DescribeCall( + IEnumerable<(MethodDefinition Method, MethodReference Called)> calls, + Func predicate, + string fallback) + { + var match = calls.FirstOrDefault(call => predicate(call.Called)); + return match.Called == null + ? fallback + : $"{fallback}: {match.Called.DeclaringType?.FullName}.{match.Called.Name} in {match.Method.FullName}"; + } + + private ScanFinding CreateFinding(string location, string description, IEnumerable evidence) + { + return new ScanFinding(location, description, Severity, string.Join(Environment.NewLine, evidence)) + { + RuleId = RuleId, + RiskScore = 98, + BypassCompanionCheck = true + }; + } + + private static bool Contains(string? value, string needle) => + value?.Contains(needle, StringComparison.OrdinalIgnoreCase) == true; + + private static IEnumerable EnumerateTypes(ModuleDefinition module) + { + foreach (var type in module.Types) + { + foreach (var item in EnumerateTypeAndNested(type)) + { + yield return item; + } + } + } + + private static IEnumerable EnumerateTypeAndNested(TypeDefinition type) + { + yield return type; + foreach (var nested in type.NestedTypes) + { + foreach (var item in EnumerateTypeAndNested(nested)) + { + yield return item; + } + } + } + + private static IEnumerable EnumerateMethods(TypeDefinition type) => type.Methods; +} diff --git a/Models/Rules/DataExfiltrationRule.cs b/Models/Rules/DataExfiltrationRule.cs index 4292d78..8d42831 100644 --- a/Models/Rules/DataExfiltrationRule.cs +++ b/Models/Rules/DataExfiltrationRule.cs @@ -44,6 +44,66 @@ public bool IsSuspicious(MethodReference method) return false; } + /// + /// Detects outbound sends whose endpoint is computed at runtime when host or process data is + /// transformed before transmission. This avoids requiring a literal C2 URL beside SendAsync. + /// + public IEnumerable PostAnalysisRefine( + ModuleDefinition module, + IEnumerable existingFindings) + { + if (module == null) + { + return []; + } + + var findings = new List(); + foreach (var method in EnumerateTypes(module).SelectMany(static type => type.Methods)) + { + if (!method.HasBody) + { + continue; + } + + var calls = method.Body.Instructions + .Where(static instruction => instruction.Operand is MethodReference) + .Select(static instruction => (MethodReference)instruction.Operand) + .ToList(); + bool hasNetworkSend = calls.Any(IsDataSendingCall); + bool hasHostData = calls.Any(called => + called.DeclaringType?.FullName == "System.Environment" && + called.Name == "get_MachineName") || + calls.Any(called => + called.DeclaringType?.FullName == "System.Diagnostics.Process" && + (called.Name.Contains("StandardOutput", StringComparison.OrdinalIgnoreCase) || + called.Name.Contains("StandardError", StringComparison.OrdinalIgnoreCase))); + bool hasTransform = method.Body.Instructions.Any(static instruction => instruction.OpCode == OpCodes.Xor) || + calls.Any(called => + called.Name.Contains("Xor", StringComparison.OrdinalIgnoreCase) || + called.Name.Contains("Encrypt", StringComparison.OrdinalIgnoreCase) || + called.Name.Contains("Encode", StringComparison.OrdinalIgnoreCase)); + + if (!hasNetworkSend || !hasHostData || !hasTransform) + { + continue; + } + + var send = calls.First(IsDataSendingCall); + findings.Add(new ScanFinding( + method.FullName, + "Detected host or process data transformed before transmission to a runtime-computed network endpoint.", + Severity.High, + $"source: host/process data{Environment.NewLine}transform: XOR/encryption/encoding helper{Environment.NewLine}sink: {send.DeclaringType?.FullName}.{send.Name}") + { + RuleId = RuleId, + RiskScore = 86, + BypassCompanionCheck = true + }); + } + + return findings; + } + /// /// Analyzes network send operations for suspicious outbound destinations and nearby URL literals. /// @@ -358,5 +418,48 @@ private static bool HostMatches(string host, string domain) return host.Equals(domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); } + + private static bool IsDataSendingCall(MethodReference called) + { + string type = called.DeclaringType?.FullName ?? string.Empty; + if (!type.StartsWith("System.Net", StringComparison.OrdinalIgnoreCase) && + !type.Contains("UnityWebRequest", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string name = called.Name; + return name.Equals("Post", StringComparison.OrdinalIgnoreCase) || + name.Equals("Put", StringComparison.OrdinalIgnoreCase) || + name.Contains("PostAsync", StringComparison.OrdinalIgnoreCase) || + name.Contains("PutAsync", StringComparison.OrdinalIgnoreCase) || + name.Contains("SendAsync", StringComparison.OrdinalIgnoreCase) || + name.Contains("Upload", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetRequestStream", StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable EnumerateTypes(ModuleDefinition module) + { + foreach (var type in module.Types) + { + yield return type; + foreach (var nested in EnumerateNestedTypes(type)) + { + yield return nested; + } + } + } + + private static IEnumerable EnumerateNestedTypes(TypeDefinition type) + { + foreach (var nested in type.NestedTypes) + { + yield return nested; + foreach (var descendant in EnumerateNestedTypes(nested)) + { + yield return descendant; + } + } + } } } diff --git a/Models/Rules/DataInfiltrationRule.cs b/Models/Rules/DataInfiltrationRule.cs index 94df6f9..91fe164 100644 --- a/Models/Rules/DataInfiltrationRule.cs +++ b/Models/Rules/DataInfiltrationRule.cs @@ -40,7 +40,7 @@ public class DataInfiltrationRule : IScanRule private static readonly HashSet SuspiciousPayloadExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".exe", ".dll", ".bat", ".cmd", ".ps1", ".vbs", ".js", ".hta", ".scr", ".com" + ".exe", ".dll", ".bat", ".cmd", ".ps1", ".vbs", ".js", ".hta", ".scr", ".com", ".zip", ".jar" }; private static readonly HashSet CommonHostingPayloadExtensions = new(StringComparer.OrdinalIgnoreCase) @@ -88,6 +88,83 @@ public bool IsSuspicious(MethodReference method) return false; } + /// + /// Finds suspicious URL constants passed one hop into a local download helper. Malware commonly + /// separates URL selection from the method that calls GetResponse or DownloadFile, + /// which defeats a purely local literal window. + /// + public IEnumerable PostAnalysisRefine( + ModuleDefinition module, + IEnumerable existingFindings) + { + if (module == null) + { + return []; + } + + var methods = EnumerateTypes(module) + .SelectMany(static type => type.Methods) + .Where(static method => method.HasBody) + .ToList(); + var downloadHelpers = methods + .Where(ContainsNetworkRead) + .Select(static method => method.FullName) + .ToHashSet(StringComparer.Ordinal); + if (downloadHelpers.Count == 0) + { + return []; + } + + var priorText = existingFindings? + .SelectMany(static finding => new[] { finding.Description, finding.CodeSnippet }) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToList() ?? []; + var findings = new List(); + + foreach (var caller in methods) + { + var instructions = caller.Body.Instructions; + for (int index = 0; index < instructions.Count; index++) + { + if (instructions[index].Operand is not MethodReference called || + !downloadHelpers.Contains(called.FullName)) + { + continue; + } + + int start = Math.Max(0, index - 30); + int end = Math.Min(instructions.Count, index + 6); + var urls = UrlLiteralCollector.CollectCandidates(instructions, start, end) + .SelectMany(ExtractUrls) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(url => + IsDirectPayloadUrl(url) || + IsKnownMaliciousDomain(url) || + IsUrlShortenerDomain(url) || + Regex.IsMatch(url, @"^https?://\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:/|$)", RegexOptions.IgnoreCase)) + .ToList(); + if (urls.Count == 0 || urls.All(url => priorText.Any(text => text.Contains(url, StringComparison.OrdinalIgnoreCase)))) + { + continue; + } + + findings.Add(new ScanFinding( + $"{caller.DeclaringType.FullName}.{caller.Name}:{instructions[index].Offset}", + $"Suspicious payload URL is passed to a local download helper (one-hop argument flow). URL(s): {string.Join(", ", urls)}", + Severity.High, + $"caller: {caller.FullName}{Environment.NewLine}download helper: {called.FullName}") + { + RuleId = RuleId, + RiskScore = 82, + BypassCompanionCheck = true + }); + } + } + + return findings; + } + /// /// Analyzes read-only network operations for suspicious download sources near the call site. /// @@ -404,5 +481,52 @@ private static bool IsCommonHostingPayloadUrl(string url) return CommonHostingPayloadExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)); } + + private static bool ContainsNetworkRead(MethodDefinition method) + { + return method.Body.Instructions.Any(instruction => + { + if (instruction.Operand is not MethodReference called) + { + return false; + } + + string type = called.DeclaringType?.FullName ?? string.Empty; + string name = called.Name; + bool networkType = type.StartsWith("System.Net", StringComparison.OrdinalIgnoreCase) || + type.Contains("UnityWebRequest", StringComparison.OrdinalIgnoreCase); + return networkType && + (name.Contains("GetResponse", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetAsync", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetByteArray", StringComparison.OrdinalIgnoreCase) || + name.Contains("GetStream", StringComparison.OrdinalIgnoreCase) || + name.Contains("Download", StringComparison.OrdinalIgnoreCase) || + name.Equals("Get", StringComparison.OrdinalIgnoreCase)); + }); + } + + private static IEnumerable EnumerateTypes(ModuleDefinition module) + { + foreach (var type in module.Types) + { + yield return type; + foreach (var nested in EnumerateNestedTypes(type)) + { + yield return nested; + } + } + } + + private static IEnumerable EnumerateNestedTypes(TypeDefinition type) + { + foreach (var nested in type.NestedTypes) + { + yield return nested; + foreach (var descendant in EnumerateNestedTypes(nested)) + { + yield return descendant; + } + } + } } } diff --git a/Models/Rules/EmbeddedArchivePayloadRule.cs b/Models/Rules/EmbeddedArchivePayloadRule.cs new file mode 100644 index 0000000..291f140 --- /dev/null +++ b/Models/Rules/EmbeddedArchivePayloadRule.cs @@ -0,0 +1,126 @@ +using MLVScan.Abstractions; +using Mono.Cecil; + +namespace MLVScan.Models.Rules; + +/// +/// Detects embedded archive bytes that are materialized and handed to a process-execution path. +/// +public sealed class EmbeddedArchivePayloadRule : IScanRule +{ + private const int MinimumArchiveBytes = 512; + private const int MaximumArchiveBytes = 32 * 1024 * 1024; + + /// + public string Description => + "Detected embedded ZIP/JAR archive bytes used by a file or child-process execution path."; + + /// + public Severity Severity => Severity.High; + + /// + public string RuleId => "EmbeddedArchivePayloadRule"; + + /// + public bool RequiresCompanionFinding => false; + + /// + public bool IsSuspicious(MethodReference method) => false; + + /// + public IEnumerable PostAnalysisRefine( + ModuleDefinition module, + IEnumerable existingFindings) + { + if (module == null) + { + return []; + } + + bool hasProcessExecution = existingFindings?.Any(finding => + string.Equals(finding.RuleId, "ProcessStartRule", StringComparison.Ordinal) && + (Contains(finding.Description, "Redirected I/O") || + Contains(finding.Description, "WindowStyle=Hidden") || + Contains(finding.Description, "CreateNoWindow=true"))) == true; + if (!hasProcessExecution) + { + return []; + } + + var methods = EnumerateTypes(module) + .SelectMany(static type => type.Methods) + .Where(static method => method.HasBody) + .ToList(); + bool hasMaterializationSink = methods.Any(method => method.Body.Instructions.Any(instruction => + instruction.Operand is MethodReference called && + ((called.DeclaringType?.FullName == "System.IO.File" && + called.Name.Contains("WriteAllBytes", StringComparison.Ordinal)) || + (called.DeclaringType?.FullName == "System.IO.Stream" && + called.Name.Contains("WriteAsync", StringComparison.Ordinal)) || + called.Name == "InitializeArray"))); + bool hasArchiveExecutionMarker = methods + .SelectMany(static method => method.Body.Instructions) + .Select(static instruction => instruction.Operand as string) + .Any(static literal => + literal?.Contains(".jar", StringComparison.OrdinalIgnoreCase) == true || + literal?.Contains("-cp ", StringComparison.OrdinalIgnoreCase) == true || + literal?.Contains("MemJarBootstrap", StringComparison.OrdinalIgnoreCase) == true); + + if (!hasMaterializationSink || !hasArchiveExecutionMarker) + { + return []; + } + + var archiveFields = EnumerateTypes(module) + .SelectMany(static type => type.Fields) + .Where(static field => + field.HasFieldRVA && + field.InitialValue is { Length: >= MinimumArchiveBytes and <= MaximumArchiveBytes } bytes && + IsZip(bytes)) + .ToList(); + + return archiveFields.Select(field => new ScanFinding( + field.FullName, + $"Detected embedded ZIP/JAR archive payload ({field.InitialValue.Length} bytes) paired with archive materialization and concealed child-process execution.", + Severity, + $"archive magic: PK; field: {field.FullName}; size: {field.InitialValue.Length} bytes") + { + RuleId = RuleId, + RiskScore = 88, + BypassCompanionCheck = true + }); + } + + private static bool IsZip(byte[] bytes) => + bytes.Length >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4B && + ((bytes[2] == 0x03 && bytes[3] == 0x04) || + (bytes[2] == 0x05 && bytes[3] == 0x06) || + (bytes[2] == 0x07 && bytes[3] == 0x08)); + + private static bool Contains(string? value, string needle) => + value?.Contains(needle, StringComparison.OrdinalIgnoreCase) == true; + + private static IEnumerable EnumerateTypes(ModuleDefinition module) + { + foreach (var type in module.Types) + { + yield return type; + foreach (var nested in EnumerateNestedTypes(type)) + { + yield return nested; + } + } + } + + private static IEnumerable EnumerateNestedTypes(TypeDefinition type) + { + foreach (var nested in type.NestedTypes) + { + yield return nested; + foreach (var descendant in EnumerateNestedTypes(nested)) + { + yield return descendant; + } + } + } +} diff --git a/Models/Rules/EncodedStringPipelineRule.cs b/Models/Rules/EncodedStringPipelineRule.cs index 2b80a5f..e844798 100644 --- a/Models/Rules/EncodedStringPipelineRule.cs +++ b/Models/Rules/EncodedStringPipelineRule.cs @@ -2,6 +2,7 @@ using MLVScan.Models; using Mono.Cecil; using Mono.Cecil.Cil; +using System.Text; namespace MLVScan.Models.Rules { @@ -42,6 +43,95 @@ public bool IsSuspicious(MethodReference method) return false; } + /// + /// Correlates fixed-key byte-array XOR decoding with concealed network and process behavior. + /// This catches constants stored in compiler-generated RVA fields without treating ordinary + /// byte-array transformations as malicious on their own. + /// + public IEnumerable PostAnalysisRefine( + ModuleDefinition module, + IEnumerable existingFindings) + { + if (module == null) + { + return []; + } + + var methods = EnumerateTypes(module) + .SelectMany(static type => type.Methods) + .Where(static method => method.HasBody) + .ToList(); + var xorDecoders = methods + .Select(method => (Method: method, Keys: CollectFixedXorKeys(method))) + .Where(static item => item.Keys.Count > 0) + .ToList(); + if (xorDecoders.Count == 0) + { + return []; + } + + bool hasNetworkCall = methods.Any(method => method.Body.Instructions.Any(instruction => + instruction.Operand is MethodReference called && + (called.DeclaringType?.FullName?.StartsWith("System.Net", StringComparison.OrdinalIgnoreCase) == true || + called.DeclaringType?.FullName?.Contains("UnityWebRequest", StringComparison.OrdinalIgnoreCase) == true))); + bool hasConcealedProcess = existingFindings?.Any(finding => + string.Equals(finding.RuleId, "ProcessStartRule", StringComparison.Ordinal) && + (Contains(finding.Description, "WindowStyle=Hidden") || + Contains(finding.Description, "CreateNoWindow=true") || + Contains(finding.Description, "Redirected I/O"))) == true; + if (!hasNetworkCall || !hasConcealedProcess) + { + return []; + } + + var decodedIndicators = new HashSet(StringComparer.OrdinalIgnoreCase); + var keys = xorDecoders.SelectMany(static item => item.Keys).Distinct().ToList(); + int inspectedBytes = 0; + + foreach (var field in EnumerateTypes(module).SelectMany(static type => type.Fields)) + { + if (!field.HasFieldRVA || field.InitialValue is not { Length: >= 4 and <= 4096 } bytes) + { + continue; + } + + if (inspectedBytes > 256 * 1024 - bytes.Length) + { + break; + } + + inspectedBytes += bytes.Length; + foreach (int key in keys) + { + if (TryDecodeFixedXor(bytes, key, out string decoded) && IsSecurityRelevantDecodedString(decoded)) + { + decodedIndicators.Add(decoded); + } + } + } + + if (decodedIndicators.Count == 0) + { + return []; + } + + string decoderLocations = string.Join(", ", xorDecoders.Select(static item => item.Method.FullName).Take(3)); + string indicators = string.Join(", ", decodedIndicators.OrderBy(static value => value, StringComparer.Ordinal).Take(6)); + return + [ + new ScanFinding( + xorDecoders[0].Method.FullName, + "Detected fixed-key byte-array XOR string reconstruction concealing network, runtime, or payload indicators in an assembly with concealed process execution.", + Severity.High, + $"decoder(s): {decoderLocations}{Environment.NewLine}decoded indicator(s): {indicators}") + { + RuleId = RuleId, + RiskScore = 84, + BypassCompanionCheck = true + } + ]; + } + /// /// Scans a method body for string-to-char reconstruction pipelines and variation-selector payloads. /// @@ -383,5 +473,101 @@ private static bool TryResolveInt32Literal(Instruction instruction, out int valu value = 0; return false; } + + private static IReadOnlyList CollectFixedXorKeys(MethodDefinition method) + { + var instructions = method.Body.Instructions; + bool callsEncodingGetString = instructions.Any(instruction => + instruction.Operand is MethodReference called && + called.DeclaringType?.FullName == "System.Text.Encoding" && + called.Name == "GetString"); + if (!callsEncodingGetString) + { + return []; + } + + var keys = new HashSet(); + for (int index = 0; index < instructions.Count; index++) + { + if (instructions[index].OpCode != OpCodes.Xor) + { + continue; + } + + for (int previous = index - 1; previous >= Math.Max(0, index - 8); previous--) + { + if (TryResolveInt32Literal(instructions[previous], out int value) && value is > 0 and <= 255) + { + keys.Add(value); + break; + } + } + } + + return keys.ToList(); + } + + private static bool TryDecodeFixedXor(byte[] bytes, int key, out string decoded) + { + decoded = string.Empty; + var transformed = new byte[bytes.Length]; + for (int index = 0; index < bytes.Length; index++) + { + transformed[index] = (byte)(bytes[index] ^ key); + } + + string candidate = Encoding.UTF8.GetString(transformed).TrimEnd('\0'); + if (candidate.Length < 4 || candidate.Contains('\uFFFD')) + { + return false; + } + + int printable = candidate.Count(static character => + character is >= ' ' and <= '~' || character is '\t' or '\r' or '\n'); + if ((double)printable / candidate.Length < 0.9) + { + return false; + } + + decoded = candidate; + return true; + } + + private static bool IsSecurityRelevantDecodedString(string value) + { + return value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + value.Contains(".jar", StringComparison.OrdinalIgnoreCase) || + value.Contains("javaw", StringComparison.OrdinalIgnoreCase) || + value.Contains("--cookie", StringComparison.OrdinalIgnoreCase) || + value.Contains("/api/", StringComparison.OrdinalIgnoreCase); + } + + private static bool Contains(string? value, string needle) => + value?.Contains(needle, StringComparison.OrdinalIgnoreCase) == true; + + private static IEnumerable EnumerateTypes(ModuleDefinition module) + { + foreach (var type in module.Types) + { + yield return type; + foreach (var nested in EnumerateNestedTypes(type)) + { + yield return nested; + } + } + } + + private static IEnumerable EnumerateNestedTypes(TypeDefinition type) + { + foreach (var nested in type.NestedTypes) + { + yield return nested; + foreach (var descendant in EnumerateNestedTypes(nested)) + { + yield return descendant; + } + } + } } } diff --git a/RuleFactory.cs b/RuleFactory.cs index 290337d..742a598 100644 --- a/RuleFactory.cs +++ b/RuleFactory.cs @@ -71,6 +71,8 @@ private static List CreateCoreRules() new SuspiciousLocalVariableRule(), new ObfuscatedReflectiveExecutionRule(), new EmbeddedResourceScriptRule(), + new EmbeddedArchivePayloadRule(), + new CoordinatedPayloadDeliveryRule(), new SuspiciousAssemblyNameRule() }; } diff --git a/Services/DataFlow/DataFlowOperationClassifier.cs b/Services/DataFlow/DataFlowOperationClassifier.cs index 8075ad5..8c284aa 100644 --- a/Services/DataFlow/DataFlowOperationClassifier.cs +++ b/Services/DataFlow/DataFlowOperationClassifier.cs @@ -1046,6 +1046,8 @@ private static bool IsCompressionOperation(string declaringType, string methodNa return (declaringType == "System.IO.Compression.GZipStream" && methodName == ".ctor") || (declaringType == "System.IO.Compression.DeflateStream" && methodName == ".ctor") || (declaringType == "System.IO.Compression.BrotliStream" && methodName == ".ctor") || + (declaringType == "System.IO.Compression.ZipFile" && + methodName == "ExtractToDirectory") || (declaringType.Contains("System.IO.Compression", StringComparison.Ordinal) && methodName == "CopyTo"); } diff --git a/Services/ThreatIntel/ThreatFamilyCatalog.NewSamples.cs b/Services/ThreatIntel/ThreatFamilyCatalog.NewSamples.cs new file mode 100644 index 0000000..70b93f9 --- /dev/null +++ b/Services/ThreatIntel/ThreatFamilyCatalog.NewSamples.cs @@ -0,0 +1,164 @@ +using MLVScan.Models; + +namespace MLVScan.Services.ThreatIntel; + +internal static partial class ThreatFamilyCatalog +{ + private static ThreatFamilyDefinition CreatePawnsAppDropperFamily() + { + return new ThreatFamilyDefinition + { + FamilyId = "family-pawns-app-dropper-v1", + DisplayName = "Pawns.app credential-seeded autorun dropper", + Summary = "Downloads and extracts a per-user archive payload, seeds application state, installs Windows autorun persistence, and launches the payload hidden.", + AdvisorySlugs = [], + ExactSampleHashes = + [ + "4f1f3bc0028d9059939c9218dc6d975974b656f4c540ee83a51a7a39278c9c8b", + "9dcc2c192b1b5e8bb9e9db99e03f58a385c4aff0bd117c8b60d93ff482d67516", + "7b96c506a062bc7a8deb99fb8429c7b72db5a7fbe86f85b46584fc7f4e3d48f7" + ], + Variants = + [ + new ThreatFamilyVariantDefinition + { + VariantId = "archive-userprofile-runkey-hidden-launch", + DisplayName = "User-profile archive -> Run key -> hidden launch", + Summary = "Stages a downloaded archive under the user profile, establishes CurrentVersion Run persistence, and starts the installed executable with hidden-process settings.", + Confidence = 0.98, + Matcher = MatchArchiveUserProfileRunKeyHiddenLaunch + } + ] + }; + } + + private static ThreatFamilyDefinition CreateBlockchainJavaStagerFamily() + { + return new ThreatFamilyDefinition + { + FamilyId = "family-blockchain-java-stager-v1", + DisplayName = "Blockchain-resolved Java payload stager", + Summary = "Uses blockchain RPC service discovery and fixed-key decoding to retrieve or embed a Java archive, then executes it through a concealed Java child process.", + AdvisorySlugs = [], + ExactSampleHashes = + [ + "3a6a9292767af6c4df205c766cda0e811b8ac12a61a7e2aa5c88d08a7a8de144" + ], + Variants = + [ + new ThreatFamilyVariantDefinition + { + VariantId = "evm-resolved-jar-runas", + DisplayName = "EVM-resolved JAR -> hidden elevated Java", + Summary = "Resolves service infrastructure through EVM RPC calls, decodes concealed runtime strings, writes a JAR payload, and launches Java hidden or elevated.", + Confidence = 0.99, + Matcher = MatchEvmResolvedJarRunAs + }, + new ThreatFamilyVariantDefinition + { + VariantId = "embedded-jar-stdin-bootstrap", + DisplayName = "Embedded JAR -> redirected Java bootstrap", + Summary = "Materializes an embedded JAR bootstrap and transfers a second payload to a concealed Java process through redirected standard input.", + Confidence = 0.97, + Matcher = MatchEmbeddedJarStdinBootstrap + } + ] + }; + } + + private static ThreatFamilyVariantMatch? MatchArchiveUserProfileRunKeyHiddenLaunch( + ThreatFamilyAnalysisContext context) + { + var coordinator = context.Findings.FirstOrDefault(finding => + string.Equals(finding.RuleId, "CoordinatedPayloadDeliveryRule", StringComparison.Ordinal) && + FindingContainsAll(finding, "archive payload delivery", "autorun persistence", "hidden executable launch")); + var registry = context.FindFinding("RegistryRule"); + var process = context.Findings.FirstOrDefault(finding => + string.Equals(finding.RuleId, "ProcessStartRule", StringComparison.Ordinal) && + FindingContainsAll(finding, "--hidden", "CreateNoWindow=true", "WindowStyle=Hidden")); + if (coordinator == null || registry == null || process == null) + { + return null; + } + + var download = context.FindFinding("DataInfiltrationRule"); + return new ThreatFamilyVariantMatch + { + MatchedRules = context.BuildMatchedRules( + "CoordinatedPayloadDeliveryRule", + "DataInfiltrationRule", + "RegistryRule", + "ProcessStartRule"), + Evidence = + [ + context.CreateRuleEvidence("behavior-chain", "archive -> user profile -> autorun -> hidden launch", coordinator), + context.CreateRuleEvidence("download", "remote archive acquisition", download), + context.CreateRuleEvidence("persistence", "CurrentVersion Run registry write", registry), + context.CreateRuleEvidence("execution", "concealed executable launch with --hidden", process) + ] + }; + } + + private static ThreatFamilyVariantMatch? MatchEvmResolvedJarRunAs(ThreatFamilyAnalysisContext context) + { + var coordinator = context.Findings.FirstOrDefault(finding => + string.Equals(finding.RuleId, "CoordinatedPayloadDeliveryRule", StringComparison.Ordinal) && + FindingContainsAll(finding, "blockchain-resolved Java archive stager", "EVM RPC", "JAR staging")); + var decoder = context.FindFinding("EncodedStringPipelineRule", "fixed-key", "XOR"); + var process = context.Findings.FirstOrDefault(finding => + string.Equals(finding.RuleId, "ProcessStartRule", StringComparison.Ordinal) && + (FindingContainsAll(finding, "WindowStyle=Hidden") || + FindingContainsAll(finding, "Redirected I/O"))); + if (coordinator == null || decoder == null || process == null) + { + return null; + } + + var embeddedArchive = context.FindFinding("EmbeddedArchivePayloadRule"); + var exfiltration = context.FindFinding("DataExfiltrationRule"); + return new ThreatFamilyVariantMatch + { + MatchedRules = context.BuildMatchedRules( + "CoordinatedPayloadDeliveryRule", + "EncodedStringPipelineRule", + "EmbeddedArchivePayloadRule", + "DataExfiltrationRule", + "ProcessStartRule"), + Evidence = + [ + context.CreateRuleEvidence("behavior-chain", "EVM-resolved JAR staging and concealed Java execution", coordinator), + context.CreateRuleEvidence("transform", "fixed-key XOR decoded runtime and network strings", decoder), + context.CreateRuleEvidence("embedded-payload", "embedded ZIP/JAR bootstrap", embeddedArchive), + context.CreateRuleEvidence("outbound-channel", "runtime-computed transformed network send", exfiltration), + context.CreateRuleEvidence("execution", "hidden/elevated Java process", process) + ] + }; + } + + private static ThreatFamilyVariantMatch? MatchEmbeddedJarStdinBootstrap(ThreatFamilyAnalysisContext context) + { + var embeddedArchive = context.FindFinding("EmbeddedArchivePayloadRule"); + var coordinator = context.FindFinding("CoordinatedPayloadDeliveryRule", "Java archive stager"); + var process = context.Findings.FirstOrDefault(finding => + string.Equals(finding.RuleId, "ProcessStartRule", StringComparison.Ordinal) && + FindingContainsAll(finding, "Redirected I/O")); + if (embeddedArchive == null || coordinator == null || process == null) + { + return null; + } + + return new ThreatFamilyVariantMatch + { + MatchedRules = context.BuildMatchedRules( + "CoordinatedPayloadDeliveryRule", + "EmbeddedArchivePayloadRule", + "ProcessStartRule"), + Evidence = + [ + context.CreateRuleEvidence("embedded-payload", "embedded ZIP/JAR bootstrap", embeddedArchive), + context.CreateRuleEvidence("behavior-chain", "archive materialization and Java execution", coordinator), + context.CreateRuleEvidence("execution", "redirected child-process payload handoff", process) + ] + }; + } +} diff --git a/Services/ThreatIntel/ThreatFamilyCatalog.cs b/Services/ThreatIntel/ThreatFamilyCatalog.cs index e841f91..d44290e 100644 --- a/Services/ThreatIntel/ThreatFamilyCatalog.cs +++ b/Services/ThreatIntel/ThreatFamilyCatalog.cs @@ -13,6 +13,8 @@ internal static partial class ThreatFamilyCatalog /// public static IReadOnlyList Families { get; } = [ + CreatePawnsAppDropperFamily(), + CreateBlockchainJavaStagerFamily(), new ThreatFamilyDefinition { FamilyId = "family-resource-shell32-tempcmd-v2",