jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Security for Frontend Devs · Part 21 — XS-Leaks: Cross-Site Leaks & Side Channels

Bonus track: how an attacker site asks the victim browser one yes/no question about another site — via frame counting, error events, and timing — without reading any response. The oracles, the defenses, a live simulator, and labs.

Phần 21 — Nhánh bonus trong series Web Security for Frontend Devs. Trước: Tiếp:

Same-Origin Policy (Phần 1) hứa rằng evil.com không đọc được phản hồi của bank.com. Đúng vậy. Nhưng SOP không hứa che giấu mọi thứ về phản hồi đó — và đây là khe hở của cả một họ tấn công tinh vi: XS-Leaks (Cross-Site Leaks).

Một XS-Leak không trộm dữ liệu trực tiếp. Nó hỏi trình duyệt của nạn nhân đang-đăng-nhập một câu hỏi có/không về một site khác — “người này có phải admin không?”, “đơn hàng #4012 có tồn tại không?”, “tìm ‘HIV’ có ra kết quả không?” — rồi đọc câu trả lời qua một kênh phụ (side channel) mà SOP chưa từng phủ. Từng bit một, kẻ tấn công rút ra bí mật.


Mô hình tư duy: oracle, không phải trộm cắp

Mọi XS-Leak đều cùng một hình hài:

1. Nạn nhân đang đăng nhập target.example mở tab evil.com.
2. evil.com kích hoạt một thao tác cross-site tới target (nhúng, mở, request).
3. Trình duyệt TỰ đính kèm cookie của nạn nhân (đây là chỗ mấu chốt).
4. evil.com không đọc được phản hồi — nhưng đo được MỘT thuộc tính quan sát được:
   số frame, sự kiện onload/onerror, thời gian phản hồi, trạng thái cache…
5. Thuộc tính đó khác nhau giữa "admin" và "không admin" → 1 bit bí mật rò ra.

Gọi là oracle: bạn không nhìn được bên trong, nhưng hỏi đúng cách thì nó gật hay lắc. Cốt lõi của phòng thủ là làm hai trạng thái không phân biệt được qua kênh đó — hoặc khiến request cross-site đăng xuất ngay từ đầu.


Oracle 1 — đếm frame (window.frames.length)

Số khung con của một cửa sổ là thông tin đọc được cross-origin — SOP không chặn:

// evil.com
const win = window.open("https://target.example/dashboard");
setTimeout(() => {
  // can't read DOM, but CAN read the count
  console.log(win.frames.length); // 3 cho admin, 2 cho user thường?
}, 1500);

Nếu trang admin render thêm một widget <iframe>, số đếm khác đi. Cùng kiểu này còn lạm dụng window.length, lỗi window.open ném ra, hay history.length. Không cần đọc một byte HTML nào.


Oracle 2 — sự kiện onload / onerror

Một phần tử nhúng phát sự kiện khác nhau tuỳ phản hồi load được hay không — và chính sự kiện đó là bit:

<!-- evil.com -->
<script
  src="https://target.example/admin/report.json"
  onload="leak('admin')"
  onerror="leak('not-admin')"></script>

200 (admin được phép) → onload; 403/404 (user thường) → onerror. Bạn không đọc được thân JSON, nhưng biết được nó tồn tại và bạn được phép. Biến thể đo Content-Type, kích thước, hay redirect.


Oracle 3 — thời gian phản hồi

Trang làm nhiều việc hơn thì trả lời lâu hơn — và thời gian đo được cross-site:

// evil.com — số lần lặp khuếch đại khác biệt nhỏ
const t0 = performance.now();
await fetch("https://target.example/search?q=secret", { mode: "no-cors", credentials: "include" });
const dt = performance.now() - t0; // tìm "secret" ra kết quả → chậm hơn rõ rệt

mode: "no-cors" khiến phản hồi opaque (không đọc được), nhưng thời gian thì có. Tìm có kết quả vs không kết quả, dashboard admin nặng vs trang user nhẹ — đều lệch thời gian.

Có cả họ con cache probing: nạp một tài nguyên rồi đo nhanh/chậm để biết nó đã ở cache chưa (tức nạn nhân từng ghé). Trình duyệt hiện đại phân vùng HTTP cache theo top-level site nên bịt phần lớn biến thể này mặc định — một lý do nữa để không tắt nó.


