securinets
Puzzle // Securinets CTF Quals 2025
Only an admin can open /data/, where the challenge keeps an executable and an encrypted ZIP file. I worked my way to an admin login through three issues: the server lets us choose the editor role, it lets us accept a collaboration request sent to someone else, and an editor can read any user's password.
Getting an editor account
Open the registration page and fill in the normal fields. The page does not offer a role choice. I sent the form through Burp and added role=1 to the POST /confirm-register request before forwarding it.
The server reads the role from the request. If it is missing, it uses 2, which means a normal user. It blocks 0, the admin role, but accepts 1, the editor role:
role = request.form.get('role', '2')
role_map = {'1': 'editor', '2': 'user'}
if role == '0':
return jsonify({'error': 'Admin registration is not allowed.'}), 403
if role not in role_map:
return jsonify({'error': 'Invalid role id.'}), 400
The form sends a username, email, and phone_number; role is the extra field to add in Burp. After forwarding the changed request, the app creates an editor account and logs us in. This role gives us access to /users/<uuid>, but we still need the admin's UUID to use it.
Abusing the collaboration feature
The site has a normal Publish page. I used it to make an article, entered admin in the collaborator field, and submitted it. This creates a pending request addressed to the admin. It does not create the article yet.
I then opened Collaborations. The outgoing request is listed as pending, and its request UUID is in a hidden span in the page HTML:
<span class="d-none">{{ request.uuid }}</span>
<span class="badge bg-warning text-dark">Pending</span>
There is an Accept button for incoming requests, but not for outgoing ones. I used the browser's Developer Tools to read the UUID from the outgoing request, then sent this in Burp Repeater:
POST /collab/accept/<request_uuid> HTTP/1.1
Keep the session cookie from the browser request so Repeater is still logged in as the editor. The request does not need a body.
The handler checks that we are logged in and blocks admin users, then looks up the request by its UUID. It creates the article with the request's from_uuid as the author and its to_uuid as the collaborator. It never checks that our session UUID matches the intended recipient:
if not session.get('uuid'):
return jsonify({'error': 'Unauthorized'}), 401
c.execute("SELECT * FROM collab_requests WHERE uuid = ?", (request_uuid,))
request = c.fetchone()
c.execute("""
INSERT INTO articles (uuid, title, content, author_uuid, collaborator_uuid)
VALUES (?, ?, ?, ?, ?)
""", (request['article_uuid'], request['title'], request['content'],
request['from_uuid'], request['to_uuid']))
The server checks that we have a session, but not that the request was sent to us. So our editor account can accept the request it sent to admin. The article is created with us as the author and the admin as the collaborator.
Finding the admin UUID
After accepting the request, I went back to Home and opened the new article. Its page includes the collaborator's UUID in the HTML:
<span class="d-none collaborator-uuid">
{{ article.collaborator_uuid }}
</span>
d-none hides the span from the page, but the browser still receives its value. I found it in Developer Tools or View Page Source. This UUID belongs to the admin account. It is different from the collaboration request UUID we used in the previous step.
Reading the admin password
With the admin UUID, I opened /users/<admin_uuid> in the same browser session. Editors are allowed to use this endpoint, and it returns the matching user's details as JSON. The database query includes the password:
SELECT uuid, username, email, phone_number, role, password
FROM users
WHERE uuid = ?
The endpoint checks our role, but does not check whether we should be able to view that user's profile. It also returns the stored password. I copied the admin password from the JSON response, logged out, and used the normal login page to sign in as admin.
Getting the flag
As admin, I opened /data/ and downloaded dbconnect.exe and secrets.zip. The ZIP is password-protected, but the executable contains a readable config string. I checked it without running the Windows file:
strings -a dbconnect.exe | grep "password ="
The line gives the ZIP password:
password = 'PUZZLE+7011_X207+!*'
I used single quotes so the shell passes the +, !, and * characters as part of the password:
unzip -P 'PUZZLE+7011_X207+!*' secrets.zip
cat data.txt
The file contains the flag:
Securinets{777_P13c3_1T_Up_T0G3Th3R}