Làm một công cụ giám sát máy Mac khó bị tắt lén

Phân biệt khởi động lại với kill -9, bot Telegram tự xoá được cảnh báo, hai tiến trình canh nhau, log chỉ-ghi-thêm và công tắc người chết.

· Đăng ngày · 7 phút đọc · Phiên bản: macOS 26 · Về dự án →

Một công cụ giám sát bảo mật bị tắt lén được còn tệ hơn không có, vì nó vẫn tạo cảm giác yên tâm. Bài này kể cách MacSentry, công cụ giám sát máy Mac mã nguồn mở của mình, cố làm cho việc tắt nó trở nên ồn ào: phân biệt khởi động lại với kill -9, hai tiến trình canh chừng lẫn nhau, file log chỉ được ghi thêm, công tắc người chết (dead man's switch), và một chi tiết của Telegram cho phép mã độc xoá cảnh báo của bạn nếu không cẩn thận.

Mục tiêu không phải là "không thể bị dừng". Kẻ có quyền root, cho đủ thời gian, dừng được mọi công cụ giám sát chạy trên máy. Mục tiêu là chính hành động tắt đó được báo ra ngoài máy trước khi nó thành công.

Đưa bằng chứng ra khỏi máy trước

Thứ gì chỉ nằm trên máy Mac thì kẻ xâm nhập dọn được. Nên mỗi phát hiện được gửi về Telegram ngay khi xảy ra, còn log trên máy chỉ là bản sao thứ hai, không phải bản chính.

Việc gửi phải sống sót trong đúng những lúc cảnh báo quan trọng nhất:

  • Chỉ thử lại lỗi tạm thời. Mất kết nối, HTTP 429 và 5xx được thử lại mỗi 10 giây, tối đa khoảng năm phút, vì ngay sau khi bật máy hay thức dậy, mạng thường chưa lên. Lỗi khác (token bị thu hồi, sai chat ID) chỉ ghi log một lần thay vì thử mãi.
  • Tôn trọng giới hạn độ dài. Bot API nhận 1–4096 ký tự mỗi tin. Cảnh báo dài, như diff của một file bị sửa, được cắt ở độ dài an toàn kèm ghi chú trỏ tới bản đầy đủ trong log trên máy.
  • Khi tắt máy, gửi đồng bộ. Lúc launchd gửi SIGTERM, daemon thử gửi một lần và chờ tối đa năm giây rồi mới thoát, để tin "máy đang tắt" thật sự rời khỏi máy.

Bot Telegram xoá được cảnh báo của chính nó trong 48 giờ

Đây là chi tiết mình bỏ sót lúc đầu. deleteMessage của Bot API cho phép bot xoá tin nó đã gửi trong chat riêng và nhóm, miễn là tin được gửi chưa tới 48 giờ. Chỉ cần token là gọi được.

MacSentry có một app thanh menu chạy bằng user, và app đó cần token để tự gửi cảnh báo. Vì vậy token của nó nằm trong thư mục home của user, thứ gì chạy bằng user đó cũng đọc được. Nếu daemon root dùng chung bot, mã độc chạy bằng user có thể đọc token rồi xoá luôn cảnh báo của daemon, kể cả cảnh báo về chính nó.

Cách sửa là dùng bot thứ hai. Trình cài đặt hỏi một token riêng cho daemon và lưu vào /etc/macsentry.conf, thuộc root, quyền 0600. Daemon cũng kiểm tra xem token của nó có trùng với của app không, và nếu trùng thì nhắc mỗi tuần một lần. Kẻ tấn công ở mức user vẫn xoá được tin của app, nhưng không xoá được tin của daemon, mà daemon mới là bên báo cài cắm và can thiệp.

Phân biệt khởi động lại với kill -9

Tiến trình không bắt được SIGKILL, nên không tự báo được cái chết bị ép của mình. Nhưng ở lần khởi động sau, nó nhận ra được rằng lần chạy trước đã không kết thúc bình thường. Daemon ghép hai dữ kiện (rút gọn từ daemon/Sources/main.swift):

let boot = bootTime()   // sysctl KERN_BOOTTIME
if readText(sp("lastboot")) != String(boot) {
    alert("🟢 Máy vừa khởi động")
} else if exists(sp("clean_stop")) {
    info("▶️ macsentryd chạy lại")
} else {
    alert("⚠️ macsentryd vừa bị dừng ĐỘT NGỘT (kill -9 hoặc lỗi) và đã được launchd chạy lại")
}

Hàm xử lý SIGTERM ghi file đánh dấu clean_stop trước khi thoát. Nếu thời điểm khởi động máy không đổi mà file đánh dấu không có, lần chạy trước đã chết mà không kịp chạy hàm xử lý, tức là SIGKILL hoặc crash. Vì job có KeepAlive, launchd chạy lại nó trong vài giây và cảnh báo được gửi đi.

Cây quyết định khi daemon MacSentry khởi động: nếu thời điểm khởi động máy đã đổi thì báo Mac vừa khởi động; nếu không, có file clean_stop thì ghi nhận khởi động lại; nếu không thì cảnh báo bị dừng đột ngột bởi kill -9 hoặc crash.macsentryd khởi độngBoot time đã đổi?KERN_BOOTTIME với lastbootMac vừa khởi độngcảnh báocókhôngCó file clean_stop?do handler SIGTERM ghi raKhởi động lạichỉ thông báocókhôngCảnh báo: bị dừng đột ngộtkill -9 hoặc crash; launchd đã chạy lại
Hai dữ kiện lưu trước khi thoát, thời điểm khởi động máy và file đánh dấu ghi khi nhận SIGTERM, đủ để phân biệt ba trường hợp.

Lần dừng có kế hoạch không được trông như tấn công

Một công cụ giám sát kêu "có sói" mỗi lần cập nhật sẽ dạy bạn phớt lờ nó. Nên trình cài đặt để lại dấu trước khi đụng vào bất cứ thứ gì: updating (lần dừng này là cập nhật, không phải tắt máy), clean_stop (lần chạy tới không phải kill -9) và just_updated (thay đổi trên file của chính MacSentry trong mười phút tới chỉ báo im lặng thay vì cảnh báo). Trình cài đặt cũng chờ daemon cũ thoát hẳn rồi mới chạy bản mới, vì daemon cũ có thể vẫn đang gửi tin cuối cùng.

Cùng ý đó áp dụng cho việc build lại. Daemon ghi lại chữ ký mã (code signature) của các binary của chính nó. Bản build lại ký bằng cùng chứng chỉ chỉ được báo im lặng; binary đổi người ký, hoặc mất chữ ký, được báo là có thể đã bị tráo. Kiểm tra này cũng chạy cho file thực thi của mọi LaunchDaemon và LaunchAgent trong /Library: bản cập nhật thường giữ nguyên nhà phát hành, binary bị tráo thì thường không.

Hai tiến trình canh chừng lẫn nhau

MacSentry có hai phần chạy lâu dài, mỗi phần có điểm yếu khác nhau:

  • Daemon chạy bằng root từ lúc bật máy, trước khi ai đăng nhập. User thường không dừng được nó.
  • App thanh menu chạy bằng user. Nó là một LaunchAgent với KeepAlive đặt là {SuccessfulExit: false}, và không có mục Thoát.

Hai bên kiểm tra nhau. Daemon cảnh báo nếu không có app MacSentry nào chạy cho user đang đăng nhập; app cảnh báo nếu daemon hoặc helper Endpoint Security biến mất khoảng một phút, và báo lại khi chúng chạy trở lại.

Chi tiết SuccessfulExit quan trọng. launchd.plist(5) định nghĩa SuccessfulExit: false là "chạy lại job trừ khi nó thoát với mã 0". Khi một bản thứ hai của app khởi động (ví dụ mở từ Finder trong lúc bản do launchd chạy vẫn còn), nó phải thoát với mã khác 0. Thoát với mã 0 sẽ báo cho launchd rằng job đã kết thúc bình thường, và nếu chuyện đó xảy ra với bản duy nhất, sẽ chẳng còn bản nào chạy.

Để ý những lệnh dùng để tắt giám sát

Sự kiện Endpoint Security (xem bài đọc eslogger bằng Swift) cho MacSentry thấy được lệnh, không chỉ hậu quả của nó. Các quy tắc bắt:

  • launchctl bootout, unload hay disable nhắm vào MacSentry, Wazuh hay các agent EDR phổ biến, và pkill, killall tiến trình của chúng;
  • chflags gỡ cờ chỉ-ghi-thêm hay cờ bất biến, và log erase;
  • tắt SIP, Gatekeeper, firewall hay pf.

Mỗi cảnh báo kèm user và toàn bộ chuỗi tiến trình cha, nên bạn thấy được lệnh đến từ terminal của mình hay từ thứ gì đó bạn không nhận ra.

Log chỉ-ghi-thêm, và thứ nó không bảo vệ được

File log trên máy được tạo với cờ append-only của hệ thống:

chflags sappnd /var/log/macsentry.log

Theo chflags(1), chỉ super-user mới đặt được sappnd, và chflags(2) định nghĩa cờ này là "file chỉ được ghi thêm vào cuối". Khi cờ còn đó, các dòng cũ không sửa hay cắt bớt được, và file không xoá được, kể cả bằng root. Root vẫn có thể gỡ cờ trước, nên cách này chặn được tiến trình không phải root và việc sửa nhầm, chứ không chặn được một quản trị viên quyết tâm. Nhưng gỡ cờ chính là một trong các lệnh ở trên, nên nó bị báo trước khi log kịp bị động vào.

Khi bạn kiểm soát được nơi lưu trữ, một chuỗi mật mã là cách mạnh hơn: mình đã viết về một cách như vậy, với chuỗi hash và khoá HMAC đặt ngoài database, trong bài audit log chống sửa đổi trên PostgreSQL. Còn với một file trên chiếc máy có thể đã không còn đáng tin, đưa từng dòng ra khỏi máy ngay khi nó xảy ra quan trọng hơn việc bảo vệ bản sao tại chỗ.

Công tắc người chết lo những gì chiếc Mac không tự báo được

Có những chuyện kết thúc mà không còn ai để báo: máy bị xoá sạch, bị rút điện, daemon bị gỡ bởi người có root. Với những trường hợp đó, daemon ping một URL bên ngoài mỗi phút, ví dụ một check trên healthchecks.io. Dịch vụ đó báo cho bạn khi ping ngừng. Đây cũng là nguyên tắc "giám sát cả hệ thống giám sát" trong bài tự vận hành server cho dự án cá nhân: tín hiệu báo một thứ đã chết phải đến từ nơi không chết cùng nó.

Giữ bí mật ngoài tầm với của thứ mình đang canh

  • Token của daemon nằm trong file chỉ root đọc được và chỉ tồn tại trong bộ nhớ, không bao giờ xuất hiện trên dòng lệnh. Bản shell đời đầu truyền token cho curl qua stdin (curl -K -) cũng vì lý do đó: tham số dòng lệnh hiện ra trong ps.
  • Helper Endpoint Security không bao giờ giữ token. Nó ghi phát hiện vào một thư mục spool chỉ root đọc được, và daemon là bên gửi.
  • Diff của file và mọi thứ helper gửi đi đều được che trước khi rời khỏi máy: token bot Telegram, và giá trị đứng sau những tên như TOKEN= hay PASSWORD:.

Tóm tắt

Mối đe doạ MacSentry làm gì
Bằng chứng trên máy bị dọn Gửi mỗi phát hiện về Telegram ngay; thử lại lỗi tạm thời trong ~5 phút
Mã độc xoá cảnh báo của bot Bot riêng cho daemon, token chỉ root đọc được; nhắc khi dùng chung token
Daemon bị kill -9 Thời điểm khởi động máy cộng file đánh dấu dừng sạch; launchd chạy lại và việc đó được báo
Báo nhầm khi cập nhật File đánh dấu của trình cài đặt; build lại cùng người ký chỉ báo im lặng
App hoặc daemon bị dừng Hai bên canh nhau; app được launchd chạy lại trừ khi thoát mã khác 0
Lệnh tắt giám sát Quy tắc Endpoint Security kèm chuỗi tiến trình cha
Máy bị xoá sạch hay mất điện Heartbeat tới một dead man's switch bên ngoài

Không điều nào ở trên tự nó làm chiếc Mac an toàn. MacSentry là một sợi dây báo động, đặt cạnh FileVault, cập nhật thường xuyên và một EDR nếu bạn có. Thứ những chi tiết này mang lại là: chính việc cắt sợi dây cũng là thứ bạn sẽ nghe thấy.

Nếu bạn từng tìm cách làm cho một công cụ giám sát trên máy khó bị tắt lặng lẽ, mình rất muốn được trao đổi. Email của mình ở trang chủ.