IDOR 不安全的直接物件參照攻擊

Insecure Direct Object Reference Vulnerability and Prevention

IDOR(Insecure Direct Object Reference,不安全的直接物件參照)是 OWASP Top 10 中 Broken Access Control 類別下最常見的漏洞之一。本文將深入探討 IDOR 漏洞的原理、類型、測試方法以及防禦策略。

什麼是 IDOR 漏洞

IDOR 漏洞發生在應用程式直接使用使用者提供的輸入來存取物件,而沒有進行適當的授權驗證。攻擊者可以透過修改參數值(如 ID、檔案名稱等)來存取未經授權的資源。

漏洞原理

IDOR 漏洞的核心問題在於:

  1. 直接物件參照:應用程式使用可預測的識別符(如遞增的數字 ID)來參照內部物件
  2. 缺乏授權檢查:伺服器端沒有驗證請求者是否有權限存取該物件
  3. 信任使用者輸入:應用程式假設使用者不會修改請求參數
1
2
3
4
5
6
7
8
9
# 典型的 IDOR 漏洞請求
GET /api/users/1001/profile HTTP/1.1
Host: vulnerable-app.com
Cookie: session=abc123

# 攻擊者修改 ID 後的請求
GET /api/users/1002/profile HTTP/1.1
Host: vulnerable-app.com
Cookie: session=abc123

漏洞影響

IDOR 漏洞可能導致:

  • 未授權存取敏感資料(個人資訊、財務資料)
  • 修改或刪除其他使用者的資料
  • 權限提升(從一般使用者到管理員)
  • 業務邏輯繞過
  • 隱私洩露與法規違規(GDPR、個資法)

IDOR 漏洞類型

水平權限提升(Horizontal Privilege Escalation)

水平權限提升指攻擊者存取同級別使用者的資源。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 漏洞範例:查看訂單 API
@app.route('/api/orders/<order_id>')
def get_order(order_id):
    # 沒有檢查訂單是否屬於當前使用者
    order = Order.query.get(order_id)
    return jsonify(order.to_dict())

# 攻擊者可以列舉其他使用者的訂單
# GET /api/orders/1001  -> 攻擊者的訂單
# GET /api/orders/1002  -> 其他使用者的訂單(未授權存取)

實際案例場景

1
2
3
4
5
6
7
# 正常請求 - 查看自己的銀行帳戶
GET /api/account/12345/balance HTTP/1.1
Authorization: Bearer eyJhbGc...

# 攻擊請求 - 嘗試查看他人帳戶
GET /api/account/12346/balance HTTP/1.1
Authorization: Bearer eyJhbGc...

垂直權限提升(Vertical Privilege Escalation)

垂直權限提升指攻擊者存取更高權限級別的資源或功能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 漏洞範例:管理功能 API
@app.route('/api/admin/users/<user_id>', methods=['DELETE'])
def delete_user(user_id):
    # 只檢查是否登入,沒有檢查是否為管理員
    if not current_user.is_authenticated:
        return jsonify({'error': 'Unauthorized'}), 401

    user = User.query.get(user_id)
    db.session.delete(user)
    db.session.commit()
    return jsonify({'message': 'User deleted'})

攻擊向量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 普通使用者嘗試存取管理員功能
DELETE /api/admin/users/999 HTTP/1.1
Authorization: Bearer <normal_user_token>

# 透過修改角色參數提升權限
PUT /api/users/1001 HTTP/1.1
Content-Type: application/json

{
    "role": "admin",
    "permissions": ["all"]
}

基於參數的 IDOR

不同類型的參數都可能存在 IDOR 漏洞:

URL 路徑參數

1
2
GET /users/123/documents/456
GET /api/v1/accounts/789/transactions

查詢字串參數

1
2
GET /download?file_id=12345
GET /invoice?id=67890&format=pdf

請求主體參數

1
2
3
4
5
6
7
8
POST /api/transfer HTTP/1.1
Content-Type: application/json

{
    "from_account": "ACC001",
    "to_account": "ACC002",
    "amount": 1000
}
1
2
3
GET /api/profile HTTP/1.1
Cookie: user_id=12345; session=abc123
X-User-ID: 12345

間接物件參照 IDOR

有時物件參照是間接的,但仍然可以被利用:

