Ngày xưa, mình hay xài mấy cái extension Unseen Facebook để đọc tin nhắn mà không bị hiện cái “Đã xem”. Kiểu mình muốn trả lời người ta sau chẳng hạn, seen rồi để đó không rep thì hơi kì =))
Nhưng giờ Facebook liên tục update cấu trúc web và bắt buộc mã hoá đầu cuối (E2EE) cho các đoạn chat, mấy extension kiểu này còn sống thì đếm trên đầu ngón tay. Thêm vào đó, ngoài mấy tính năng cơ bản như chặn seen, chặn typing hay ẩn xem story, mình còn hay dùng thêm cái custom story reaction và hay tải mấy cái video xàm xàm nữa nên phải dùng kẹp thêm vài extension khác. Đặc biệt là mình dùng Instagram khá nhiều, nhưng tìm mỏi mắt trên Store lại chẳng thấy extension nào hỗ trợ mấy trò này cho Instagram cả.
Thế là nhân dịp nghỉ lễ rảnh rỗi, mình quyết định vibe code một chiếc extension để tự đáp ứng nhu cầu của mình. Mình gọi nó là Kiri (Sương mù) - một tool tối giản giúp bảo vệ quyền riêng tư và hỗ trợ custom react/tải video trên cả Facebook lẫn Instagram.
Inspiration
Ban đầu khi bắt tay vào ngâm cứu, mình có tìm hiểu và đọc được một vài bài blog rất hay của anh Việt Thảo (T-Rekt) về việc làm extension viết tin nhắn tàng hình và Modding Facebook nói về việc sử dụng kỹ thuật hook vào các module nội bộ của Facebook.
Như chết đuối vớ được cọc, mình bắt đầu mò mẫm từ hai bài blog cực kỳ giá trị của anh Thảo.
Kiến trúc module của Facebook
Mở DevTools lên, vào tab Sources rồi search __d( là thấy ngay hàng nghìn dòng khai báo module. Facebook đóng gói mọi thứ thành module kiểu AMD (Asynchronous Module Definition):
__d("ModuleName", ["Dependency1", "Dependency2"], function (a, b, c, d, e, f) {
// logic module ở đây
});
Hàm __d đăng ký module, require / requireLazy để gọi qua lại giữa các module. Component React, logic chat, story, video… đều nằm trong đây hết.
Mình nghĩ: nếu chen được vào hàm __d thì mình sẽ kiểm soát mọi module từ lúc chúng vừa được khai báo. Ý tưởng này anh Thảo đã đề cập, nhưng Facebook đổi khá nhiều từ lúc đó tới giờ.
Hijack window.__d
Gán thẳng window.__d = ... thì có vẻ không ổn vì Facebook có thể ghi đè lại bất cứ lúc nào. Dùng Object.defineProperty để kiểm soát cả getter lẫn setter thì dù Facebook gán lại kiểu gì mình vẫn chặn được.
src/app/content/hijack-core.ts:
export const installDefineHijack = (callbacks) => {
const existingDefine = window.__d;
let originalDefine = typeof existingDefine === "function" ? existingDefine : undefined;
const hijackedDefine = function kiriDefineModule(...args) {
if (!originalDefine) return;
try {
callbacks.applyPrivacyHooks(args); // patch các module seen, typing, ...
callbacks.injectUiFeatures(args); // thêm nút download, story reaction
} catch {}
return originalDefine.apply(this, args);
};
Object.defineProperty(window, "__d", {
get() { return originalDefine ? hijackedDefine : undefined; },
set(value) { originalDefine = typeof value === "function" ? value : undefined; },
configurable: true,
});
};
Vậy là mỗi khi Facebook khai báo module qua __d(...), code mình chạy trước, check tên module rồi quyết định có patch hay không.
Facebook factory function
Trước khi patch thì cần hiểu factory function hoạt động thế nào đã.
Khi Facebook gọi __d("ModuleName", ["deps"], factory), cái tham số thứ ba (factory) là một hàm nhận vào nhiều argument nội bộ.
__d("MAWJobDefinitions", ["MAWExternalId"], (function(t, n, r, o, a, i, l) {
"use strict";
var d = {
markThreadAsRead: function(t, n, r, o, a, i) { ... },
sendMediaMsg: function(t, n, r, ...) { ... },
forwardMsg: function(n, r, a, ...) { ... },
// ... hàng chục job serializer khác
};
// argument cuối cùng (l) chính là object exports
l.jobSerializers = d;
l.createStartJobInfo = m;
}), 98);
Mấy argument t, n, r, o là các hàm require nội bộ (kiểu o("MAWExternalId") = require("MAWExternalId")).
Cái quan trọng nhất là argument cuối cùng l, đây là object exports. Gán gì vào l thì cái đó thành API public của module.
Nói ngắn gọn: __d là phiên bản Facebook của import/export, chỉ là dùng callback thay vì import statement.
Và trong trường hợp này:
defineArgs[2]là factory functionfactoryArgs[factoryArgs.length - 1]là object exports
Patch seen module
OK vậy đã hijack được __d rồi, giờ phải tìm xem Facebook dùng module nào để gửi “đã xem” lên server.
Thay vì ngồi mò trong hàng nghìn module, mình đi hướng ngược lại: xem Facebook gửi cái gì lên server trước. Mở Burp Suite, bật intercept, rồi mở một tin nhắn chưa đọc trên Messenger. Ngay lập tức xuất hiện một frame chứa payload thế này:
{
"tasks": [{
"label": "21",
"payload": "{\"thread_id\":6817327698348467,\"last_read_watermark_ts\":1777966558572,\"sync_group\":95}",
"queue_name": "6817327698348467",
"task_id": 160
}]
}
Từ khoá last_read_watermark_ts chính là timestamp mà Facebook dùng để đánh dấu “đọc đến đâu” trong một thread. Search ngược từ khoá này trong Sources, mình tìm thấy thủ phạm đầu tiên.
LSOptimisticMarkThreadReadV2
// __d("LSOptimisticMarkThreadReadV2", ["LSIssueNewTask", "LSMarkThreadRead"], ...)
function e() {
var e = arguments, t = e[e.length - 1];
return t.sequence([
function(o) {
return t.sequence([
// 1. Update local DB: đánh dấu thread đã đọc
function(r) {
return t.storedProcedure(n("LSMarkThreadRead"), e[1], e[0])
},
// 2. Update lastReadWatermarkTimestampMs trong bảng threads
function(n) {
return t.db.table(9).fetch([[[e[0]]]]).next().then(...)
},
// 3. Issue task 21 lên server với last_read_watermark_ts
function(o) {
r[2] = new t.Map;
r[2].set("thread_id", e[0]);
r[2].set("last_read_watermark_ts", e[1]);
r[2].set("sync_group", r[0]);
return t.storedProcedure(n("LSIssueNewTask"), ...);
}
])
}
])
}
Module này chạy 3 bước: update local DB -> cập nhật watermark -> bắn task 21 lên server. Patch cũng đơn giản, wrap hàm export để return sớm khi blockSeen bật là xong, cả pipeline bị cắt từ bước đầu.
Mình thử patch thì giao diện web không hiện “Đã xem” nữa thật. Nhưng check trên điện thoại thì app mobile vẫn hiện bình thường.
Điều này có nghĩa Facebook không chỉ dùng một path duy nhất. Phải có một đường khác vẫn đang lẳng lặng gửi read receipt lên server. Mình quay lại Sources, lần này thử search các từ khoá liên quan đến việc đánh dấu đã đọc: markThreadRead và markThreadAsRead, và tìm thêm được hai module nữa.
MAWJobDefinitions
Đây là module chứa toàn bộ các hàm khởi tạo công việc (job) cho Messenger, từ gửi ảnh, rời nhóm, cho đến… đánh dấu đã đọc:
// __d("MAWJobDefinitions", ...)
markThreadAsRead: function(t, n, r, o, a, i) {
var e = _(i);
return {
args: {
chatJid: t,
isBlocked: r,
isReadReceiptsDisabled: a,
isRestricted: o,
relationship: n
},
scheduleConfig: e != null ? e : void 0,
type: "markThreadAsRead"
}
},
Nhiệm vụ của nó chỉ đơn giản là đóng gói thông tin thread vào một object job, rồi để hệ thống WAJobOrchestrator gửi lên server. Vậy nếu ngăn không cho job này được tạo ra, seen sẽ không bao giờ bay đi.
Nhờ đã hijack __d, mình có thể can thiệp ngay khi module này được định nghĩa:
if (moduleName === "MAWJobDefinitions") {
defineArgs[2] = new Proxy(factory, {
apply(target, thisArg, factoryArgs) {
const result = Reflect.apply(target, thisArg, factoryArgs);
const exports = factoryArgs[factoryArgs.length - 1];
if (exports?.jobSerializers?.markThreadAsRead) {
const original = exports.jobSerializers.markThreadAsRead;
exports.jobSerializers.markThreadAsRead = function(...args) {
if (getConfig().blockSeen) return undefined;
return original.apply(this, args);
};
}
return result;
},
});
}
Dùng Proxy thay vì ghi đè factory thẳng vì ghi đè sẽ mất metadata mà Facebook gắn kèm. Proxy cho phép mình intercept sau khi factory chạy xong, lúc đó object exports đã có markThreadAsRead rồi, chỉ cần swap nó bằng phiên bản check blockSeen. Bật thì return undefined, tắt thì gọi hàm gốc.
useMAWMarkThreadAsRead
Nhưng không phải lúc nào cũng đi qua job. Mình tìm thêm được hook React useMAWMarkThreadAsRead, cái này chịu trách nhiệm kích hoạt “đánh dấu đã đọc”. Bên trong nó check flag gkx rồi chọn 1 trong 2 đường:
- Gọi thẳng
bridge.fireAndForget(...)-> bỏ qua job. - Gọi qua
TimedUIJobStarters.fireAndForget(...)-> dùng job.
// __d("useMAWMarkThreadAsRead", ...)
return t.folderName === r("MessagingFolderTag").SPAM || t.folderName === r("MessagingFolderTag").PENDING
? (e || (e = n("Promise"))).resolve()
: r("gkx")("8717")
? (o("MAWBridge").getBridge().fireAndForget("backend", "markThreadAsRead", {
chatJid: i,
isReadReceiptsDisabled: m,
relationship: _ && o("MAWContactRelationshipType").getContactRelationshipType(_)
}),
(e || (e = n("Promise"))).resolve())
: o("MAWTimedJob").TimedUIJobStarters.fireAndForget(
o("MAWJobDefinitions").jobSerializers.markThreadAsRead(i, ...))
Còn Instagram?
Instagram cũng dùng __d giống Facebook (cùng codebase Meta) nên kỹ thuật hijack y hệt. Với seen thì Instagram có 2 đường: LSOptimisticMarkThreadReadV2 giống Facebook, và useIGDMarkThreadAsRead kích hoạt khi user click ra newsfeed và popup chat hiện lên. Ngoài ra còn IGDMAPISendTypingIndicator cho typing và usePolarisStoriesV3SeenMutation cho story seen, mỗi cái chỉ cần wrap một module là xong, không phức tạp như Facebook.
P/s: Logic download video và custom story reaction trên Facebook mình tham khảo từ anh @monokaijs. Facebook cho phép lấy link video qua GraphQL nên khá tiện.
Với Instagram thì không tìm được endpoint GraphQL tương tự, nên mình đi hướng khác: hook thẳng vào component PolarisVideo.react, lấy hdSrc / sdSrc từ props rồi gắn nút download lên.
Tổng kết
Từ hijack __d, đến Burp Suite bắt WebSocket, rồi lần ra LSOptimisticMarkThreadReadV2, thấy chưa đủ lại đào thêm MAWJobDefinitions với useMAWMarkThreadAsRead. Cuối cùng cũng chặn được seen.
Cái hay là Facebook không dùng một đường duy nhất để gửi read receipt, mà tận ba đường song song qua ba hệ thống khác nhau. Patch một chỗ thì tưởng xong nhưng vẫn lộ qua đường khác, phải chặn hết mới thật sự im.
Source code Kiri trên GitHub: github.com/rumi-chan/kiri, ai muốn mổ xẻ thêm hay góp tính năng thì cứ thoải mái.