Skip to main content

Command Palette

Search for a command to run...

"Giải mã" extension viết tin nhắn tàng hình trên Facebook

Updated
10 min read
"Giải mã" extension viết tin nhắn tàng hình trên Facebook

Gần đây khi mình đăng tải extension "Invisible Message for Facebook™" cho phép nhắn tin mã hoá và tàng hình trên Facebook, thì có được khá nhiều bạn quan tâm. Một số bạn thắc mắc tại sao mình lại làm extension viết tin nhắn tàng hình, và tại sao lại là cho Facebook. Vì vậy mình viết bài viết này để giải thích quá trình mình nghiên cứu ra extension này và các kĩ thuật được mình áp dụng để tạo ra nó.

React hooking

Đầu tiên, lý do mà mình nghiên cứu kĩ thuật để tương tác với tin nhắn của Facebook là vì trước đây mình có thấy vài extension sử dụng kĩ thuật hooking vào các module được define bằng AMD (Async Module Definition) của Facebook. Các kiến thức đó được mình nghiên cứu để viết thành bài blog Modding Facebook website [Phần 1] đồng thời áp dụng để chỉnh sửa một số tính năng cho Facebook. Với mình thì đây là một kĩ thuật rất đỉnh vì nó giúp mình có thể viết hooking để modding các tính năng cho Facebook chỉ với 30-50 dòng code mà vẫn có thể đạt được độ ổn định rất cao trong thời gian dài.