1
2
3
4
5
6
7
8
9
# 透過檔案名稱的間接參照
@app.route('/documents/<filename>')
def get_document(filename):
    # 檔案名稱可能包含其他使用者的 ID
    # 例如:user_12345_invoice.pdf
    return send_file(f'/documents/{filename}')

# 攻擊者可以猜測檔案名稱格式
# GET /documents/user_12346_invoice.pdf

實際案例分析

案例一:電子商務平台訂單洩露

漏洞描述:某電商平台的訂單查詢 API 存在 IDOR 漏洞。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 有漏洞的程式碼
@app.route('/api/orders/<order_id>')
def get_order(order_id):
    order = Order.query.get(order_id)
    if not order:
        return jsonify({'error': 'Order not found'}), 404
    return jsonify({
        'order_id': order.id,
        'customer_name': order.customer_name,
        'customer_address': order.address,
        'customer_phone': order.phone,
        'items': order.items,
        'total': order.total
    })

攻擊過程

1
2
3
4
5
6
7
8
9
# 步驟 1:確認自己的訂單 ID
curl -H "Authorization: Bearer $TOKEN" \
     https://shop.example.com/api/orders/10001

# 步驟 2:列舉其他訂單
for i in $(seq 10000 10100); do
    curl -s -H "Authorization: Bearer $TOKEN" \
         "https://shop.example.com/api/orders/$i" | jq .
done

影響:攻擊者可以存取所有訂單資訊,包括客戶姓名、地址、電話等敏感資料。

案例二:雲端儲存檔案存取

漏洞描述:雲端儲存服務的檔案下載功能使用可預測的檔案 ID。

1
2
3
4
5
6
7
8
9
// 有漏洞的 API 端點
app.get('/api/files/:fileId/download', async (req, res) => {
    const file = await File.findById(req.params.fileId);
    if (!file) {
        return res.status(404).json({ error: 'File not found' });
    }
    // 沒有檢查檔案所有權
    res.download(file.path, file.originalName);
});

Burp Suite 測試

1
2
3
4
5
6
7
# 原始請求
GET /api/files/507f1f77bcf86cd799439011/download HTTP/1.1
Host: cloud.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# 修改 MongoDB ObjectId 的時間戳部分來列舉檔案
GET /api/files/507f1f77bcf86cd799439012/download HTTP/1.1

案例三:銀行 API 帳戶餘額查詢

漏洞場景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 漏洞 API
@app.route('/api/accounts/<account_number>/balance')
@login_required
def get_balance(account_number):
    account = Account.query.filter_by(
        account_number=account_number
    ).first()

    if not account:
        return jsonify({'error': 'Account not found'}), 404

    # 缺少所有權驗證!
    return jsonify({
        'account_number': account.account_number,
        'balance': account.balance,
        'holder_name': account.holder_name
    })

安全修復

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@app.route('/api/accounts/<account_number>/balance')
@login_required
def get_balance(account_number):
    account = Account.query.filter_by(
        account_number=account_number,
        user_id=current_user.id  # 加入所有權檢查
    ).first()

    if not account:
        return jsonify({'error': 'Account not found'}), 404

    return jsonify({
        'account_number': account.account_number,
        'balance': account.balance
    })

案例四:醫療系統病歷存取

漏洞描述:醫療系統允許透過病歷編號直接存取病歷。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 有漏洞的 Java Spring Controller
@GetMapping("/api/records/{recordId}")
public ResponseEntity<MedicalRecord> getRecord(
        @PathVariable Long recordId,
        @AuthenticationPrincipal User user) {

    MedicalRecord record = recordRepository.findById(recordId)
        .orElseThrow(() -> new NotFoundException("Record not found"));

    // 沒有驗證醫生是否有權限查看此病歷
    return ResponseEntity.ok(record);
}

測試方法與工具

手動測試方法

步驟一:識別物件參照

尋找 URL、參數或請求主體中的識別符:

1
2
3
4
5
# 常見的 IDOR 參數
id, user_id, account_id, order_id, doc_id
file, filename, document, report
uid, guid, uuid
ref, reference, num, number

步驟二:收集有效的物件 ID

1
2
3
4
5
# 使用 Burp Suite 或瀏覽器開發工具記錄正常操作中的 ID
# 記錄自己帳戶的各種 ID
my_user_id=1001
my_order_ids=(10001 10002 10003)
my_file_ids=(f001 f002 f003)

步驟三:測試存取控制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 建立第二個測試帳戶
# 嘗試使用帳戶 A 的 token 存取帳戶 B 的資源