Thử ngay — trình mô phỏng XS-Leak

Bí mật là “khách có phải admin trên target.example?”. Chọn một oracle, rồi bật/tắt từng phòng thủ của target và xem kênh phụ mở hay đóng — và vì sao đúng phòng thủ đó (không phải mọi phòng thủ) mới khoá được cửa đó.

Mở demo đầy đủ:


Lab thực hành — tái hiện an toàn

Tự dựng một oracle đếm-frame trên máy với hai origin (cổng khác nhau = origin khác nhau).

Chuẩn bị — “target” với trạng thái phụ thuộc vai trò

mkdir -p /tmp/xsl && cd /tmp/xsl
# target trên cổng 5000: số iframe đổi theo ?role
cat > target.html <<'EOF'
<!doctype html><meta charset="utf-8"><title>target dashboard</title>
<h1>target.example dashboard</h1>
<script>
  const role = new URLSearchParams(location.search).get("role") || "user";
  // admin gets an extra widget frame — the leaked difference
  const n = role === "admin" ? 2 : 1;
  for (let i = 0; i < n; i++) document.body.appendChild(document.createElement("iframe"));
</script>
EOF
python3 -m http.server 5000 >/dev/null 2>&1 &
echo "target on http://localhost:5000/target.html"

Lab — “attacker” đọc số frame qua origin khác

cat > attacker.html <<'EOF'
<!doctype html><meta charset="utf-8"><title>evil.example</title>
<h1>evil.example</h1>
<button onclick="probe('admin')">Probe as admin</button>
<button onclick="probe('user')">Probe as user</button>
<pre id="o"></pre>
<script>
  function probe(role) {
    // open the cross-origin target as a popup we keep a handle to
    const w = window.open("http://localhost:5000/target.html?role=" + role, "t", "width=300,height=200");
    setTimeout(() => {
      try {
        o.textContent = "frames.length = " + w.frames.length + "  → guess: " +
          (w.frames.length >= 2 ? "ADMIN" : "user");
      } catch (e) { o.textContent = "blocked: " + e.message; }
      w.close();
    }, 800);
  }
</script>
EOF
# serve attacker on a DIFFERENT port = different origin
python3 -m http.server 5050 >/dev/null 2>&1 &
echo "attacker on http://localhost:5050/attacker.html"

Mở http://localhost:5050/attacker.html, bấm hai nút: dù không đọc được DOM của target, frames.length vẫn lộ vai trò. Giờ thêm Cross-Origin-Opener-Policy: same-origin cho target (qua server Node như Phần 20) — handle popup mất, w.frames ném lỗi, oracle tắt.

Đã chứng minh: thuộc tính quan sát được rò qua biên SOP; COOP cắt handle.

Dọn dẹp

kill %1 %2 2>/dev/null; rm -rf /tmp/xsl

Phòng thủ — đúng khoá cho đúng cửa

Không có một header thần kỳ. XS-Leaks chống bằng nhiều lớp, mỗi lớp bịt một loại oracle.

1. SameSite cookies — lớp mạnh nhất, rẻ nhất

SameSite=Lax (mặc định Chrome) hoặc Strict khiến request cross-site đi ở trạng thái đăng xuất. Không có phiên → cả hai trạng thái render giống hệt → gần như mọi oracle dựa-trên-trạng-thái tắt cùng lúc. Đây là phòng thủ nền tảng, xem Phần 4.

2. Fetch Metadata — từ chối request cross-site ở server

Trình duyệt gắn kèm Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest. Server từ chối các request cross-site không hợp lệ:

// Resource Isolation Policy (express-style)
app.use((req, res, next) => {
  const site = req.get("Sec-Fetch-Site");
  if (!site || site === "same-origin" || site === "same-site") return next();
  // cross-site: chỉ cho navigation top-level GET, chặn phần còn lại
  const mode = req.get("Sec-Fetch-Mode");
  if (mode === "navigate" && req.method === "GET" && req.get("Sec-Fetch-Dest") !== "object") return next();
  return res.status(403).end(); // chặn nhúng/no-cors cross-site
});

Request bị chặn sớm trả về giống nhau bất kể vai trò → triệt nhiều oracle gốc.