Điển hình là nếu xem commit history của userscript [Invisible Message](https://github.com/t-rekttt/invisible_message) chính là phiên bản sơ khai của extension tin nhắn tàng hình, có thể thấy nó đã hoạt động được từ năm 2021 - 2023 mà không cần chỉnh sửa gì, có thể nói là một khoảng thời gian rất dài.

Tuy nhiên kĩ thuật này cũng rất khó vì không phải component nào trong React cũng có thể patch để ghi đè tính năng được, vì vậy có khi Facebook thay đổi gì trong cái component mình đang sử dụng cái là tịt. Ngoài ra mình nghĩ trong tương lai nếu Facebook không thích cách mình đang hooking vào mấy component này thì họ có thể sửa lại mấy component đó cho không patch được nữa sẽ khiến đống code của mình tèo hết luôn, khá là rủi ro. Ví dụ như khi Facebook cập nhật giao diện và loại bỏ component cũ đi khiến cho userscript Invisible Message nói trên chết ngắc, mình đã phải archive nó tới 2026 mới khôi phục lại được tính năng (trước đó mình còn tưởng là hết cứu luôn rồi :( ). Có thể nói kĩ thuật React hookingnày là “high risk, high reward”, sử dụng kĩ thuật này thì những dòng code viết ra luôn rất ngắn gọn mĩ miều, có thể nói là dát vàng con mắt :v.

Để mà nói về độ mĩ miều của nó thì đây là đoạn code mình dùng để chỉnh sửa (mã hoá/giải mã) nội dung tin nhắn đi và đến trong userscript, cả đoạn code chỉ dài 37 dòng:

requireLazy(
    ["MWUnvaultedText", "MAWSecureComposerText", "LexicalText"],
    (MWUnvaultedText, MAWSecureComposerText, LexicalText) => {
      const LexicalTextRootTextContentOrig = LexicalText.$rootTextContent;

      LexicalText.$rootTextContent = function () {
        return patch(LexicalTextRootTextContentOrig.apply(this, arguments));
      }

      const getTextFromEditorStateOrig = MAWSecureComposerText.getTextFromEditorState;

      MAWSecureComposerText.getTextFromEditorState = function () {
        // console.log({ editorState });

        return patch(getTextFromEditorStateOrig.apply(this, arguments));
      }

      //   console.log({ MWUnvaultedText, MAWSecureComposerText });

      const useMWUnvaultedTextOrig = MWUnvaultedText.useMWUnvaultedText;

      MWUnvaultedText.useMWUnvaultedText = function (isSecure, vaultedText) {
        let text = useMWUnvaultedTextOrig(isSecure, vaultedText);

        if (checkEncode(text)) {
          try {
            text = `Encrypted message: ${text.replace(encodedPattern, `>${decode(text)}<`)}`;
          } catch (err) {
            console.log(err);
          }
        }

        // console.log({ text });
        return text;
      };
    }
  );

Kí tự tàng hình

Nhiều năm trở trước, có 1 trang web rút gọn link nổi lên với khả năng tạo ra các đoạn link rút gọn sử dụng các kí tự “tàng hình”. Trang web đó chính là https://zws.im/ - Zero Width Shortener.

Đặc điểm của trang web này đấy là khi bạn rút gọn một đường link bất kì, nó sẽ tạo ra đường dẫn https://zws.im/‌󠁮󠁡󠁳󠁡󠁬󠁳, trông chẳng khác gì đường link dẫn đến trang chủ của ZWS. Tuy nhiên khi bạn click vào đường link này thì nó sẽ dẫn bạn tới đúng trang web mà người tạo link trỏ tới. Lý do là vì nó đã sử dụng một bảng gồm toàn các kí tự “Zero Width Character” - “Kí tự có chiều rộng bằng không”. Những kí tự này không hiển thị lên nên không thể nhìn thấy bằng mắt thường, tuy nhiên vẫn sẽ được máy tính “nhìn thấy” và xử lý. Mình có tìm thấy bài viết Ký tự zero-width - "sát thủ" vô hình nằm giữa đoạn văn bản thuần vô hại giải thích về ZWC rất hay, mọi người có thể đọc để hiểu thêm về kĩ thuật này.

Trong lúc viết bài blog này, mình có ngồi đọc lại xem ngày xưa đã tham khảo của ZWS cái gì, nhưng có vẻ như là mình chỉ tham khảo cái bảng kí tự tàng hình này thôi:

const CHARS = [
    // "\u200d",
    // "\u{e0061}",
    "\u{e0062}",
    "\u{e0063}",
    "\u{e0064}",
    "\u{e0065}",
    "\u{e0066}",
    "\u{e0067}",
    "\u{e0068}",
    "\u{e0069}",
    "\u{e006a}",
    "\u{e006b}",
    "\u{e006c}",
    "\u{e006d}",
    "\u{e006e}",
    "\u{e006f}",
    "\u{e0070}",
    "\u{e0071}",
    "\u{e0072}",
    "\u{e0073}",
    "\u{e0074}",
    "\u{e0075}",
    "\u{e0076}",
    "\u{e0077}",
    "\u{e0078}",
    "\u{e0079}",
    "\u{e007a}",
    "\u{e007f}",
  ];

Chuyển đổi cơ số

“Chuyển đổi cơ số” là một trong những thuật toán cơ bản mình được học khi lập trình. Cơ bản nhất là đổi từ hệ thập phân sang nhị phân như bức ảnh dưới đây (có lẽ đây là hình ảnh đã ám ảnh nhiều bạn học sinh, sinh viên thời còn đi học :D). Bạn có thể đọc về thuật toán này tại [đây](https://codedream.edu.vn/chuyen-doi-he-co-so/).

Lý do mà mình cần dùng tới thuật toán này đó là vì số lượng kí tự tàng hình có hạn, vì vậy cần phải đổi ra hệ cơ số tương ứng với số lượng kí tự tàng hình để có thể biểu diễn hết các kí tự trong bảng Unicode. Thực tế chỉ cần sử dụng 2 kí tự tàng hình là đủ, tuy nhiên càng sử dụng ít kí tự thì đoạn văn bản sau khi mã hoá sẽ càng dài. Vì vậy mình sử dụng bảng chữ cái ZWC với 26 kí tự để tối ưu độ dài văn bản khi mã hoá. Về lý thuyết thì hệ Unicode có 1.114.112 khả năng cho mỗi code point (một kí tự hiển thị có thể có nhiều code point, ví dụ như các emoji), do vậy khi đổi mỗi code point ra bảng ZWC thì tương đương với đổi từ hệ cơ số 1.114.112 về hệ cơ số 26.

Dưới đây là bảng quy đổi độ dài (tăng lên bao nhiêu lần) khi đổi từ Unicode sang bảng ZWC có b kí tự từ 2 - 26:

Minh hoạ độ dài khi đổi biểu diễn từ hệ 1114112 sang hệ b

Như vậy với 26 kí tự thì xâu sau khi mã hoá của mình sẽ dài gấp khoảng 4 lần xâu ban đầu.

Code của thuật toán đổi cơ số này cũng giống chính là dựa trên việc chia lấy phần nguyên và phần dư, hệt như việc đổi số thập phân ra một hệ cơ số bất kì (do trên lý thuyết thì là hệ cơ số 1.114.112 nhưng Unicode code point thì vốn đã được biểu diễn ở dạng thập phân).

Dưới đây là đoạn code mình viết để đổi 1 code point ra biểu diễn cơ số BASE với độ dài cố định là LEN

const UNICODE_CHARS = 1114112;
const BASE = CHARS.length;
const LEN = lenCalc(BASE, UNICODE_CHARS);

const charConvert = (char) => {
  let charCode = char.codePointAt(0);
  let arr = [];

  while (charCode > 0) {
    arr.push(charCode % BASE);
    charCode = ~~(charCode / BASE);
  }

  while (arr.length < LEN) {
    arr.push(0);
  }

  return arr.reverse();
};

Đoạn code này dùng để chuyển đổi biểu diễn cơ số đã quy đổi thành các kí tự tàng hình, nếu mà đổi với bảng Alphabet thì nó tương tự như đổi số (1, 2, 3,…) thành (a, b, c,…) vậy:

const charEncode = (convertedChar) => {
  return convertedChar.reduce((curr, digit) => curr + CHARS[digit], "");
};

Sau khi đã có các hàm để đổi code point ra kí tự tàng hình rồi thì mình tiếp tục viết hàm để chuyển đổi cả xâu kí tự thành tàng hình:

const encode = (s) => {
  let converted = [];

  for (let c of s) {
    converted.push(charConvert(c));
  }

  let res = converted.map(charEncode);

  // Dùng 2 kí tự PADDING_START và PADDING_END để đánh dấu chỗ mình mã hoá
  return PADDING_START + res.join("") + PADDING_END;
};

Tương tự, ta có hàm để giải mã từng kí tự tàng hình thành kí tự ban đầu:

// Mapping ngược bảng chữ cái
const CHARS_MAP = CHARS.reduce((curr, val, i) => {
  curr[val] = i;

  return curr;
}, {});

// Giải mã kí tự
const decodeChar = (encodedChar) => {
  encodedChar = encodedChar.reverse();

  let curr = 1;
  let charCode = 0;

  for (let digit of encodedChar) {
    charCode += digit * curr;
    curr *= BASE;
  }

  return String.fromCodePoint(charCode);
};

Và hàm giải mã xâu đã mã hoá thành xâu ban đầu:

const decode = (s) => {
  s = encodedPattern.exec(s)[1];

  let curr = [];
  let res = "";

  for (let c of s) {
    curr.push(CHARS_MAP[c]);

    if (curr.length >= LEN) {
      res += decodeChar(curr);
      curr = [];
    }
  }

  return res;
};

Kết hợp lại

Từ những kĩ thuật ở trên, mình đã có đủ 3 mảnh ghép để tạo nên một tính năng/userscript/extension “độc lạ”, một kĩ thuật lấy nhiều kĩ thuật khác làm nền tảng. Đó là lý do tại sao ban đầu mình dừng lại ở việc chia sẻ 1 userscript mã nguồn mở trên Github. Nó không dễ để sử dụng, nhưng những người sử dụng được thì khả năng họ có thể ngồi đọc, hiểu và “chiêm ngưỡng” nó cũng cao hơn. Một bức tranh trừu tượng với người này là kiệt tác, với người khác có khi chỉ là tấm giấy vẽ nguệch ngoạc :v.

“Mở rộng ra”

Làm userscript là một chuyện, viết “tiện ích mở rộng” (extension) lại là chuyện khác. Nếu bạn từng làm extension đăng tải lên Chrome store thì có lẽ sẽ hiểu từ việc khai báo permissions để inject các đoạn code tới việc chuẩn bị các tài nguyên (mô tả, logo) nó khá là mệt mỏi. Do niềm vui từ việc hồi sinh được 1 kiệt tác từ cõi chết cộng với việc có Claude Code hỗ trợ nên mình mới có thể dành thời gian để biến nó thành một extension thực sự và đẩy lên Chrome store:

Và cũng vì vậy đùng một cái khi mình đăng bài lên J2TEAM Community để quảng bá cho extension, nhiều người tuy thấy hay nhưng không hiểu mục đích của cái extension này là gì :v. Mong rằng sau khi mọi người đọc bài blog này thì sẽ hiểu rằng nó là công cụ mài đao giũa kiếm của mình (chứ không phải trốn vợ đi uống bia như ông anh J nào đó quảng bá nhé :) ), thời gian tới nếu mọi người thấy mình ra một lô extension khác thì khả năng cao nó đều bắt nguồn từ một kĩ thuật này của mình thôi, cái gì ngon thì phải vắt nó cực khô chứ :3.

Sau khi đăng tải version đầu tiên thì cũng có ý kiến góp ý là nếu như chỉ là encoding/decoding kiểu steganography thì bất cứ ai cài extension vào cũng có thể xem được:

Mình nghĩ lại thấy cũng đúng, vì nếu làm 1 extension mã hoá kiểu giả cầy lừa trẻ con thôi thì nó cũng không nói lên được mục đích gì. Vì vậy mình đã cập nhật thêm cho nó tính năng ẩn tin nhắn sử dụng mã hoá thật và chỉ hiện ra khi nhập đúng mã pin:

May be a graphic of phone and text that says 'Invisible Message for Facebook TM Version 1.1.0 8 LOCKED 1234 SET 8 LOCKED 5678 f Việt Thảo 8 LOCKED SET ENTER PIN (4+ Việt ViệtThảo SET 12-36 Việt Thào Learn A 12-36 Encrypted message: Đoan bình thường Đoạn annay nhập 1234c Đoạn ็ bình thuởng hiên thị Đoạnt nhân nihân bình thưởng hie thi Encrypted mossage: ีดอูก tinnhânnà ayhienthi binh >Nhập ass 5678 thây Đoan tin nhắn này hiển binh thưởng Đoan này hiển 2 Mã pin 1234 Mã pin 5678 Khi Khitắt tắt mã pin'

Mặc dù với mã pin 4 số thì vẫn khá yếu, kể cả mã hoá thì cũng bruteforce được, tuy nhiên với những mã pin dài khoảng 11-12 kí tự thì có lẽ là có thể yên tâm.

Viết tới đây thì cũng gọi là hòm hòm rồi, mong mọi người đọc xong sẽ hiểu quá trình mình nghiên cứu và kết hợp các ý tưởng ngẫu nhiên tưởng chừng không liên quan tới nhau như thế nào. Nếu quan tâm tới các hoạt động của mình thì cho mình xin 1 follow Github nhé hẹ hẹ :)).