# 測試腳本
#!/bin/bash
TOKEN_A="user_a_token"
TOKEN_B="user_b_token"
USER_B_ID="1002"

# 使用 A 的 token 存取 B 的資源
curl -H "Authorization: Bearer $TOKEN_A" \
     "https://api.example.com/users/$USER_B_ID/profile"

Burp Suite 測試

使用 Intruder 進行 ID 列舉

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 1. 攔截請求
GET /api/users/1001/data HTTP/1.1

# 2. 發送到 Intruder
# 3. 設定 Payload 位置
GET /api/users/§1001§/data HTTP/1.1

# 4. 配置 Payload
Payload type: Numbers
From: 1000
To: 1100
Step: 1

# 5. 分析回應
# 比較回應長度、狀態碼、內容

使用 Autorize 擴充套件

Autorize 是 Burp Suite 的擴充套件,專門用於測試授權問題:

1
2
3
4
5
1. 安裝 Autorize 擴充套件
2. 設定低權限使用者的 Cookie/Token
3. 以高權限使用者瀏覽應用程式
4. Autorize 自動使用低權限憑證重放請求
5. 比較回應以識別授權繞過

使用 ffuf 進行模糊測試

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 安裝 ffuf
go install github.com/ffuf/ffuf/v2@latest

# ID 列舉測試
ffuf -u "https://api.example.com/users/FUZZ/profile" \
     -w <(seq 1000 2000) \
     -H "Authorization: Bearer $TOKEN" \
     -mc 200 \
     -o idor_results.json

# 使用字典檔進行測試
ffuf -u "https://api.example.com/documents/FUZZ" \
     -w /usr/share/wordlists/idor-ids.txt \
     -H "Cookie: session=$SESSION" \
     -fc 404,403

# 多參數測試
ffuf -u "https://api.example.com/api/v1/users/FUZZ1/orders/FUZZ2" \
     -w users.txt:FUZZ1 \
     -w orders.txt:FUZZ2 \
     -H "Authorization: Bearer $TOKEN" \
     -mode clusterbomb

使用 Postman 進行測試

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Postman Pre-request Script
// 動態修改 ID 進行測試
const baseId = 1000;
const currentId = pm.variables.get("currentId") || baseId;
pm.variables.set("userId", currentId);
pm.variables.set("currentId", parseInt(currentId) + 1);

// Postman Test Script
// 檢測 IDOR 漏洞
pm.test("Check for IDOR vulnerability", function () {
    const response = pm.response.json();
    const requestedId = pm.variables.get("userId");
    const myUserId = pm.environment.get("myUserId");

    if (requestedId !== myUserId && pm.response.code === 200) {
        console.log("Potential IDOR: Accessed user " + requestedId);
        pm.expect(response.user_id).to.equal(myUserId);
    }
});

自動化測試腳本

Python IDOR 測試腳本

  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
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/usr/bin/env python3
"""
IDOR Vulnerability Scanner
用於自動化測試 IDOR 漏洞的 Python 腳本
"""

import requests
import argparse
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse, urljoin
import re