3. COOP — cắt oracle dựa trên cửa sổ

Cross-Origin-Opener-Policy: same-origin (xem Phần 20) khiến popup cross-origin mất handle, nên frames.length/window.length không đọc được. Ghép với framing protection để bịt cả đường iframe.

4. Framing protection — bịt oracle dựa trên nhúng iframe

Content-Security-Policy: frame-ancestors 'none' (hoặc X-Frame-Options: DENY) chặn nhúng — kẻ tấn công mất một cách lấy handle và một bề mặt đo. Đây cũng là phòng thủ clickjacking (Phần 7).

5. CORP — bịt oracle dựa trên subresource

Cross-Origin-Resource-Policy: same-origin khiến phản hồi không nhúng được cross-origin, nên <img>/<script> trỏ vào nó luôn onerrorbất kể vai trò, nên sự kiện hết là tín hiệu.

Ghi nhớ ánh xạ: cửa sổ → COOP, iframe → frame-ancestors, subresource → CORP, mọi thứ → SameSite + Fetch Metadata.


Checklist phòng tránh

  1. Đặt SameSite=Lax/Strict cho cookie phiên — lớp nền.
  2. Bật Fetch Metadata Resource Isolation Policy ở server.
  3. Thêm COOP: same-origin để bịt oracle cửa sổ.
  4. Thêm frame-ancestors 'none' cho trang không cần bị nhúng.
  5. Đặt CORP: same-origin cho endpoint nhạy cảm.
  6. Cho endpoint nhạy cảm trả về kích thước/thời gian/trạng thái ổn định giữa các vai trò.
  7. Đừng tắt cache partitioning; tránh để bí mật trong tài nguyên cache-được.

Bài tập / Exercises

1. Đồng đội nói: “SOP đã chặn đọc cross-origin rồi, XS-Leaks không thể có.” Sai ở đâu?

Lời giải

SOP chặn đọc thân phản hồi, không che thuộc tính quan sát được (số frame, sự kiện load/error, thời gian, trạng thái cache). XS-Leaks suy luận bí mật từ các thuộc tính đó mà không cần đọc body.

2. Trong simulator, chọn oracle “Error/load events” và bật chỉ Framing protection. Vì sao vẫn rò, và phòng thủ nào mới đúng cửa?

Lời giải

Oracle error/load dùng <img>/<script> (subresource), không dùng iframe — nên frame-ancestors không liên quan. Đúng khoá: SameSite (đăng xuất → cùng status), CORP: same-origin (subresource bị chặn → luôn onerror), hoặc Fetch Metadata (server 403 cross-site).

3. Vì sao SameSite đóng được nhiều loại oracle cùng lúc trong khi COOP chỉ đóng một loại?

Lời giải

SameSite tác động ở gốc: request cross-site mất cookie → phản hồi không còn phụ thuộc trạng thái đăng nhập, nên mọi kênh đo (frame, sự kiện, thời gian) đều cho cùng một kết quả. COOP chỉ cắt handle cửa sổ, nên chỉ bịt oracle dựa trên cửa sổ.

Nâng cao:Trong simulator, với oracle “Frame counting”, tìm tập phòng thủ nhỏ nhất đóng được kênh, rồi giải thích vì sao một mình Framing protection (hoặc một mình COOP) là chưa đủ.


Điểm chính

  • XS-Leak biến trình duyệt nạn nhân thành oracle có/không về một site khác, qua kênh phụ ngoài tầm SOP.
  • Oracle hay gặp: đếm frame, sự kiện onload/onerror, thời gian phản hồi, cache probing.
  • Phòng thủ là nhiều lớp, mỗi khoá cho một cửa: COOP (cửa sổ), frame-ancestors (iframe), CORP (subresource).
  • SameSiteFetch Metadata là lớp nền — chúng khiến request cross-site đăng xuất / bị từ chối, triệt nhiều oracle cùng lúc.

Nguồn


Series

Các phần bonus xếp trên mười phần lõi và nhánh nâng cao. Sợi chỉ nối thẳng tới Phần 20: cùng bộ header cô lập trang để chống Spectre cũng dập tắt phần lớn XS-Leaks — cô lập là một chiến lược, không phải một mẹo.