Writeup - Mysterious Vault (DUCTF 2025)
DUCTF 2025 - Mysterious Vault
Description
1 | You've discovered a mysterious vault exposed on a server - surely, if you can break |
Writeup
Inside the provided zip file was a Dockerfile, nsjail.cfg
, the main executable mysterious_vault
and its source code, and two other executables password_handler_3000
and password_handler_3001
along with their source code. Lastly, two directories checker1-trusted-env/
and checker2-trusted-env/
were also provided with a password
file inside that was never referenced or used in the challenge. That was a lot more files than are usually included in pwn challenges so it took a hot second to break down everything that was inside.
mysterious_vault
The code for mysterious_vault
is below:
1 | // includes |
The first function called, setup()
, made calls to shmget()
and shmat()
which I was unfamiliar with.
int shmget(key_t key, size_t size, int shmflg)
(ref)shmget()
retrieves or creates a System V shared memory segment. These shared memory segments are custom mapped memory segments that can be shared by multiple processes at the same time.key
is a unique identifier that each process uses to access the shared memory segment.size
is the minimum size of the segment rounded up to the nearest page size.shmflg
are flag options provided to it.- The return value is an integer identifying the segment that can be passed into
shmat
.
void *shmat(int shmid, const void *shmaddr, int shmflg)
(ref)shmat()
takes an ID returned fromshmget()
and provides the virtual memory address of the shared memory segment.shmid
is the ID fromshmget()
shmaddr
is the address that the process would like this memory segment to be mapped at. If this argument isNULL
, then the system chooses the address.shmflg
are flag options provided to it.
In our case, shmget()
and shmat()
were used to create a new shared memory segment at address 0x1337000
with a size of 0x1000 bytes (they provided 0x20 but was rounded up to nearest page size). Note that the default memory permissions for shared memory segments is RW-only.
After creating the segment, a password of up to 512 bytes is read into a stack buffer and the following fields are written to the shared memory segment:
state
(int
) - A state of 0 meant “Ready for password authentication”, 1 meant “Password checker 1 retrieved pwd”, and 2 meant “Password checker 2 retrieved pwd”length
(int
) - The length of the password in the shared memory segmentpassword
(char []
) - The password
The spawn_checkers()
function is then called. It forks twice and has each child process call chroot('./checkerX-trusted-env')
and execve('./password_handler_300X')
. It then enters a while true loop where it consistently calls wait()
; the wait()
function returns whether or not the child processes have finished executing and some information about them. This loop checks if the child process has exited cleanly and what the exit status is. The exit status is either the return value from the main()
function, or the argument passed in to the exit()
function/syscall. If the exit status is not 0, then it throws an error and exits.
Assuming both password checker child processes exit cleanly with status 0, it compares the shared memory segment buffer with a 64-byte hard-coded byte sequence, and if they are the same, it runs the get_flag()
function.
Therefore, we can determine that our goal is to have both password checker exit cleanly, and have one of them write the hard-coded byte sequence to the shared memory segment.
Password Checkers
The password checkers were designed to be sandboxed processes that handled the untrusted user input, like something you might see inside of a browser. Both password checker binaries used the same source code, but had been compiled with different ABIs or ELF setups:
1 | password_handler_3000: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), |
They both were stripped, statically-linked binaries and also both had the same protections:
1 | Arch: amd64-64-little |
Inside of their setup, they also called the shmget()
and shmat()
functions to map the shared memory segment identified by the key 0xdeadbeef
at the address 0x1337000
. They closed stdout/stderr/stdin, and called a setup_sandbox()
function. This setup_sandbox()
function made two calls to prctl()
in order to set up seccomp with a custom filter. By using seccomp-tools
, I was able to find the filter looked like this:
1 | line CODE JT JF K |
This filter only allowed the read
, write
, exit
, and open
syscalls and would break otherwise.
The main code was fairly simple with an easy-to-spot vulnerability:
1 | int main() { |
The password provided by the main mysterious_vault
binary (which could be up to 512 bytes) was copied into a stack buffer of size 200 leading to an obvious buffer overflow. It then added one to the state
in the shared memory segment and waited for it to equal 2, meaning both password checkers had retrieved it. It then ran a quick check to ensure that none of the characters in the word “password” were present in the provided password, and returned 0 if that was the case.
Approach
Okay, so we know:
- Our goal is to have both checkers exit cleanly, and have a hard-coded byte sequence in the shared memory segment
- There is an easy buffer overflow in the password checkers with a large overflow length, no stack canaries, and no PIE
- Both checkers are statically-linked, meaning there’s lots of ROP gadgets available
The hard part, though, is that we can only specify one password, and therefore a single stream of bytes that need to act as a ROP chain for two different binaries with different gadgets and addresses.
My first thought was to try to cheese the challenge by doing ORW on the flag.txt
file. Even though seccomp was set up in the flag checkers, the ORW syscalls were allowed. stdin/stderr/stdout were all closed, but I figured I could re-open them using the open syscall. The first roadblock was the fact that chroot()
was called, so the flag file wasn’t technically mapped in our file space. Chroot is not a hard security boundary and can be bypassed, but requires the use of the mkdir
, chroot
, and chdir
syscalls, which we didn’t have access to. I looked into seccomp bypasses and found 2 of them, but the seccomp filter specifically blocked both of them.
I then thought that if the shared memory segment was RWX, we could put shellcode in there and that would allow us to easily figure out a ROP chain that worked for both. Unfortunately, shared memory segments are mapped as RW-only by default.
I then used cyclic()
from pwntools to test the padding lengths for each binary and found they differed - the ROP chain for 3000 required 232 bytes of padding, and the ROP chain for 3001 required 216 bytes of padding. Since the padding differed, we could be selective in our use of gadgets and interleave the two ROP chains. Since only one process needed to write the hard-coded byte sequence to the shared memory segment, we could just have the other one immediately exit with status code 0.
To make things easy, I decided my payload (password) would be:
- Padding
- ROP chain for 3000 that called exit (single gadget)
- ROP chain for 3001 that used the
rep movsb
instruction to write the hard-coded byte sequence to the beginning of the memory segment - Hard-coded byte sequence
Debugging
Because the forking, execves, and synchronization between all three processes, debugging it was a little more complicated. First off, everything had to be run with sudo perms so it could set up the sandbox and shared memory segments appropriately. I learned about the following GDB commands:
set follow-fork-mode [parent|child]
- whenfork()
is called and the process is split into two, this determines which process will be in focus in GDBset detach-on-fork [on|off]
- the default behavior forfork()
in GDB is to detach from the out-of-focus process to let it do it’s thing. Since we didn’t want that, we set this to “off”set follow-exec-mode [new|same]
- theexecve
syscall replaces the current process, and “same” meant to keep the same inferior, but “new” meant to create a new inferior. An inferior is like a child process that’s debuggable in the same GDB sessioncatch exec
- whenexecve
replaces the current process, it stops at the beginning of the new process so it can be properly debuggedinferior X
- usinginfo inferiors
showed all the inferiors and their numbers, and this command was used to switch between inferiors
The two base GDB scripts I used in my solve script were:
1 | # track both children |
and
1 | # let children do their thing and just follow the main process |
Exploitation
Since the main process used read()
to get our input, we weren’t restricted on any characters and could freely use what we wanted. The checkers also made sure that the letters in “password” weren’t present, but only checked up to the first null byte, so I made the first 9 bytes of padding b"b\x00bbbbbb"
. I also included the 64-byte hard-coded byte sequence in the padding (starting at address 0x1337020) just to save some space.
At byte 216, I placed the first ROP chain for checker 3001. I used the gadget mov rax,qword ptr[rbp-0x8]; mov rdi,qword ptr[rbp-0x10]; syscall
, which would populate $rax
and $rdi
from the attacker-controlled $rbp
register. I set $rbp
to partway through the shared 0x1337000 segment and had it put 0x3c into $rax
(exit syscall) and 0 into $rdi
(exit status). Note that I tried calling exit(0)
first, but the exit()
function actually uses the exit_group
syscall instead of exit
. Due to our seccomp filter, the exit_group
syscall would actually cause the checker to throw a fit and not exit cleanly.
For the checker 3000 ROP chain, I used various ROP gadgets to set up the registers for the rep movsb
instruction. If you’re not familiar with that instruction, it’s pretty much a built-in memcpy()
instruction. The $rcx
register contains the number of bytes to move, $rdi
contains the starting destination address, and $rsi
contains the starting source address. I ended this ROP chain with a single gadget that set up the exit
syscall with the 0 status.
I also had to take into account some concurrent execution issues. Checker 3000 was spun up first, and it’s job was to write data to the shared memory segment. If it wrote stuff there before checker 3001 could pull the ROP chain, then checker 3001 would not fail nicely. To add in a small delay, I set up a second rep movsb
instruction that copied 0x5000 bytes to itself+1 inside of the writable segment of the process that ran before copying the hard-coded sequence to the shared memory segment.
My final solve script (also here) was:
1 | from pwn import * |
This was a fun problem getting introduced to multi-exploitation in a sandboxed environment and took me just over 3.5 hours to solve.
Flag: DUCTF{pushing_and_popping_and_so_on_and_so_forth}