class IDORScanner:
    def __init__(self, base_url, auth_header, timeout=10):
        self.base_url = base_url
        self.session = requests.Session()
        self.session.headers.update(auth_header)
        self.timeout = timeout
        self.results = []

    def test_numeric_idor(self, endpoint_pattern, id_range, original_id):
        """
        測試數字型 IDOR
        endpoint_pattern: /api/users/{id}/profile
        id_range: (1000, 1100)
        original_id: 當前使用者的 ID
        """
        vulnerable_ids = []

        for test_id in range(id_range[0], id_range[1] + 1):
            if test_id == original_id:
                continue

            url = endpoint_pattern.replace('{id}', str(test_id))
            full_url = urljoin(self.base_url, url)

            try:
                response = self.session.get(full_url, timeout=self.timeout)

                if response.status_code == 200:
                    vulnerable_ids.append({
                        'id': test_id,
                        'url': full_url,
                        'status_code': response.status_code,
                        'response_length': len(response.content),
                        'response_preview': response.text[:200]
                    })
                    print(f"[VULN] Potential IDOR: {full_url}")

            except requests.RequestException as e:
                print(f"[ERROR] Request failed for {full_url}: {e}")

        return vulnerable_ids

    def test_uuid_idor(self, endpoint_pattern, uuid_list, original_uuid):
        """
        測試 UUID 型 IDOR
        """
        vulnerable_uuids = []

        for test_uuid in uuid_list:
            if test_uuid == original_uuid:
                continue

            url = endpoint_pattern.replace('{uuid}', test_uuid)
            full_url = urljoin(self.base_url, url)

            try:
                response = self.session.get(full_url, timeout=self.timeout)

                if response.status_code == 200:
                    vulnerable_uuids.append({
                        'uuid': test_uuid,
                        'url': full_url,
                        'status_code': response.status_code
                    })
                    print(f"[VULN] Potential IDOR: {full_url}")

            except requests.RequestException as e:
                print(f"[ERROR] Request failed: {e}")

        return vulnerable_uuids

    def test_parameter_tampering(self, url, params, param_to_test, test_values):
        """
        測試參數竄改型 IDOR
        """
        original_value = params.get(param_to_test)
        vulnerable_params = []

        for test_value in test_values:
            if test_value == original_value:
                continue

            test_params = params.copy()
            test_params[param_to_test] = test_value
            full_url = urljoin(self.base_url, url)

            try:
                response = self.session.get(
                    full_url,
                    params=test_params,
                    timeout=self.timeout
                )

                if response.status_code == 200:
                    vulnerable_params.append({
                        'param': param_to_test,
                        'value': test_value,
                        'url': response.url
                    })
                    print(f"[VULN] Parameter tampering: {param_to_test}={test_value}")

            except requests.RequestException as e:
                print(f"[ERROR] Request failed: {e}")

        return vulnerable_params

    def parallel_test(self, endpoint_pattern, id_list, original_id, workers=10):
        """
        平行測試多個 ID
        """
        results = []

        with ThreadPoolExecutor(max_workers=workers) as executor:
            futures = {}

            for test_id in id_list:
                if test_id == original_id:
                    continue

                url = endpoint_pattern.replace('{id}', str(test_id))
                full_url = urljoin(self.base_url, url)
                future = executor.submit(
                    self.session.get,
                    full_url,
                    timeout=self.timeout
                )
                futures[future] = test_id

            for future in as_completed(futures):
                test_id = futures[future]
                try:
                    response = future.result()
                    if response.status_code == 200:
                        results.append({
                            'id': test_id,
                            'url': response.url,
                            'status': 'VULNERABLE'
                        })
                        print(f"[VULN] ID {test_id} accessible")
                except Exception as e:
                    print(f"[ERROR] ID {test_id}: {e}")

        return results

    def generate_report(self, output_file='idor_report.json'):
        """
        產生測試報告
        """
        report = {
            'target': self.base_url,
            'total_vulnerabilities': len(self.results),
            'findings': self.results
        }

        with open(output_file, 'w') as f:
            json.dump(report, f, indent=2)

        print(f"\n[INFO] Report saved to {output_file}")
        return report


def main():
    parser = argparse.ArgumentParser(description='IDOR Vulnerability Scanner')
    parser.add_argument('-u', '--url', required=True, help='Base URL')
    parser.add_argument('-e', '--endpoint', required=True,
                        help='Endpoint pattern (e.g., /api/users/{id}/profile)')
    parser.add_argument('-t', '--token', required=True, help='Authorization token')
    parser.add_argument('-r', '--range', default='1-100',
                        help='ID range to test (e.g., 1-100)')
    parser.add_argument('-o', '--original-id', type=int, required=True,
                        help='Your original user ID')
    parser.add_argument('-w', '--workers', type=int, default=10,
                        help='Number of parallel workers')
    parser.add_argument('--output', default='idor_report.json',
                        help='Output report file')

    args = parser.parse_args()

    # 解析 ID 範圍
    id_start, id_end = map(int, args.range.split('-'))
    id_list = list(range(id_start, id_end + 1))

    # 初始化掃描器
    auth_header = {'Authorization': f'Bearer {args.token}'}
    scanner = IDORScanner(args.url, auth_header)

    print(f"[INFO] Starting IDOR scan on {args.url}")
    print(f"[INFO] Testing endpoint: {args.endpoint}")
    print(f"[INFO] ID range: {id_start} - {id_end}")

    # 執行掃描
    results = scanner.parallel_test(
        args.endpoint,
        id_list,
        args.original_id,
        args.workers
    )

    scanner.results = results
    scanner.generate_report(args.output)

    print(f"\n[INFO] Scan complete. Found {len(results)} potential vulnerabilities.")


