guest@websec ~ % man 10-redirection-and-files
10 · Redirect และไฟล์
ทั้งสองแปลง input ของผู้ใช้เป็นตำแหน่ง: URL ที่จะไป หรือไฟล์บนดิสก์ ถ้าให้ผู้ใช้เลือกตำแหน่งเอง ผู้โจมตีจะเลือกที่ที่คุณไม่ได้คิดไว้
10.1 Redirect
หลายแอปมี /login?next=/dashboard ถ้าเรา redirect ไปตามที่ next บอก ผู้โจมตีก็สร้างลิงก์ที่เริ่มที่เว็บเราแต่จบที่เว็บเขาได้:
https://bank.test/login?next=https://bank-secure-login.evil.test/
เหยื่อเห็นโดเมนของเรา ล็อกอินจริง แล้วไปเจอหน้าปลอม "session หมดอายุ" phishing ที่สมบูรณ์แบบ
ทางแก้: redirect ไปได้เฉพาะ path ในเว็บเรา และใช้ URL parser จริง เพราะการเช็กสตริงหลอกได้ง่าย
| ตัวตรวจ | ผลตัดสิน | เบราว์เซอร์ไปที่ไหนจริงๆ |
|---|
# Rails 7.0+ default: raise_on_open_redirects = true redirect_to params[:next] # raises for foreign hosts redirect_to params[:next], allow_other_host: true # opts out: never with user input # keep "return to" on our own site redirect_to url_from(params[:next]) || root_path
function safeNext(next) {
// resolve against our own origin, then require the same origin
try {
const u = new URL(next, "https://bank.test");
if (u.origin !== "https://bank.test") return "/";
return u.pathname + u.search + u.hash; // relative, same-site
} catch { return "/"; }
}
app.get("/after-login", (req, res) => res.redirect(safeNext(req.query.next)));
# Django ships a helper for exactly this
from django.utils.http import url_has_allowed_host_and_scheme
def after_login(request):
nxt = request.GET.get("next", "/")
if not url_has_allowed_host_and_scheme(nxt, allowed_hosts={request.get_host()},
require_https=request.is_secure()):
nxt = "/"
return redirect(nxt)
redirect_to ไม่ยอมไป host อื่น เว้นแต่ใส่ allow_other_host: true ให้ใช้ url_from(params[:next]) แอปเก่าที่อัปเกรดมาอาจยังปิดอยู่ ลอง grep ดู10.2 การอัปโหลดไฟล์: ชื่อไฟล์
ชื่อไฟล์มาจากผู้โจมตี ถ้าบันทึกตามนั้น ../../config/master.key จะไปอยู่นอกโฟลเดอร์อัปโหลด
อย่าพยายามลบส่วนที่ "ไม่ดี" เพราะอาจสร้างของใหม่ขึ้นมา ให้รับเฉพาะตัวอักษรที่ปลอดภัย หรือดีกว่านั้น ไม่ใช้ชื่อเขาเลย ใช้ชื่อสุ่มแทน
blocklist: ลบ "../" หนึ่งรอบ
allowlist: เก็บเฉพาะ [A-Za-z0-9._-]
ดีที่สุด: ชื่อสุ่ม
original_filename ปัญหาก็กลับมาการอัปโหลดกับ denial of service
การย่อรูปหรือแปลงไฟล์ช้า ให้ทำใน background job และจำกัดขนาดไฟล์ที่ proxy
10.3 โค้ดที่รันได้ในไฟล์อัปโหลด
ถ้าไฟล์อัปโหลดอยู่ในโฟลเดอร์สาธารณะ เซิร์ฟเวอร์อาจรันมัน และไฟล์ .html หรือ .svg ที่อัปโหลดก็รัน JavaScript บนโดเมนเราได้
- เก็บไฟล์อัปโหลด นอก web root หรือใน object storage (S3, GCS) บนโดเมนแยก
- ส่งไฟล์โดยระบุชนิดชัดเจน และให้เป็นไฟล์ดาวน์โหลด
- ส่ง
X-Content-Type-Options: nosniffเพื่อไม่ให้เบราว์เซอร์เดา (หัวข้อ 11) - ตรวจชนิดไฟล์จริงโดยอ่าน magic bytes ไม่ใช่เชื่อนามสกุลหรือ
Content-Typeที่เบราว์เซอร์ส่งมา
public/ ตรวจชนิดไฟล์จริงจากเนื้อไฟล์ และให้ดาวน์โหลดไฟล์เสี่ยงอย่าง HTML กับ SVG แทนการแสดงผล แต่มัน ไม่ปฏิเสธ ไฟล์ใดๆ: การตรวจชนิดและขนาดไฟล์เป็นหน้าที่ของเรา10.4 การดาวน์โหลดไฟล์และ path traversal
บั๊กเดียวกันแต่กลับทิศ: URL ดาวน์โหลดที่สร้าง path จากพารามิเตอร์
# VULNERABLE def download send_file Rails.root.join("files", params[:name]) end # GET /download?name=../config/master.key → serves your master key
// VULNERABLE app.get("/download", (req, res) => res.sendFile(path.join(__dirname, "files", req.query.name))); // GET /download?name=../.env → serves your secrets
# VULNERABLE @app.get("/download") def download(): return send_file(os.path.join("files", request.args["name"])) # GET /download?name=../.env → serves your secrets
ทางแก้ที่เชื่อถือได้ 2 แบบ:
- แปลงเป็น path จริงก่อน แล้วเช็ก ว่ายังอยู่ในโฟลเดอร์ของเรา
- ดีกว่านั้น: ไม่ใช้ชื่อไฟล์เลย ดาวน์โหลดด้วย ID ของเรคคอร์ด แล้วตรวจว่าผู้ใช้มีสิทธิ์ดู
BASE = Rails.root.join("files").realpath
def download
path = BASE.join(params[:name].to_s).expand_path
return head :not_found unless path.to_s.start_with?("#{BASE}/") && path.file?
send_file path
end
# better: Active Storage record, authorized first
def show
doc = current_user.documents.find(params[:id])
redirect_to rails_blob_path(doc.file, disposition: "attachment")
end
const BASE = path.resolve(__dirname, "files") + path.sep;
app.get("/download", (req, res) => {
const target = path.resolve(BASE, String(req.query.name));
if (!target.startsWith(BASE)) return res.sendStatus(404); // escaped the folder
res.sendFile(target);
});
// Express's res.sendFile(name, { root: BASE }) also rejects "../" escapes.
from pathlib import Path
BASE = Path("files").resolve()
@app.get("/download")
def download():
target = (BASE / request.args["name"]).resolve()
if not target.is_relative_to(BASE): # Python 3.9+
abort(404)
return send_file(target)
# Flask's send_from_directory(BASE, name) performs this check for you.
send_file ไม่ตรวจ path ให้ ใช้ current_user.documents.find(params[:id]) ดีกว่า