Home / WebSecurity / Clickjacking và postMessage: Chặn Tấn Công Qua iframe
WebSecurity

Clickjacking và postMessage: Chặn Tấn Công Qua iframe

Chặn Clickjacking và postMessage bằng X-Frame-Options, CSP frame-ancestors và phép kiểm tra event.origin ở bên nhận.

Hai lỗ hổng dưới đây có chung một gốc: trình duyệt cho phép một trang tương tác với trang khác qua iframe, và nếu bạn không khai báo giới hạn, kẻ tấn công lợi dụng đúng cơ chế đó. Clickjacking nhúng trang thật của bạn vào iframe trong suốt để lừa click; postMessage gửi dữ liệu độc vào trang không kiểm tra nguồn gốc.

Điểm chung nữa: cả hai đều chặn được bằng vài dòng header hoặc một phép kiểm tra origin - miễn là bạn biết cần khai báo gì. Bài này đi qua cơ chế từng loại rồi đến biện pháp chặn cụ thể, kèm ví dụ server bạn tự chạy được để thấy header thật đi kèm mỗi response.

1. Clickjacking: người dùng click nhầm vào iframe ẩn

Người dùng nghĩ họ đang bấm nút “Xem video miễn phí”, nhưng thực tế con trỏ rơi vào một iframe trong suốt che phía trên - và cú click đó xác nhận chuyển tiền hoặc đổi mật khẩu trên trang thật mà họ đang đăng nhập. Đây là Clickjacking (còn gọi là UI Redress Attack).

1.1. Cơ chế

Kẻ tấn công dựng một trang mồi, nhúng trang thật của bạn vào bằng <iframe>, đặt opacity gần bằng 0 rồi căn vị trí sao cho nút nguy hiểm trên trang thật nằm đúng dưới nút mồi. Người dùng thấy nút mồi, bấm vào, nhưng cú click xuyên qua iframe trong suốt và kích hoạt hành động trên trang thật.

1
2
3
4
5
6
7
8
<!-- Trang mồi của kẻ tấn công -->
<h1>Nhận quà miễn phí — bấm vào đây</h1>
<iframe
  src="https://your-bank.com/confirm-transfer"
  style="opacity: 0.01; position: absolute; top: 0; left: 0"
  width="400"
  height="200"
></iframe>

Điều kiện để đòn này thành công: trang thật cho phép mình bị nhúng vào iframe, và người dùng đang có phiên đăng nhập. Muốn chặn, phải làm cho trình duyệt từ chối hiển thị trang của bạn bên trong iframe của bất kỳ trang lạ nào.

View Mermaid diagram code
flowchart TD
    Start([Kẻ tấn công dựng trang mồi]) --> Embed[Nhúng trang thật vào iframe]
    Embed --> Hide["Đặt opacity: 0.01, căn nút trùng vị trí"]
    Hide --> Click[Người dùng bấm vào nút mồi]
    Click --> Through[Click xuyên qua iframe vào trang thật]
    Through --> Action([Kích hoạt đổi mật khẩu, chuyển tiền])

    style Action fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3

1.2. Chặn bằng X-Frame-Options và CSP frame-ancestors

Có hai header ra lệnh cho trình duyệt về việc trang của bạn được nhúng vào iframe hay không:

  • X-Frame-Options - header cũ, hai giá trị còn dùng được: DENY (không trang nào nhúng được) và SAMEORIGIN (chỉ trang cùng origin). Giá trị ALLOW-FROM đã lỗi thời - Chrome và Safari chưa bao giờ hỗ trợ, Firefox gỡ từ bản 70 (2019); cần cho phép một origin cụ thể thì dùng CSP bên dưới.
  • CSP frame-ancestors - directive hiện đại thay thế X-Frame-Options: frame-ancestors 'none' chặn hoàn toàn, 'self' chỉ cho cùng origin, hoặc liệt kê origin cụ thể như frame-ancestors 'self' https://partner.example.