if __name__ == '__main__':
    main()

Bash 快速測試腳本

 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
81
82
83
#!/bin/bash
#
# IDOR Quick Test Script
# 快速測試 IDOR 漏洞的 Bash 腳本
#

# 設定變數
BASE_URL="https://api.example.com"
ENDPOINT="/api/users/{ID}/profile"
TOKEN="your_jwt_token_here"
MY_USER_ID=1001
START_ID=1000
END_ID=1100
OUTPUT_FILE="idor_findings.txt"

# 顏色設定
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

echo "========================================"
echo "IDOR Vulnerability Quick Test"
echo "========================================"
echo "Target: $BASE_URL"
echo "Endpoint: $ENDPOINT"
echo "Testing ID range: $START_ID - $END_ID"
echo ""

# 清空輸出檔案
> "$OUTPUT_FILE"

# 計數器
vulnerable_count=0
tested_count=0

for id in $(seq $START_ID $END_ID); do
    # 跳過自己的 ID
    if [ "$id" -eq "$MY_USER_ID" ]; then
        continue
    fi

    # 建構 URL
    url="${BASE_URL}${ENDPOINT//\{ID\}/$id}"

    # 發送請求
    response=$(curl -s -o /tmp/response.txt -w "%{http_code}" \
        -H "Authorization: Bearer $TOKEN" \
        -H "Content-Type: application/json" \
        "$url")

    tested_count=$((tested_count + 1))

    # 檢查回應
    if [ "$response" == "200" ]; then
        echo -e "${RED}[VULN]${NC} ID $id - Status: $response"
        echo "ID: $id, URL: $url, Status: $response" >> "$OUTPUT_FILE"

        # 儲存回應內容
        echo "Response:" >> "$OUTPUT_FILE"
        cat /tmp/response.txt >> "$OUTPUT_FILE"
        echo -e "\n---\n" >> "$OUTPUT_FILE"

        vulnerable_count=$((vulnerable_count + 1))
    elif [ "$response" == "403" ] || [ "$response" == "401" ]; then
        echo -e "${GREEN}[SAFE]${NC} ID $id - Status: $response (Access Denied)"
    elif [ "$response" == "404" ]; then
        echo -e "${YELLOW}[INFO]${NC} ID $id - Status: $response (Not Found)"
    else
        echo -e "${YELLOW}[INFO]${NC} ID $id - Status: $response"
    fi

    # 避免過快發送請求
    sleep 0.1
done

echo ""
echo "========================================"
echo "Scan Complete"
echo "========================================"
echo "Total tested: $tested_count"
echo "Vulnerabilities found: $vulnerable_count"
echo "Results saved to: $OUTPUT_FILE"

Nuclei 模板

 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
# idor-numeric-id.yaml
id: idor-numeric-id

info:
  name: IDOR - Numeric ID Enumeration
  author: security-team
  severity: high
  description: Tests for IDOR vulnerability using numeric ID enumeration
  tags: idor,access-control

http:
  - method: GET
    path:
      - "{{BaseURL}}/api/users/{{id}}/profile"

    payloads:
      id:
        - "1"
        - "2"
        - "100"
        - "1000"
        - "9999"

    headers:
      Authorization: "Bearer {{token}}"

    matchers-condition: and
    matchers:
      - type: status
        status:
          - 200

      - type: word
        words:
          - "user_id"
          - "email"
          - "profile"
        condition: or

    extractors:
      - type: json
        json:
          - '.user_id'
          - '.email'

執行 Nuclei 掃描:

1
2
3
4
5
6
7
8
# 執行單一模板
nuclei -t idor-numeric-id.yaml -u https://target.com -V token=your_token

# 執行所有 IDOR 相關模板
nuclei -t ~/nuclei-templates/vulnerabilities/idor/ -u https://target.com

# 批次掃描多個目標
nuclei -t idor-numeric-id.yaml -l targets.txt -V token=your_token -o results.txt

防禦策略

實施存取控制檢查

 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
# 正確的存取控制實作
from functools import wraps
from flask import g, abort

def ownership_required(model_class, id_param='id'):
    """
    裝飾器:驗證資源所有權
    """
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            resource_id = kwargs.get(id_param)
            resource = model_class.query.get(resource_id)

            if not resource:
                abort(404)

            # 檢查所有權
            if resource.user_id != g.current_user.id:
                abort(403)

            return f(*args, **kwargs)
        return decorated_function
    return decorator

