Từ script Bash quét liên tục sang daemon Swift chạy theo sự kiện trên macOS

Daemon bảo mật macOS từ ~4,5% xuống ~0,4% một nhân: API thay fork, FSEvents và IOKit, lọc trước bằng ctime, và netstat lặng lẽ ẩn socket.

· Đăng ngày · Cập nhật · 8 phút đọc · Phiên bản: macOS 26, Swift 6 · Về dự án →

Phiên bản đầu của daemon root trong MacSentry là một script Bash dài 1.110 dòng, quét hệ thống trong một vòng lặp. Nó chạy được, nhưng trên chiếc Mac dev Apple M4 của mình, có lúc đo được khoảng 10% một nhân CPU, và kể cả sau hai vòng tối ưu vẫn còn khoảng 4,5%, chạy suốt cả ngày. Bản viết lại bằng Swift chỉ còn khoảng 0,4%. Bài này kể những gì thật sự tạo ra khác biệt: fork, polling, phát hiện thay đổi với chi phí thấp, để macOS hạ ưu tiên cho daemon, và một hành vi không có trong tài liệu của netstat khiến kết nối biến mất một cách im lặng.

Các con số lấy từ lịch sử commit và được đo trên đúng một máy đó. Hãy xem chúng như bậc độ lớn, không phải benchmark.

Đo từng kiểm tra trước khi viết lại bất cứ thứ gì

Trước khi viết lại, bản Bash đã qua hai vòng tối ưu, và cả hai đều dựa trên việc đo thời gian từng kiểm tra thay vì đoán:

  • Bỏ qua việc khi không có gì thay đổi (chi tiết ở dưới) giảm thời gian chạy các kiểm tra mỗi vòng từ khoảng 16,6 giây xuống 8,1 giây.
  • Một đường đi nhanh không fork khi danh sách không đổi, bỏ các lần fork date và cksum, chỉ chạy lsof cho chương trình đáng ngờ, quét thưa hơn cho kiểm tra ít khi đổi, và bỏ lệnh gọi wazuh-control mỗi 30 giây đã đưa daemon từ 12,0 giây CPU mỗi 120 giây xuống 4,6 giây, tức từ khoảng 10% một nhân xuống khoảng 4%.

Hai vòng đó cho thấy chi phí nằm ở đâu, và phần còn lại là chi phí mang tính cấu trúc: một script shell không đọc được bảng tiến trình hay chờ sự kiện mà không fork thêm thứ gì đó.

Chi phí nằm ở fork, nên hãy đọc dữ liệu qua API

Mỗi lần gọi ps, lsof hay codesign là khởi động một tiến trình, nạp nó, rồi nhận về chữ để script parse tiếp bằng... thêm tiến trình khác. Daemon Swift đọc thẳng cùng dữ liệu đó:

Shell Daemon Swift
ps proc_listallpids, proc_pidinfo, sysctl(KERN_PROCARGS2) để lấy tham số
lsof -i proc_pidinfo(PROC_PIDLISTFDS) và proc_pidfdinfo(PROC_PIDFDSOCKETINFO)
who getutxent (utmpx)
codesign -dv SecStaticCodeCreateWithPath, SecCodeCopySigningInformation
ioreg, pmset IOKit
defaults read, launchctl Đọc thẳng file plist bằng PropertyListSerialization

Lệnh ngoài vẫn được dùng ở chỗ không có API, như netstat, profiles, kmutil và sfltool. Chúng chạy thưa, và lệnh chậm chạy trên một hàng đợi ưu tiên thấp riêng để không bao giờ chặn vòng lặp chính. Riêng sfltool dumpbtm đã mất 3–5 giây.

Đăng ký sự kiện thay vì quét định kỳ

Polling nghĩa là trả giá mỗi vài giây chỉ để biết rằng không có gì thay đổi. Daemon giờ đăng ký những gì macOS vốn đã thông báo, và mỗi callback chỉ đẩy việc vào hàng đợi:

  • FSEvents cho LaunchAgent và LaunchDaemon, /etc, ~/.ssh và các thư mục hay bị cài cắm khác.
  • IOKit cho thiết bị USB (IOServiceAddMatchingNotification), thay đổi nguồn điện, và ngủ, thức (IORegisterForSystemPower).
  • DiskArbitration cho ổ đĩa ngoài, và SCDynamicStore cho thay đổi mạng, proxy và DNS.
  • Một dispatch source trên thư mục spool cho tin nhắn từ helper Endpoint Security.