Ở trình duyệt hỗ trợ CSP, frame-ancestors ghi đè X-Frame-Options. Cách an toàn là gửi cả hai: frame-ancestors phục vụ trình duyệt mới, X-Frame-Options phủ nốt trình duyệt cũ.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Lưu thành `frame-ancestors.js` (`npm install express`)
const express = require("express");

const app = express();

// Cấm mọi trang nhúng trang này vào iframe — chặn Clickjacking từ gốc
app.use((req, res, next) => {
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
  next();
});

app.get("/", (req, res) => {
  res.type("html").send(`<!doctype html>
<h1>Trang thật — form nhạy cảm</h1>
<button>Xác nhận chuyển tiền</button>`);
});

app.listen(4030);

Chạy node frame-ancestors.js, cả hai header đi kèm mọi response - kiểm nhanh bằng curl:

1
curl -is http://localhost:4030/ | grep -iE 'x-frame-options|content-security-policy'
1
2
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

Giờ nếu kẻ tấn công nhúng trang này vào iframe, trình duyệt từ chối render và console của trang mồi báo lỗi kiểu Framing 'http://localhost:4030/' violates the following Content Security Policy directive: "frame-ancestors 'none'". The request has been blocked.. Không có iframe hiển thị thì không có gì để click xuyên qua.

Lưu ý: frame-ancestors phải đi qua HTTP header

Trình duyệt bỏ qua frame-ancestors nếu bạn khai báo CSP bằng thẻ <meta> - directive này chỉ có hiệu lực khi gửi thông qua HTTP header.

2. postMessage: nhận message không kiểm tra origin

window.postMessage là API cho phép hai trang khác origin (một trang và iframe của nó, hoặc một trang và tab nó mở ra) trao đổi dữ liệu. Tiện cho tích hợp cross-domain, nhưng nếu bên nhận không kiểm tra message đến từ đâu, kẻ tấn công gửi được dữ liệu độc vào và biến nó thành XSS hoặc rò rỉ token.

2.1. Cơ chế

Có hai phép kiểm tra origin, ở hai đầu, và cả hai đều bắt buộc:

  • Bên gửi truyền targetOrigin cho postMessage để nói “chỉ giao message này nếu window đích đang ở đúng origin đó”.
  • Bên nhận đọc event.origin trong handler để nói “chỉ xử lý message nếu nó đến từ origin tôi tin”.

Origin là bộ ba scheme + host + port, nên http://127.0.0.1:4031http://localhost:4031hai origin khác nhau dù cùng trỏ về máy này - vừa đủ để dựng một cuộc postMessage cross-origin thật ở local. Một server Express duy nhất trên port 4031 phục vụ cả hai đầu: mở qua 127.0.0.1 là bên gửi, mở qua localhost là bên nhận trong iframe.

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
// Lưu thành `postmessage-origin.js` (`npm install express`)
const express = require("express");

const app = express();

const SENDER_ORIGIN = "http://127.0.0.1:4031"; // bên gửi
const RECEIVER_ORIGIN = "http://localhost:4031"; // bên nhận, nằm trong iframe

// Bên gửi: nhúng receiver (localhost) vào iframe rồi gửi message kèm targetOrigin cụ thể
app.get("/sender", (req, res) => {
  res.type("html").send(`<!doctype html>
<iframe id="receiverFrame" src="${RECEIVER_ORIGIN}/receiver"></iframe>
<script>
  const frame = document.getElementById("receiverFrame");
  frame.addEventListener("load", () => {
    // targetOrigin = origin của BÊN NHẬN, không dùng '*'
    frame.contentWindow.postMessage("Hello from Sender!", "${RECEIVER_ORIGIN}");
  });
</script>`);
});

// Bên nhận: chỉ xử lý message đến từ SENDER_ORIGIN, ghi log khi từ chối
app.get("/receiver", (req, res) => {
  res.type("html").send(`<!doctype html>
<p id="message">Đang chờ message...</p>
<script>
  window.addEventListener("message", (event) => {
    // event.origin = origin của BÊN GỬI — phải khớp CHÍNH XÁC, không dùng includes()
    if (event.origin !== "${SENDER_ORIGIN}") {
      console.warn("REJECTED <- " + event.origin);
      return;
    }
    document.getElementById("message").innerText = "Received: " + event.data;
  });
</script>`);
});

app.listen(4031);

// Trang độc, chạy trên server riêng ở một origin khác hẳn: localhost:4032.
// Nó cũng nhúng đúng bên nhận rồi gửi message — không cần ai "cho phép" cả.
const attacker = express();

attacker.get("/attacker", (req, res) => {
  res.type("html").send(`<!doctype html>
<iframe id="receiverFrame" src="${RECEIVER_ORIGIN}/receiver"></iframe>
<script>
  const frame = document.getElementById("receiverFrame");
  frame.addEventListener("load", () => {
    frame.contentWindow.postMessage("payload doc hai", "${RECEIVER_ORIGIN}");
  });
</script>`);
});

attacker.listen(4032);

Lưu block trên thành postmessage-origin.js, chạy npm install express rồi node postmessage-origin.js, và mở lần lượt hai URL:

  • Nhánh hợp lệhttp://127.0.0.1:4031/sender: event.originhttp://127.0.0.1:4031, khớp SENDER_ORIGIN, iframe hiện Received: Hello from Sender!.
  • Nhánh bị từ chốihttp://localhost:4032/attacker: trang độc cũng gửi message thật, nhưng event.originhttp://localhost:4032, không khớp, nên bên nhận return trước khi đụng vào DOM — iframe vẫn Đang chờ message... và console in REJECTED <- http://localhost:4032.
View Mermaid diagram code
flowchart TD
    Msg([Message đi qua ranh giới origin]) --> Send{"Bên gửi truyền targetOrigin nào?"}
    Send -->|"'*'"| Leak["Window đích đã bị điều hướng: dữ liệu rơi thẳng vào trang của kẻ tấn công"]
    Send -->|"Origin cụ thể"| Recv{"Bên nhận có đọc event.origin?"}
    Recv -->|"Không"| Xss["Trang bất kỳ gửi được message độc: XSS, rò rỉ token"]
    Recv -->|"Có, khớp danh sách tin cậy"| Safe([Message được xử lý an toàn])

    style Leak fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
    style Xss fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
    style Safe fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50

2.2. Phòng tránh

  • Kiểm tra event.origin ở bên nhận, so khớp chính xác với danh sách origin tin cậy.
  • Chỉ định targetOrigin cụ thể khi gửi, tuyệt đối không '*' với dữ liệu nhạy cảm.
  • Validate event.data như mọi input không tin cậy trước khi dùng.
  • Tránh các sink nguy hiểm: đừng đưa event.data thẳng vào innerHTML, eval() hay location - một lỗ hổng postMessage sẽ lập tức thành XSS hoặc open redirect.
  • Với giao tiếp hai chiều phức tạp, cân nhắc MessageChannel để có kênh point-to-point riêng thay vì broadcast qua window.postMessage.

Hai lỗ hổng, một nguyên tắc: khai báo rõ ranh giới cho trình duyệt. Gửi X-Frame-Options cùng CSP frame-ancestors để không ai nhúng được trang bạn vào iframe; kiểm tra event.origin ở bên nhận và chỉ định targetOrigin cụ thể ở bên gửi cho mọi giao tiếp postMessage. Cả hai đều rẻ, và hai server demo trong bài chạy được ngay bằng node với duy nhất express - dựng lên để tự thấy header thật lẫn message bị từ chối trước khi áp vào dự án. Đây cũng là một mắt xích trong bức tranh lớn hơn - xem frame-ancestors phối hợp với chống inline script ra sao trong bài Ngăn Chặn XSS Với CSP.

← Previous
Quá nhiều Event Listener? Tối ưu với Event Delegation
Table of Contents
  1. 1. Clickjacking: người dùng click nhầm vào iframe ẩn
    1. 1.1. Cơ chế
    2. 1.2. Chặn bằng X-Frame-Options và CSP frame-ancestors
  2. 2. postMessage: nhận message không kiểm tra origin
    1. 2.1. Cơ chế
    2. 2.2. Phòng tránh