# 使用裝飾器
@app.route('/api/orders/<int:id>')
@login_required
@ownership_required(Order, 'id')
def get_order(id):
    order = Order.query.get(id)
    return jsonify(order.to_dict())

使用間接參照對映

 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
import hashlib
import secrets
from datetime import datetime, timedelta

class IndirectReferenceMap:
    """
    間接參照對映:將內部 ID 對映到臨時的隨機 token
    """
    def __init__(self, expiry_minutes=30):
        self.mapping = {}
        self.reverse_mapping = {}
        self.expiry_minutes = expiry_minutes

    def create_reference(self, internal_id, user_id):
        """建立間接參照"""
        # 產生隨機 token
        token = secrets.token_urlsafe(32)
        expiry = datetime.now() + timedelta(minutes=self.expiry_minutes)

        self.mapping[token] = {
            'internal_id': internal_id,
            'user_id': user_id,
            'expiry': expiry
        }

        return token

    def resolve_reference(self, token, user_id):
        """解析間接參照"""
        if token not in self.mapping:
            return None

        ref = self.mapping[token]

        # 檢查是否過期
        if datetime.now() > ref['expiry']:
            del self.mapping[token]
            return None

        # 檢查使用者是否匹配
        if ref['user_id'] != user_id:
            return None

        return ref['internal_id']

# 使用範例
reference_map = IndirectReferenceMap()

@app.route('/api/orders')
@login_required
def list_orders():
    orders = Order.query.filter_by(user_id=current_user.id).all()
    result = []

    for order in orders:
        # 建立間接參照
        ref = reference_map.create_reference(order.id, current_user.id)
        result.append({
            'reference': ref,
            'total': order.total,
            'status': order.status
        })

    return jsonify(result)

@app.route('/api/orders/<reference>')
@login_required
def get_order(reference):
    # 解析間接參照
    order_id = reference_map.resolve_reference(reference, current_user.id)

    if not order_id:
        abort(404)

    order = Order.query.get(order_id)
    return jsonify(order.to_dict())

使用 UUID 取代遞增 ID

 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
import uuid
from sqlalchemy import Column, String
from sqlalchemy.dialects.postgresql import UUID

class User(db.Model):
    __tablename__ = 'users'

    # 使用 UUID 作為主鍵
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    email = Column(String(255), unique=True, nullable=False)

    # 如果需要數字 ID,保持為內部使用
    internal_id = Column(db.Integer, autoincrement=True)

class Document(db.Model):
    __tablename__ = 'documents'

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    user_id = Column(UUID(as_uuid=True), db.ForeignKey('users.id'))
    filename = Column(String(255))

    # 產生安全的公開參照
    @property
    def public_reference(self):
        return str(self.id)

實施速率限制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"]
)

@app.route('/api/users/<user_id>/profile')
@limiter.limit("10 per minute")  # 限制列舉攻擊
@login_required
def get_user_profile(user_id):
    # 加上速率限制可以減緩 IDOR 列舉攻擊
    pass

記錄和監控

 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
import logging
from datetime import datetime

# 設定安全日誌
security_logger = logging.getLogger('security')
security_logger.setLevel(logging.WARNING)

handler = logging.FileHandler('/var/log/app/security.log')
handler.setFormatter(logging.Formatter(
    '%(asctime)s - %(levelname)s - %(message)s'
))
security_logger.addHandler(handler)

def log_access_attempt(user_id, resource_type, resource_id, success):
    """記錄存取嘗試"""
    security_logger.info(
        f"Access attempt: user={user_id}, "
        f"resource={resource_type}:{resource_id}, "
        f"success={success}, "
        f"ip={request.remote_addr}"
    )

    # 偵測可疑活動
    if not success:
        detect_suspicious_activity(user_id, resource_type)

def detect_suspicious_activity(user_id, resource_type):
    """偵測可疑的 IDOR 嘗試"""
    # 計算最近的失敗嘗試次數
    recent_failures = get_recent_failures(user_id, minutes=5)

    if recent_failures > 10:
        security_logger.warning(
            f"Potential IDOR attack detected: "
            f"user={user_id}, failures={recent_failures}"
        )
        # 可以觸發警報或暫時封鎖使用者
        trigger_security_alert(user_id)

各框架安全實作

Django 安全實作

 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
# Django - 使用 get_object_or_404 搭配過濾條件
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required

