Introduction
When I first started dabbling in malware and general computer based tom-foolery I knew what shellcode WAS, but always struggled with one simple question that wasn't quite explained by anyone:
"so... how u make that shit then?"
Use msfvenom! Okay, sure that's the
pragmatic approach and will work in 99% of cases,
however just getting shellcode is not the goal. I want
the knowledge college. I wanna be the pro hackermans,
get mad skillz, alladat.
That's what this post is going to be about. How to create shellcode (it's in the title...) and how every step along the way works, why it works and yada yada yada.
Oh yeah, keep it legal. The defense is op af and you will get owned real bad.
What is Shellcode
Put simply shellcode is raw bytes that the cpu will execute. Typically presented in hex because binary is unwieldy and hurts our human brains. I will use the Linux exit function to illustrate going from C code to shellcode.
int main(int argc, char* argv[]) {
//Do something
//Do something
exit(0) // Or return 0, for you l33t coders out there.
}
A compiler may generate assembly equivalent to:
; exit(0) on Linux x86-64
mov rax, 60 ; syscall number for exit
xor rdi, rdi ; exit code 0
syscall ; invoke the kernel
Assembled, each instruction becomes raw bytes:
; exit(0) on Linux x86-64
48 C7 C0 3C 00 00 00 ; mov rax, 60
48 31 FF ; xor rdi, rdi
0F 05 ; syscall
Lets break down how we got to these funny numbers. To
start you will notice the first two instructions
prefixed with 48, this is known as a register extension
prefix, and is indicating to the cpu to expect 64-bit
operand width. Next we will have the opcodes, these are
the action that the cpu will take. For the first
instruction the opcode is C7, corresponding
to "Move Immediate"* or in ASM mov. If you
are a particularly smart cookie you might guess that
what comes next is telling the cpu where its moving
something to. This byte is called the ModR/M byte,
C0 in this instance meaning the
rax register. Yes, 3C 00 00 00 is 60, but in little endian format.
Technically C7 /0 corresponds to MOV r/m64, imm32, but I refuse to elaborate on that for now.
Concatenated into shellcode string notation:
char sc[] = "\x48\xC7\xC0\x3C\x00\x00\x00\x48\x31\xFF\x0F\x05";
The next step is to place that string into executable memory, call it and let the computer do its thing. The CPU sees no difference between these representations, they are the same bytes. The art of maldev is how you get it there and how you call it without some nosey "EDR" deleting your beautifully crafted puppycat.jpg.exe.
Writing The Code
When you write code, you likely do not mind how the compiler will structure the binary, this however, is very important to how we are actually going to extract our shellcode as the compiler bakes in a lot of noise:
+--------------------------------------------------+
| DOS Header |
| "MZ" |
+--------------------------------------------------+
| DOS Stub |
| "This program cannot be run in DOS mode." |
+--------------------------------------------------+
| PE Header |
| "PE\0\0" |
+--------------------------------------------------+
| Optional Header |
| ImageBase, EntryPoint, Section Info, etc |
+--------------------------------------------------+
| .text |
| Executable code |
| main(), CRT startup, compiler generated code |
+--------------------------------------------------+
| .rdata |
| Read-only data, strings, imports |
+--------------------------------------------------+
| .data |
| Initialized global variables |
+--------------------------------------------------+
| .pdata |
| Exception handling metadata |
+--------------------------------------------------+
| .rsrc |
| Icons, manifests, version info |
+--------------------------------------------------+
| .reloc |
| Relocation table |
+--------------------------------------------------+
| Import Address Table |
| kernel32.dll, ntdll.dll, etc |
+--------------------------------------------------+
Our shellcode is usually hiding somewhere inside
.text, surrounded by runtime initialization
code, metadata, imports, relocations and other compiler-generated
shit we neither need nor want. The cleaner our code is, the easier
extraction becomes.
Sectioning
The easiest way to separate our code from the junk is to have it placed in a specific section that we can force to only contain our code. Lucky for us, modern compilers will allow us to do just that:
#include "myheader.h"
// GCC/Clang
__attribute__((section(".mysection")))
// MSVC
#pragma code_seg(".mysection")
int innocentfn(CONST PBYTE pl, CONST SIZE_T plsize) {
// do stuff
}
With this our code will be contained within the .mysection section rather than .text which will aid us later when extracting.
Position Independence
When creating shellcode there is a necessity that the code can run in any place in memory that it happens to land. Meaning references to absolute addresses are banned, everything must be position independent without assuming anything about the current memory layout, because once you copy the shellcode elsewhere, those addresses still point to the old image base.
An example of code that is not explicitly position independent:
#include <stdio.h>
int secretValue = 1337;
/* pointer stored in writable memory (relocation target) */
int *globalPtr = &secretValue;
void printInfo(void){
printf("%d\n", *globalPtr);
}
int main(void){
/* assumes globalPtr resolves correctly via loader fixups */
printInfo();
return 0;
}
This style of code demonstrates a hard dependency on a fixed address via a global pointer. The main idea is that the pointer value is assumed stable and stored in a relocatable slot. This will probably break when you remove it from a PE/ELF context, which is exactly what we are going to do.
And here is code that is accomplishing the same thing while avoiding absolute addressing:
#include <stdio.h>
static int secretValue = 1337;
static int* getPtr(void){
/* compiler typically uses RIP-relative addressing here */
return &secretValue;
}
void printInfo(void){
int *ptr = getPtr();
printf("%d\n", *ptr);
}
int main(void){
printInfo();
return 0;
}
The astute ones among you may notice that calling printf() would not quite work in shellcode,
since you would not be able to use the Import Address Table to find the printf function.
And you would be correct. Your homework is to research how to dynamically resolve functions
so that you can use them in shellcode. Have fun.
Compiling and Linking
Now we have reached the easy part, it really is just two commands until you move onto extraction. But there is some nuance to dig into on this.
We will use MSVC tools as an example for this:
cl.exe /c /GS- /O2 shellcode.c
Let's look at the options and what they are doing. The /c
option is instructing the compiler to only create a .obj file and stop,
since we want to handle the linking manually to manage the structure of
the binary. The /GS- option disables the stack canary that
MSVC adds to functions to prevent overflows. This requires a call to
__security_check_cookie, which is an external C Runtime (CRT) function that
will cause your shellcode to crash. The /O2 option is for
optimizing the code. This will also help reduce the number of null
bytes, which we will touch on later.
Next is to manually link the .obj file into a binary:
link.exe /ENTRY:FunctionName /NODEFAULTLIB /FIXED shellcode.obj
This is where the nuance really lies, to start: /ENTRY:FunctionName
is defining a manual entry point, by default programs start at main
or WinMain, which are actually called by the CRT startup code. Since
we are ditching the CRT, we tell the linker to start execution at our function
instead. /NODEFAULTLIB does exactly what it suggests, disables the
inclusion of default libraries. /FIXED Prevents the linker from
creating a .reloc section. Since our code is position-independent
by design, we nix the relocation table.
Extraction
Now we have a clean binary, parsing the PE at this point is trivial with a python library pefile:
import pefile
import sys
def save_shellcode(exe_path):
try:
pe = pefile.PE(exe_path)
target_section = None
for section in pe.sections:
if b".shellcode" in section.Name:
target_section = section
break
if not target_section:
print("[-] Could not find .shellcode section!")
return
raw_code = target_section.get_data()[: target_section.Misc_VirtualSize]
c_string = ''.join(f'\\x{b:02x}' for b in raw_code)
print("\n[CSTRING]\n")
print(f"\"{c_string}\"")
except Exception as e:
print(f"[-] Error: {e}")
if __name__ == "__main__":
save_shellcode("shellcode.exe")
The script iterates over the PE's section table looking for a section named .shellcode straightforward enough. The interesting part is this line:
raw_code = target_section.get_data()[:target_section.Misc_VirtualSize]
get_data() pulls the sections raw bytes, but it returns data sized
to the sections raw data size, that is, the size after the linker has padded it
to meet the file alignment boundary (probably 512 bytes). That padding is just
\x00 appended to fill the alignment gap.
Misc_VirtualSize on the other hand, is the field the linker writes
to record the sections actual content size before any padding is applied.
By slicing get_data() to Misc_VirtualSize, you're
stripping the alignment padding and keeping only the bytes that you want.
Null-Bytes
One immediate problem you will run into when converting raw shellcode into a
cstring is the presence of null bytes. In C strings are terminated by a null byte,
meaning any \x00 inside your shellcode will truncate execution prematurely
if it is treated as a string literal.
In practice, this means raw output like:
\x48\x00\xC7...
shouldn't be embedded as a normal string. The string will end at the first
null byte and you will silently lose the rest of your payload.
The simplest and easiest way to work around this is to store the shellcode in
unsigned char[] and execute it as a buffer. Encoding, encryption, substitution, etc.
are the more interesting solutions, but those belong in a post about evasion, not
creation. This one's long enough.
Conclusion
That's pretty much it. That's how u make that shit. If you want to see examples of this being used to generate custom shellcode, feel free to check out the first maldev project I will totally finish one day stubcore.