guest@websec: ~/websec/10-redirection-and-files/th — zsh — 100×40 เขียนโดย SaKKo ENTH โค้ด:

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 จริง เพราะการเช็กสตริงหลอกได้ง่าย

sim: ประลองตัวตรวจ redirectลองวิธีหลบเลี่ยง
payloads:
ตัวตรวจผลตัดสินเบราว์เซอร์ไปที่ไหนจริงๆ
# 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)
ตั้งแต่ Rails 7 redirect_to ไม่ยอมไป host อื่น เว้นแต่ใส่ allow_other_host: true ให้ใช้ url_from(params[:next]) แอปเก่าที่อัปเกรดมาอาจยังปิดอยู่ ลอง grep ดู

10.2 การอัปโหลดไฟล์: ชื่อไฟล์

ชื่อไฟล์มาจากผู้โจมตี ถ้าบันทึกตามนั้น ../../config/master.key จะไปอยู่นอกโฟลเดอร์อัปโหลด

อย่าพยายามลบส่วนที่ "ไม่ดี" เพราะอาจสร้างของใหม่ขึ้นมา ให้รับเฉพาะตัวอักษรที่ปลอดภัย หรือดีกว่านั้น ไม่ใช้ชื่อเขาเลย ใช้ชื่อสุ่มแทน

sim: ทำความสะอาดชื่อไฟล์blocklist เทียบกับ allowlist
try:

blocklist: ลบ "../" หนึ่งรอบ

allowlist: เก็บเฉพาะ [A-Za-z0-9._-]

ดีที่สุด: ชื่อสุ่ม

Active Storage ใช้คีย์สุ่มให้แล้ว ถ้าเขียนไฟล์เองด้วย 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 ที่เบราว์เซอร์ส่งมา
Active Storage เก็บไฟล์นอก 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
sim: ตัวแปลง pathโฟลเดอร์หลัก: /srv/app/files
try:

ทางแก้ที่เชื่อถือได้ 2 แบบ:

  1. แปลงเป็น path จริงก่อน แล้วเช็ก ว่ายังอยู่ในโฟลเดอร์ของเรา
  2. ดีกว่านั้น: ไม่ใช้ชื่อไฟล์เลย ดาวน์โหลดด้วย 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]) ดีกว่า