@login_required
def get_order(request, order_id):
    # 正確:加入使用者過濾條件
    order = get_object_or_404(
        Order,
        id=order_id,
        user=request.user  # 確保只能存取自己的訂單
    )
    return JsonResponse(order.to_dict())

# Django REST Framework - 使用權限類別
from rest_framework import permissions, viewsets

class IsOwner(permissions.BasePermission):
    """
    自訂權限:只允許物件擁有者存取
    """
    def has_object_permission(self, request, view, obj):
        return obj.user == request.user

class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()
    serializer_class = OrderSerializer
    permission_classes = [permissions.IsAuthenticated, IsOwner]

    def get_queryset(self):
        # 只返回當前使用者的訂單
        return Order.objects.filter(user=self.request.user)

Express.js 安全實作

 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
// Express.js - 中介軟體檢查所有權
const checkOwnership = (model, paramName = 'id') => {
    return async (req, res, next) => {
        try {
            const resourceId = req.params[paramName];
            const resource = await model.findById(resourceId);

            if (!resource) {
                return res.status(404).json({ error: 'Not found' });
            }

            // 檢查所有權
            if (resource.userId.toString() !== req.user.id.toString()) {
                return res.status(403).json({ error: 'Access denied' });
            }

            req.resource = resource;
            next();
        } catch (error) {
            next(error);
        }
    };
};

// 使用中介軟體
app.get('/api/orders/:id',
    authenticate,
    checkOwnership(Order),
    (req, res) => {
        res.json(req.resource);
    }
);

// 使用 Mongoose 的查詢範圍
const Order = require('./models/Order');

app.get('/api/orders/:id', authenticate, async (req, res) => {
    const order = await Order.findOne({
        _id: req.params.id,
        userId: req.user.id  // 確保只能查詢自己的訂單
    });

    if (!order) {
        return res.status(404).json({ error: 'Order not found' });
    }

    res.json(order);
});

Spring Boot 安全實作

 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
81
82
83
84
85
86
87
88
// Spring Boot - 使用 Spring Security 和方法級安全
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    // 安全配置
}

// 自訂權限評估器
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {

    @Autowired
    private OrderRepository orderRepository;

    @Override
    public boolean hasPermission(
            Authentication auth,
            Object targetDomainObject,
            Object permission) {

        if (targetDomainObject instanceof Order) {
            Order order = (Order) targetDomainObject;
            User user = (User) auth.getPrincipal();
            return order.getUserId().equals(user.getId());
        }

        return false;
    }

    @Override
    public boolean hasPermission(
            Authentication auth,
            Serializable targetId,
            String targetType,
            Object permission) {

        if ("Order".equals(targetType)) {
            Order order = orderRepository.findById((Long) targetId)
                .orElse(null);
            if (order == null) return false;

            User user = (User) auth.getPrincipal();
            return order.getUserId().equals(user.getId());
        }

        return false;
    }
}

// Controller 使用方法級安全
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @GetMapping("/{id}")
    @PreAuthorize("hasPermission(#id, 'Order', 'read')")
    public ResponseEntity<Order> getOrder(@PathVariable Long id) {
        return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasPermission(#id, 'Order', 'delete')")
    public ResponseEntity<Void> deleteOrder(@PathVariable Long id) {
        orderService.deleteById(id);
        return ResponseEntity.noContent().build();
    }
}

// 服務層的額外檢查
@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    public Optional<Order> findByIdForUser(Long orderId, Long userId) {
        return orderRepository.findByIdAndUserId(orderId, userId);
    }

    public List<Order> findAllForUser(Long userId) {
        return orderRepository.findByUserId(userId);
    }
}

Ruby on Rails 安全實作

 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
# Rails - 使用 scope 和 before_action
class OrdersController < ApplicationController
  before_action :authenticate_user!
  before_action :set_order, only: [:show, :update, :destroy]

  def show
    render json: @order
  end

  def index
    # 只返回當前使用者的訂單
    @orders = current_user.orders
    render json: @orders
  end

  private

  def set_order
    # 使用 scope 確保只能存取自己的訂單
    @order = current_user.orders.find_by(id: params[:id])

    unless @order
      render json: { error: 'Order not found' }, status: :not_found
    end
  end
end

# 使用 Pundit 進行授權
class OrderPolicy < ApplicationPolicy
  def show?
    record.user == user
  end

  def update?
    record.user == user
  end

  def destroy?
    record.user == user && record.status == 'pending'
  end

  class Scope < Scope
    def resolve
      scope.where(user: user)
    end
  end