Có hai chi tiết IOKit rất dễ sai. Một matching notification bắn một lần, rồi chỉ được "lên dây" lại sau khi bạn rút hết iterator bằng IOIteratorNext, kể cả ngay sau khi đăng ký. Và một callback nguồn điện khi nhận câu hỏi "máy có được ngủ không?" hay "máy sắp ngủ" phải trả lời bằng IOAllowPowerChange, nếu không macOS sẽ chờ nó trước khi ngủ.

Sự kiện cũng đến theo đợt: cắm một hub USB sinh ra vài thông báo. Một bộ debounce nhỏ gộp chúng thành một lần kiểm tra sau một giây.

Phần còn lại là một timer 2 giây cho những thứ không có sự kiện, như kết nối TCP mới, và quét dự phòng cho mọi thứ khác với nhịp thưa hơn: 10 giây, 30 giây, một phút, năm phút. Khi helper Endpoint Security đang chạy, nó đã báo đăng nhập SSH và lệnh reverse shell ngay khi xảy ra, nên timer quét những thứ đó thưa hơn nữa.

netstat ẩn socket khi được gọi từ binary không phải của Apple

Chuyện này mình không tìm thấy trong tài liệu nào, nên hãy xem nó là hành vi quan sát được trên các bản macOS gần đây. Khi netstat -an -p tcp được khởi động bởi một binary không phải của Apple, kể cả dưới dạng tiến trình con, socket thuộc tiến trình khác bị loại khỏi kết quả. Không có lỗi nào được báo, danh sách chỉ đơn giản là ngắn hơn.

Hai thay đổi giúp đi vòng qua chuyện này:

  • Gộp hai nguồn. Daemon đọc socket theo từng tiến trình qua libproc, thấy được tất cả khi chạy bằng root, đồng thời vẫn parse netstat, thứ thấy được socket không thuộc tiến trình nào, như kết nối mà launchd giữ hộ sshd trước khi nó khởi động. Một kết nối được tính nếu nguồn nào báo cũng được.
  • Để launchd chạy netstat hộ app. Khi app thanh menu phải tự quét (không có daemon), nó đăng ký một job launchd nhỏ chạy netstat mỗi hai giây và ghi kết quả ra file. Job do launchd khởi động không bị ảnh hưởng, còn app chỉ đọc file. Khi daemon đang chạy, nó công bố kết quả quét của mình trong một file ai cũng đọc được dưới /var/run, nên app không cần quét gì cả.

Bài học chung: khi kết quả của một công cụ trông hợp lý nhưng thiếu, hãy đối chiếu với một nguồn thứ hai trước khi tin.

Phát hiện thay đổi với chi phí thấp, rồi mới làm việc nặng

Phần lớn kiểm tra đều hỏi "tập file này có đổi không?". Băm lại mọi file mỗi lần là lãng phí, nên daemon so trước một chữ ký rẻ: ctime, kích thước và đường dẫn của từng file. Chỉ khi chữ ký đó khác, nó mới băm, so khác biệt và cảnh báo.

Dùng ctime thay vì mtime là có chủ ý. utimes(2) cho phép chủ file đặt thời gian truy cập và sửa đổi thành bất kỳ giá trị nào, nhưng thời gian thay đổi inode luôn bị đặt thành thời điểm hiện tại. Vì vậy một file đã bị sửa không thể giả vờ chưa ai đụng tới bằng cách trả lại timestamp cũ.

Thêm hai quy tắc để tránh bão cảnh báo:

  • Lần chạy đầu chỉ ghi mốc. Cài MacSentry không nên báo về mọi LaunchAgent vốn đã có sẵn. Tương tự khi định dạng trạng thái của daemon thay đổi: một dấu phiên bản khiến daemon lấy mốc lại thay vì bắn ra hàng loạt mục "mới" giả.
  • Đọc ra rỗng là lỗi, không phải "mọi thứ đã bị xoá". Những danh sách không bao giờ rỗng thật (user, ứng dụng) sẽ bỏ qua lượt đó nếu đọc ra rỗng, và một profile trình duyệt không đọc được thì bị bỏ qua thay vì bị ghi thành mốc rỗng.

Để macOS hạ ưu tiên cho daemon

Công cụ giám sát bảo mật không nên tranh tài nguyên với người dùng. Cả hai job root được khai báo là việc chạy nền trong plist LaunchDaemon:

<key>ProcessType</key><string>Background</string>
<key>LowPriorityIO</key><true/>

launchd.plist(5) mô tả job Background là "tiến trình làm những việc người dùng không trực tiếp yêu cầu", có áp giới hạn tài nguyên, còn LowPriorityIO đánh dấu I/O hệ thống file của daemon là ưu tiên thấp. Đánh đổi ở đây là rõ ràng: khi máy bận, cảnh báo có thể tới trễ vài giây.

Một hàng đợi tuần tự xoá được cả một loại lỗi

Mọi kiểm tra và mọi trạng thái đều chạy trên một dispatch queue tuần tự. Callback sự kiện, timer và bộ theo dõi spool chỉ đẩy việc vào đó. Nhờ vậy không cần khoá nào cả, điều rất đáng giá với một chương trình mà toàn bộ công việc là so "trước" với "sau".

Nó cũng xoá luôn những lỗi khó chịu nhất của bản Bash. Hàm shell dùng chung biến trừ khi mọi biến cục bộ đều được khai báo, và có hai hàm đã quên: một hàm đọc dòng log mới ghi đè biến của vòng lặp, khiến cảnh báo về app điều khiển từ xa hiện sai đường dẫn log, còn hàm chống spam thì ghi đè một biến trong kiểm tra kết nối. Không lỗi nào báo ra thông báo gì.

Kết quả

Daemon Bash Bash đã tối ưu Daemon Swift
CPU mỗi 120 giây 12,0 giây 4,6 giây —
Tỉ lệ một nhân ~10% ~4–4,5% ~0,4%
Phát hiện thay đổi Polling Polling có lọc trước bằng ctime Sự kiện, polling chỉ để dự phòng
Dữ liệu tiến trình, socket ps, lsof, who Như trên, gọi ít hơn libproc, sysctl, utmpx
Biểu đồ cột tỉ lệ một nhân CPU mà daemon dùng: khoảng 10% với daemon Bash, khoảng 4,5% sau khi tối ưu, và khoảng 0,4% với daemon Swift.Daemon Bash~10%Bash đã tối ưu~4,5%Daemon Swift~0,4%
Đo trên một máy Mac. Bản viết lại giảm khoảng mười lần, chủ yếu nhờ không fork và không quét định kỳ.

Trên cùng chiếc máy đó, toàn bộ MacSentry giờ dùng khoảng 0,4–0,8% một nhân cho daemon, khoảng 2% cho helper Endpoint Security khi bật theo dõi exec, và khoảng 0,2% cho app thanh menu. Bài Endpoint Security qua eslogger kể cách helper giữ phần của nó ở mức thấp.

Viết lại là đáng, nhưng phần lớn hiệu quả đến từ hai ý dùng được ở mọi ngôn ngữ: đừng khởi động một tiến trình để đọc dữ liệu mà kernel đưa thẳng được cho bạn, và đừng hỏi "có gì thay đổi không?" khi hệ thống có thể tự báo cho bạn.

Tóm tắt

  • Đo thời gian từng kiểm tra trước khi viết lại; hai vòng tối ưu daemon Bash cho thấy chi phí còn lại nằm ở cấu trúc, không phải ở một hàm chậm.
  • Fork là chi phí chính, nên đọc dữ liệu tiến trình, socket, đăng nhập và chữ ký qua libproc, sysctl, utmpx, Security và IOKit thay vì phân tích output của lệnh.
  • Đăng ký sự kiện qua FSEvents, IOKit, DiskArbitration và SCDynamicStore thay vì quét định kỳ, gộp các đợt sự kiện dồn dập, và chỉ giữ quét chậm làm phương án dự phòng.
  • netstat được khởi động từ binary không phải của Apple lặng lẽ bỏ sót socket của tiến trình khác trên các bản macOS gần đây, theo những gì mình quan sát được, nên hãy đối chiếu với một nguồn thứ hai trước khi tin output của nó.
  • So một chữ ký rẻ gồm ctime, kích thước và đường dẫn trước khi băm, lần chạy đầu chỉ ghi baseline, và coi kết quả đọc rỗng là lỗi.
  • Khai báo daemon là Background kèm LowPriorityIO, và chạy mọi kiểm tra cùng trạng thái trên một hàng đợi tuần tự để không cần khoá.

Nếu bạn từng chuyển một daemon quét định kỳ sang dùng sự kiện trên macOS, hoặc gặp công cụ khác lặng lẽ trả về output thiếu, mình rất muốn được trao đổi. Email của mình ở trang chủ.