Regex tìm thấy IP trong chữ "candidate": bug rò rỉ WebRTC trong gói npm
Ngày 17/8/2026, trình kiểm tra rò rỉ WebRTC mình viết hiện ra kết quả này trong trình duyệt:
["e:2703186080", "e:1510270939"]
Cả hai đều không phải địa chỉ IP. Lúc đó gói đã lên npm hai lần, lần thứ hai còn kèm một test xanh. Còn chữ e ở đầu là chữ cái cuối cùng của từ candidate.
TL;DR: nhánh IPv6 của regex khớp vào foundation của ICE candidate thay vì địa chỉ; bản 1.0.2 đọc đúng trường theo RFC 8839, bản 1.0.4 phân biệt "không kiểm tra được" với "sạch".
Gói npm này làm gì
Gói này là @myipscan/leak-check. Mình viết nó cho MyIPScan, bộ công cụ kiểm tra quyền riêng tư chạy trong trình duyệt mà mình tự làm. Nó hỏi trình duyệt sẽ đưa cho script trên trang những địa chỉ nào. Lưu lượng mạng duy nhất là một request STUN, mặc định tới stun.l.google.com:19302.
Giá trị sai mà trông hợp lý còn tệ hơn exception
Nhìn lướt qua thì kết quả này không có gì bất thường: một ký tự hex, dấu hai chấm, một dãy số. Trông giống phần đuôi của một địa chỉ IPv6.
Nếu detector ném lỗi, người dùng thấy thông báo lỗi và thử lại. Nếu nó tự tin trả về giá trị sai, người chịu thiệt vẫn là người dùng. Họ đem con số đó so với IP lối ra của VPN rồi kết luận hoặc "bị lộ rồi", hoặc "mấy cái test rò rỉ toàn nhiễu". Đằng nào họ cũng hành động dựa trên một kết quả do code của mình bịa ra.
Với công cụ về quyền riêng tư, thứ tự ưu tiên là cố định: câu trả lời đúng, sau đó là một câu "không kiểm tra được" trung thực, mọi thứ khác xếp sau. 1.0.0 và 1.0.1 cho ra loại thứ ba.
Test đã xanh lên bằng cách nào
Test của mình chạy detector trong Node. Ở 1.0.0 nó fail với ReferenceError: window is not defined, vì code đụng vào window bên trong Promise.
Khoảng 91 phút sau, 1.0.1 lên npm với một guard ở đầu Promise:
if (typeof window === "undefined") {
return resolve({ supported: false, ips: [] });
}
Test xanh vì chạy tới dòng này là kết thúc. Mình làm cho test pass bằng cách bắt nó kiểm tra ít đi.
Cũng trong bản đó, mình chuyển regex ra một biến tên IP:
var IP = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9:]{10,})/i;
// ...
var m = IP.exec(e.candidate.candidate);
if (m) ips.add(m[1]);
Nói cách khác, mình sửa đúng dòng chứa bug, đặt cho bug một cái tên đàng hoàng, rồi publish lại.
Chữ "e" trong "candidate"
Một dòng ICE candidate loại server-reflexive (srflx) trông như sau. Địa chỉ ở đây thuộc dải dành cho tài liệu trong RFC 5737, không phải IP thật:
candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx raddr 0.0.0.0 rport 0
Regex trong JavaScript trả về match bắt đầu sớm nhất trong chuỗi. Tại mỗi vị trí, engine thử cả hai nhánh của | rồi mới dịch sang phải một ký tự. Vì vậy nhánh IPv4 chỉ thắng khi phía trước không có gì khớp.
Nhánh thứ hai cần ít nhất 10 ký tự liên tiếp thuộc [a-f0-9:], không phân biệt hoa thường. Đi từ đầu chuỗi, các đoạn hex là ca, d, da, đều quá ngắn. Tới e:1510270939 thì đủ 12 ký tự:
const m = IP.exec("candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx");
m[1]; // "e:1510270939"
m.index; // 8
Match bắt đầu ở vị trí 8, và địa chỉ thật ở trường thứ năm không bao giờ được xét tới.
Gốc của vấn đề: nhánh IPv6 mô tả những ký tự được phép có trong địa chỉ, chứ không mô tả hình dạng của một địa chỉ. Mà một dòng candidate thì đầy những đoạn trông giống hex.
Trên dòng host và srflx, regex cũ rơi vào foundation, hoặc vào priority 10 chữ số nếu foundation ngắn. Foundation thậm chí không phải lúc nào cũng là số: RFC 8839 cho phép chữ cái, chữ số, + và /, nên một regex "đoán theo ký tự" có thể rơi vào bất kỳ trường nào.
Đọc đúng trường mà ngữ pháp chỉ định
Thuộc tính candidate được định nghĩa ở RFC 8839, mục 5.1:
candidate-attribute = "candidate" ":" foundation SP component-id SP
transport SP priority SP connection-address SP
port SP cand-type [SP rel-addr] [SP rel-port]
*(SP cand-extension)
Tách theo dấu cách thì địa chỉ là trường thứ năm. Phần lõi của 1.0.2, rút gọn:
function addressOf(c) {
if (c && c.address) return c.address; // trình duyệt mới đã parse sẵn
var parts = String(c && c.candidate || "").split(" ");
return parts.length > 4 ? parts[4] : ""; // trường thứ năm, theo vị trí
}
pc.onicecandidate = function (e) {
if (!e.candidate) return finish(); // candidate null: thu thập xong
var addr = addressOf(e.candidate);
if (/\.local$/i.test(addr)) return; // tên mDNS: trình duyệt đã giấu IP
if (isIp(addr)) ips.add(addr); // kiểm tra hình dạng rồi mới thêm
};
isIp() chỉ kiểm tra hình dạng: IPv4 là bốn số không quá 255, IPv6 là tối đa 8 nhóm hex dài 1-4 ký tự với nhiều nhất một ::. Hàm này vẫn lỏng. Nó cho qua 1:2:3:4:5:6:7:8: có dấu hai chấm thừa ở cuối và từ chối ::ffff:192.0.2.1 dù đó là địa chỉ hợp lệ. Nếu cần kiểm tra IPv6 chặt trong trình duyệt, new URL("http://[" + s + "]") sẽ ném lỗi với input sai.
RTCIceCandidate.address giúp bỏ bước split khi trình duyệt hỗ trợ, nhưng MDN ghi nó mới là Baseline từ 2024, nên nhánh fallback vẫn ở lại.
Có một việc code mới chắc chắn không làm được nữa: trả về một mảnh của trường khác.
Cái test rò rỉ WebRTC lẽ ra phải có từ đầu
Test này thay RTCPeerConnection bằng một object giả, phát lại hai candidate mà trình duyệt thường tạo ra: một host candidate che bằng mDNS và một srflx.
const test = require("node:test");
const assert = require("node:assert");
const { detectWebRTCLeak } = require("@myipscan/leak-check");
function fakePC(lines) {
return function () {
this.createDataChannel = () => {};
this.close = () => {};
this.createOffer = async () => ({});
this.setLocalDescription = async () => {
setTimeout(() => {
lines.forEach((candidate) => this.onicecandidate({ candidate: { candidate } }));
this.onicecandidate({ candidate: null });
});
};
};
}
test("lấy được địa chỉ từ candidate srflx", async (t) => {
t.after(() => { delete global.window; });
global.window = { RTCPeerConnection: fakePC([
"candidate:2703186080 1 udp 2122260223 9b36a1c4-5e2f-4d1a-8c3b-0f7e6d5c4b3a.local 54321 typ host",
"candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx raddr 0.0.0.0 rport 0",
]) };
const { ips } = await detectWebRTCLeak();
assert.deepStrictEqual(ips, ["203.0.113.7"]);
});
Trên 1.0.1 hàm trả về ["e:2703186080", "e:1510270939"], chính hai giá trị ở đầu bài, và test fail. Trên 1.0.2 nó trả về ["203.0.113.7"] và pass.
Hai bản lỗi giữ tag latest khoảng hai tiếng, từ 17:56 tới 20:04 UTC ngày 17/8. Cả hai vẫn còn trên registry, đã được đánh dấu deprecated với lời nhắn "Use >=1.0.2". Nếu bạn dùng gói này, hãy khai báo ^1.0.4: 1.0.2 sửa parser, còn 1.0.4 sửa cách báo timeout.
"Không kiểm tra được" không phải là "sạch"
Còn một lỗi mình để tồn tại lâu hơn. Ở 1.0.2, STUN timeout hay createOffer thất bại vẫn bị gộp thành supported: true với mảng rỗng, đọc lên y hệt "trình duyệt không lộ gì". Theo README, chuyện này kéo dài đến hết 1.0.3. Bản 1.0.4 (23/10/2026) thêm trường status:
"complete": trình duyệt thu thập xong,ipslà tất cả những gì nó chịu đưa ra."incomplete": hết timeout trước khi thu thập xong, mảng rỗng không chứng minh được gì."error": có WebRTC nhưng tạo offer thất bại (supported: false)."unsupported": không có trình duyệt hoặc không có WebRTC.
Cái bẫy nằm ở phía code gọi hàm:
// sai: biến "không kiểm tra được" thành "sạch"
if (!ips.length) showClean();
// đúng hơn
const { status, ips } = await detectWebRTCLeak();
if (status === "complete" && ips.length === 0) showClean();
else if (status === "incomplete") showRetry();
else if (status !== "complete") showCannotCheck();
Còn khi ips có địa chỉ công khai thì đó là chuyện bình thường. Nó chỉ là rò rỉ khi khác với địa chỉ mà request HTTP của bạn đi ra, ví dụ thấy IP của nhà mạng trong lúc VPN đang bật.
Những quy tắc mình giữ lại
- Test con đường chạm vào trình duyệt. Một
RTCPeerConnectiongiả phát lại candidate đã ghi sẵn chỉ tốn vài dòng, và nó fail trên 1.0.1. - Parse theo ngữ pháp, rồi mới validate. Spec nói trường thứ năm thì đọc trường thứ năm, sau đó kiểm tra hình dạng.
- Đừng để "không kiểm tra được" hiện thành "sạch". Mang trạng thái thứ ba tới tận UI.
- Đọc từng dòng trước khi publish.
index.jscủa 1.0.0 dài 19 dòng, và mình đã publish mà không đọc hết.npm pack --dry-runcho biết file nào sẽ được đóng gói, còn đọc chúng vẫn là việc của mình.
Nếu muốn xem trình duyệt của bạn thực sự đưa ra những địa chỉ nào, công cụ WebRTC leak test của MyIPScan chạy cùng quá trình thu thập ICE ngay trong trang và chia candidate thành công khai, nội bộ và bị che bằng mDNS. (Mình là người làm ra MyIPScan. Gói @myipscan/leak-check dùng giấy phép MIT.)
Bạn đang lấy địa chỉ từ ICE candidate trong dự án của mình bằng cách nào: regex, split, hay candidate.address?
All rights reserved