end

# Controller 使用 Pundit
class OrdersController < ApplicationController
  include Pundit::Authorization

  def show
    @order = Order.find(params[:id])
    authorize @order
    render json: @order
  end

  def index
    @orders = policy_scope(Order)
    render json: @orders
  end
end

Go (Gin) 安全實作

 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
package main

import (
    "net/http"
    "github.com/gin-gonic/gin"
    "gorm.io/gorm"
)

// 中介軟體:檢查資源所有權
func OwnershipRequired(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        userID := c.GetUint("userID") // 從認證中介軟體取得
        resourceID := c.Param("id")

        var order Order
        result := db.Where("id = ? AND user_id = ?", resourceID, userID).First(&order)

        if result.Error != nil {
            if result.Error == gorm.ErrRecordNotFound {
                c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
            } else {
                c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
            }
            c.Abort()
            return
        }

        c.Set("order", order)
        c.Next()
    }
}

// 路由設定
func setupRoutes(r *gin.Engine, db *gorm.DB) {
    api := r.Group("/api")
    api.Use(AuthMiddleware()) // 認證中介軟體

    orders := api.Group("/orders")
    {
        orders.GET("", listOrders(db))
        orders.GET("/:id", OwnershipRequired(db), getOrder)
        orders.PUT("/:id", OwnershipRequired(db), updateOrder(db))
        orders.DELETE("/:id", OwnershipRequired(db), deleteOrder(db))
    }
}

func getOrder(c *gin.Context) {
    order := c.MustGet("order").(Order)
    c.JSON(http.StatusOK, order)
}

func listOrders(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        userID := c.GetUint("userID")

        var orders []Order
        db.Where("user_id = ?", userID).Find(&orders)

        c.JSON(http.StatusOK, orders)
    }
}

安全設計原則

最小權限原則

1
2
3
1. 使用者只能存取完成任務所需的最少資源
2. 預設拒絕所有存取,明確授予必要權限
3. 定期審查和撤銷不必要的權限

深度防禦

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
多層次的存取控制:

┌─────────────────────────────────────────┐
│           網路層 (Firewall/WAF)          │
├─────────────────────────────────────────┤
│         應用層 (Authentication)          │
├─────────────────────────────────────────┤
│          服務層 (Authorization)          │
├─────────────────────────────────────────┤
│         資料層 (Row-Level Security)      │
└─────────────────────────────────────────┘

安全開發生命週期

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
1. 設計階段
   - 威脅建模
   - 識別敏感資源
   - 定義存取控制需求

2. 開發階段
   - 使用安全框架
   - 程式碼審查
   - 單元測試存取控制

3. 測試階段
   - 安全測試
   - 滲透測試
   - 自動化掃描

4. 部署階段
   - 安全配置檢查
   - 監控和日誌
   - 事件回應計畫

IDOR 防禦檢查清單

 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
## 開發檢查清單

### API 設計
- [ ] 避免在 URL 中暴露內部 ID
- [ ] 使用 UUID 或間接參照
- [ ] 實施一致的命名規範

### 存取控制
- [ ] 每個端點都有授權檢查
- [ ] 使用集中式授權邏輯
- [ ] 實施物件級權限驗證

### 資料查詢
- [ ] 查詢時加入使用者過濾條件
- [ ] 使用 ORM 的 scope 功能
- [ ] 避免直接使用使用者輸入

### 日誌和監控
- [ ] 記錄所有存取嘗試
- [ ] 設定異常偵測警報
- [ ] 定期審查存取日誌

### 測試
- [ ] 編寫授權相關的測試案例
- [ ] 進行跨帳戶測試
- [ ] 納入安全測試自動化

結論

IDOR 漏洞雖然概念簡單,但在實際應用中非常普遍且危害嚴重。防禦 IDOR 的關鍵在於:

  1. 永遠不要信任使用者輸入:所有涉及資源存取的請求都必須驗證授權
  2. 實施多層存取控制:從 API 閘道到資料庫層都要有相應的檢查
  3. 使用間接參照:避免直接暴露內部識別符
  4. 持續監控和測試:定期進行安全測試,監控異常存取行為

透過遵循本文介紹的防禦策略和安全設計原則,可以有效降低 IDOR 漏洞的風險,保護使用者資料和系統安全。

參考資源

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy